1use std::{
2 cmp::Ordering,
3 collections::{BTreeMap, BTreeSet},
4 marker::PhantomData,
5 path::{Path, PathBuf},
6 sync::{Arc, RwLock},
7 time::Instant,
8};
9
10use iroh_db_blobs::{BlobEngine, BlobError, BlobRef, BlobStream, FetchScheduler, IrohBlobStore};
11use iroh_db_core::{
12 AuthorId, BlobHash, CapabilityId, CollectionId, CommitBody, CommitCodecError, CommitEnvelope,
13 CommitHeader, CommitId, Dot, EqualityFilter, IrohRecord, MigrationId, Operation, OperationKind,
14 RangeFilter, RecordError, RecordKey, SchemaActivation, SchemaId, SchemaStage, TransactionId,
15 TypedField, VersionVector, staging_digest,
16};
17use iroh_db_security::{
18 AuthorityError, CapabilityCertificate, ConsistencyMode, ControlTransition, DomainDescriptor,
19 DomainSeed, Invitation, InvitationError, InvitationSecret, InvitationTicket, KeyProvider,
20 LocalCredentials, Permission, PermissionSet, SecurityError, open_commit, seal_commit,
21};
22use iroh_db_store::{
23 ApplyOutcome, BlobFuture, BlobStore, BlobStoreError, IndexValue, MaterializedError,
24 MaterializedRecord, RecordHead, RecordHeadSet, SnapshotBaseline, Store, StoreError,
25 StoredRecord,
26};
27use tokio::io::AsyncRead;
28use tokio::sync::{Mutex, RwLock as AsyncRwLock, broadcast};
29
30use crate::telemetry::Telemetry;
31
32const CHANGE_CAPACITY: usize = 1_024;
33const MAX_RECONCILE_COMMITS: usize = 65_536;
34
35#[derive(Debug, thiserror::Error)]
37pub enum DbError {
38 #[error(transparent)]
40 Store(#[from] StoreError),
41 #[error(transparent)]
43 Record(#[from] RecordError),
44 #[error(transparent)]
46 Migration(#[from] MigrationError),
47 #[error(transparent)]
49 Materialized(#[from] MaterializedError),
50 #[error(transparent)]
52 Security(#[from] SecurityError),
53 #[error(transparent)]
55 Authority(#[from] AuthorityError),
56 #[error(transparent)]
58 Invitation(#[from] InvitationError),
59 #[error(transparent)]
61 Commit(#[from] CommitCodecError),
62 #[error(transparent)]
64 MigrationCodec(#[from] iroh_db_core::MigrationCodecError),
65 #[error("cryptographic randomness is unavailable")]
67 RandomnessUnavailable,
68 #[error("local author sequence is exhausted")]
70 SequenceExhausted,
71 #[error("transaction contains no operations")]
73 EmptyTransaction,
74 #[error("database cannot close while collection or transaction handles remain")]
76 HandlesOpen,
77 #[error("query predicate is invalid: {0}")]
79 InvalidQuery(RecordError),
80 #[error("invalid pagination cursor: {0}")]
82 InvalidCursor(String),
83 #[error(transparent)]
85 Blob(#[from] BlobError),
86 #[error("snapshot validation failed: {0}")]
88 InvalidSnapshot(String),
89 #[error("device is not authorized for this domain")]
91 UnauthorizedDevice,
92 #[error("commit {0} was authored before the active epoch cut and requires rebase")]
94 RebaseRequired(CommitId),
95 #[error("domain handle epoch {handle} is stale; active epoch is {active}")]
97 StaleEpoch { handle: u64, active: u64 },
98 #[error("offline invitation ticket was already consumed")]
100 InvitationAlreadyUsed,
101 #[error("record schema is not registered for collection {0}")]
103 UnknownRecordSchema(CollectionId),
104 #[error(
106 "collection {collection_id} requires a schema migration from version {stored_version} to {requested_version}"
107 )]
108 SchemaMigrationRequired {
109 collection_id: CollectionId,
111 stored_version: u32,
113 requested_version: u32,
115 },
116 #[error("collection {0} is frozen by incompatible schema activations")]
118 SchemaMigrationFrozen(CollectionId),
119 #[error("remote commit operation is inconsistent: {0}")]
121 InvalidRemoteCommit(String),
122 #[error("synchronization failed: {0}")]
124 Network(String),
125 #[error("operation failed: {0}")]
127 Operation(String),
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
132pub enum MigrationError {
133 #[error("invalid migration registration: {0}")]
135 InvalidRegistration(String),
136 #[error("migration record is invalid: {0}")]
138 InvalidRecord(String),
139 #[error("application migration failed: {0}")]
141 Application(String),
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Default)]
146pub struct MigrationReport {
147 staged: usize,
148 already_staged: usize,
149 activated: usize,
150 already_activated: usize,
151 activation_commits: Vec<CommitId>,
152}
153
154impl MigrationReport {
155 pub const fn staged(&self) -> usize {
157 self.staged
158 }
159
160 pub const fn already_staged(&self) -> usize {
162 self.already_staged
163 }
164
165 pub const fn activated(&self) -> usize {
167 self.activated
168 }
169
170 pub const fn already_activated(&self) -> usize {
172 self.already_activated
173 }
174
175 pub fn activation_commits(&self) -> &[CommitId] {
177 &self.activation_commits
178 }
179
180 fn absorb(&mut self, step: MigrationReport) {
181 self.staged += step.staged;
182 self.already_staged += step.already_staged;
183 self.activated += step.activated;
184 self.already_activated += step.already_activated;
185 self.activation_commits.extend(step.activation_commits);
186 }
187}
188
189impl MigrationError {
190 pub fn application(message: impl Into<String>) -> Self {
192 Self::Application(message.into())
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Change {
199 collection_id: CollectionId,
200 record_id: Vec<u8>,
201 kind: ChangeKind,
202}
203
204impl Change {
205 pub const fn collection_id(&self) -> CollectionId {
207 self.collection_id
208 }
209
210 pub fn record_id(&self) -> &[u8] {
212 &self.record_id
213 }
214
215 pub const fn kind(&self) -> ChangeKind {
217 self.kind
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum ChangeKind {
224 Upserted,
226 Deleted,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct ChangeBatch {
233 commit_id: CommitId,
234 sequence: u64,
235 frontier: Vec<CommitId>,
236 changes: Vec<Change>,
237}
238
239impl ChangeBatch {
240 pub const fn commit_id(&self) -> CommitId {
242 self.commit_id
243 }
244
245 pub const fn sequence(&self) -> u64 {
247 self.sequence
248 }
249
250 pub fn frontier(&self) -> &[CommitId] {
252 &self.frontier
253 }
254
255 pub fn changes(&self) -> &[Change] {
257 &self.changes
258 }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
263pub enum SubscriptionError {
264 #[error("subscription is closed")]
266 Closed,
267 #[error("subscription lagged by {0} commit batches; re-query required")]
269 Lagged(u64),
270}
271
272#[derive(Clone)]
274pub struct IrohDb {
275 pub(crate) inner: Arc<DbInner>,
276 pub(crate) domain: Arc<RwLock<DomainSeed>>,
277}
278
279pub struct IrohDbBuilder {
282 root: PathBuf,
283 migrations: Vec<MigrationStep>,
284 run_migrations_on_open: bool,
285 key_provider: Option<Arc<dyn KeyProvider>>,
286}
287
288#[derive(Clone)]
289struct MigrationStep {
290 from: iroh_db_core::SchemaDescriptor,
291 to: iroh_db_core::SchemaDescriptor,
292 register_from: fn(&IrohDb),
293 register_to: fn(&IrohDb),
294 transform: Arc<MigrationTransform>,
295}
296
297type MigrationTransform =
298 dyn Fn(&[u8]) -> Result<PreparedRecord, MigrationError> + Send + Sync + 'static;
299
300pub(crate) struct DbInner {
301 pub(crate) store: Store,
302 pub(crate) blobs: Arc<IrohBlobStore>,
303 pub(crate) blob_scheduler: FetchScheduler,
304 pub(crate) credentials: Arc<dyn KeyProvider>,
305 pub(crate) writer: Mutex<()>,
306 pub(crate) control_gate: AsyncRwLock<()>,
307 domains: RwLock<BTreeMap<iroh_db_core::DomainId, Arc<RwLock<DomainSeed>>>>,
308 changes: RwLock<BTreeMap<iroh_db_core::DomainId, broadcast::Sender<ChangeBatch>>>,
309 adapters: RwLock<BTreeMap<(CollectionId, SchemaId), RecordAdapter>>,
310 migrations: RwLock<Vec<MigrationStep>>,
311 pub(crate) telemetry: Arc<Telemetry>,
312}
313
314#[derive(Clone)]
315struct DomainBlobStore {
316 blobs: Arc<IrohBlobStore>,
317 metadata: Store,
318 domain_id: iroh_db_core::DomainId,
319}
320
321impl BlobStore for DomainBlobStore {
322 fn put(&self, bytes: Vec<u8>) -> BlobFuture<'_, BlobHash> {
323 Box::pin(async move {
324 let hash = self.blobs.put(bytes).await?;
325 self.metadata
326 .claim_blob(self.domain_id, hash)
327 .map_err(blob_metadata_error)?;
328 Ok(hash)
329 })
330 }
331
332 fn get(&self, hash: BlobHash) -> BlobFuture<'_, Option<Vec<u8>>> {
333 self.blobs.get(hash)
334 }
335
336 fn list(&self) -> BlobFuture<'_, Vec<BlobHash>> {
337 Box::pin(async move {
338 let mut owned = Vec::new();
339 for hash in self.blobs.list().await? {
340 if self
341 .metadata
342 .owns_blob(self.domain_id, hash)
343 .map_err(blob_metadata_error)?
344 {
345 owned.push(hash);
346 }
347 }
348 Ok(owned)
349 })
350 }
351
352 fn claim(&self, hash: BlobHash) -> BlobFuture<'_, ()> {
353 Box::pin(async move {
354 self.metadata
355 .claim_blob(self.domain_id, hash)
356 .map_err(blob_metadata_error)
357 })
358 }
359}
360
361#[derive(Clone, Copy)]
362struct RecordAdapter {
363 merge: fn(Option<&[u8]>, &MaterializedRecord) -> Result<MaterializedRecord, DbError>,
364 canonicalize: fn(
365 &MaterializedRecord,
366 AuthorId,
367 u64,
368 &VersionVector,
369 &mut u32,
370 ) -> Result<MaterializedRecord, DbError>,
371}
372
373struct QuarantineMetadata {
374 frontier: Vec<CommitId>,
375 author_sequences: Vec<(AuthorId, u64)>,
376 author_heads: Vec<(AuthorId, u64, CommitId)>,
377}
378
379impl DbInner {
380 pub(crate) fn has_record_adapters(&self) -> bool {
381 !self
382 .adapters
383 .read()
384 .expect("record adapter lock is not poisoned")
385 .is_empty()
386 }
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum RemoteApply {
392 Applied(CommitId),
394 Duplicate(CommitId),
396 SchemaRebaseRequired {
398 commit: CommitId,
400 },
401 Quarantined {
403 author: AuthorId,
405 sequence: u64,
407 },
408}
409
410#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct DeviceGroup {
413 label: String,
414 members: Vec<AuthorId>,
415}
416
417impl DeviceGroup {
418 pub fn label(&self) -> &str {
419 &self.label
420 }
421
422 pub fn members(&self) -> &[AuthorId] {
423 &self.members
424 }
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
429pub struct ProjectionReport {
430 selected: usize,
431 commit: Option<CommitId>,
432}
433
434impl ProjectionReport {
435 pub const fn selected(&self) -> usize {
436 self.selected
437 }
438
439 pub const fn commit(&self) -> Option<CommitId> {
440 self.commit
441 }
442}
443
444impl IrohDbBuilder {
445 #[must_use]
448 pub fn key_provider(mut self, provider: Arc<dyn KeyProvider>) -> Self {
449 self.key_provider = Some(provider);
450 self
451 }
452
453 #[must_use]
455 pub const fn defer_migrations(mut self) -> Self {
456 self.run_migrations_on_open = false;
457 self
458 }
459
460 pub fn migration<From, To>(
464 mut self,
465 transform: fn(From) -> Result<To, MigrationError>,
466 ) -> Result<Self, MigrationError>
467 where
468 From: IrohRecord + 'static,
469 To: IrohRecord<Id = From::Id> + 'static,
470 {
471 let from = From::schema().map_err(|error| {
472 MigrationError::InvalidRegistration(format!("source schema: {error}"))
473 })?;
474 let to = To::schema().map_err(|error| {
475 MigrationError::InvalidRegistration(format!("target schema: {error}"))
476 })?;
477 if from.collection_id() != to.collection_id() {
478 return Err(MigrationError::InvalidRegistration(
479 "source and target collection IDs differ".into(),
480 ));
481 }
482 if from.version().checked_add(1) != Some(to.version()) {
483 return Err(MigrationError::InvalidRegistration(
484 "schema versions must be adjacent and increasing".into(),
485 ));
486 }
487 if self
488 .migrations
489 .iter()
490 .any(|step| step.from.schema_id() == from.schema_id())
491 {
492 return Err(MigrationError::InvalidRegistration(
493 "source schema already has a registered successor".into(),
494 ));
495 }
496 let migration = move |state: &[u8]| {
497 let source = From::decode_record(state)
498 .map_err(|error| MigrationError::InvalidRecord(error.to_string()))?;
499 let source_id = source
500 .record_id()
501 .map_err(|error| MigrationError::InvalidRecord(error.to_string()))?;
502 let target = transform(source)?;
503 let target_id = target
504 .record_id()
505 .map_err(|error| MigrationError::InvalidRecord(error.to_string()))?;
506 if source_id != target_id {
507 return Err(MigrationError::InvalidRecord(
508 "migration changed the canonical primary key".into(),
509 ));
510 }
511 prepare_upsert(&target)
512 .map_err(|error| MigrationError::InvalidRecord(error.to_string()))
513 };
514 self.migrations.push(MigrationStep {
515 from,
516 to,
517 register_from: register_record_adapter::<From>,
518 register_to: register_record_adapter::<To>,
519 transform: Arc::new(migration),
520 });
521 Ok(self)
522 }
523
524 pub async fn open(self) -> Result<IrohDb, DbError> {
527 self.open_with_report().await.map(|(database, _)| database)
528 }
529
530 pub async fn open_with_report(mut self) -> Result<(IrohDb, MigrationReport), DbError> {
532 self.migrations
533 .sort_by_key(|step| (step.from.collection_id(), step.from.version()));
534 let database = if let Some(provider) = self.key_provider {
535 IrohDb::open_with_key_provider(&self.root, provider).await?
536 } else {
537 IrohDb::open(&self.root).await?
538 };
539 for step in &self.migrations {
540 (step.register_from)(&database);
541 (step.register_to)(&database);
542 }
543 *database
544 .inner
545 .migrations
546 .write()
547 .expect("migration registry lock is not poisoned") = self.migrations;
548 let report = if self.run_migrations_on_open {
549 database.run_migrations().await?
550 } else {
551 MigrationReport::default()
552 };
553 Ok((database, report))
554 }
555}
556
557impl IrohDb {
558 pub fn builder(root: impl AsRef<Path>) -> IrohDbBuilder {
560 IrohDbBuilder {
561 root: root.as_ref().to_path_buf(),
562 migrations: Vec::new(),
563 run_migrations_on_open: true,
564 key_provider: None,
565 }
566 }
567
568 pub async fn open(root: impl AsRef<Path>) -> Result<Self, DbError> {
570 let root = root.as_ref();
571 std::fs::create_dir_all(root).map_err(|error| StoreError::Io(error.to_string()))?;
572 let credentials = Arc::new(LocalCredentials::load_or_create(root.join("keys"))?);
573 Self::open_with_key_provider(root, credentials).await
574 }
575
576 pub async fn open_with_key_provider(
578 root: impl AsRef<Path>,
579 credentials: Arc<dyn KeyProvider>,
580 ) -> Result<Self, DbError> {
581 let root = root.as_ref();
582 std::fs::create_dir_all(root).map_err(|error| StoreError::Io(error.to_string()))?;
583 let blobs = Arc::new(
584 IrohBlobStore::load(root.join("blobs"))
585 .await
586 .map_err(StoreError::from)?,
587 );
588 let store = Store::open(root, blobs.clone())?;
589 reconcile_domain_epochs(credentials.as_ref(), &store)?;
590 store.trust_author(credentials.domain_id(), credentials.signer().author())?;
591 let domain = credentials.domain_seed();
592 credentials.store_domain(&domain)?;
593 let domain = Arc::new(RwLock::new(domain));
594 let (changes, _) = broadcast::channel(CHANGE_CAPACITY);
595 let mut domain_changes = BTreeMap::new();
596 domain_changes.insert(
597 domain
598 .read()
599 .expect("domain seed lock is not poisoned")
600 .domain_id(),
601 changes,
602 );
603 let mut domains = BTreeMap::new();
604 domains.insert(
605 domain
606 .read()
607 .expect("domain seed lock is not poisoned")
608 .domain_id(),
609 domain.clone(),
610 );
611 Ok(Self {
612 inner: Arc::new(DbInner {
613 store,
614 blobs,
615 blob_scheduler: FetchScheduler::default(),
616 credentials,
617 writer: Mutex::new(()),
618 control_gate: AsyncRwLock::new(()),
619 domains: RwLock::new(domains),
620 changes: RwLock::new(domain_changes),
621 adapters: RwLock::new(BTreeMap::new()),
622 migrations: RwLock::new(Vec::new()),
623 telemetry: Arc::new(Telemetry::default()),
624 }),
625 domain,
626 })
627 }
628
629 pub async fn create_in_domain(
631 root: impl AsRef<Path>,
632 seed: &DomainSeed,
633 ) -> Result<Self, DbError> {
634 let root = root.as_ref();
635 std::fs::create_dir_all(root).map_err(|error| StoreError::Io(error.to_string()))?;
636 LocalCredentials::create_for_domain(root.join("keys"), seed)?;
637 Self::open(root).await
638 }
639
640 pub fn collection<Record: IrohRecord>(&self) -> Collection<Record> {
642 register_record_adapter::<Record>(self);
643 Collection {
644 db: self.clone(),
645 marker: PhantomData,
646 }
647 }
648
649 pub fn transaction(&self) -> Transaction {
651 Transaction {
652 db: self.clone(),
653 prepared: Vec::new(),
654 }
655 }
656
657 pub fn blobs(&self) -> Blobs {
659 Blobs { db: self.clone() }
660 }
661
662 pub fn author_id(&self) -> AuthorId {
664 self.inner.credentials.signer().author()
665 }
666
667 pub fn domain_seed(&self) -> DomainSeed {
669 self.domain
670 .read()
671 .expect("domain seed lock is not poisoned")
672 .clone()
673 }
674
675 pub fn domain_id(&self) -> iroh_db_core::DomainId {
677 self.domain
678 .read()
679 .expect("domain seed lock is not poisoned")
680 .domain_id()
681 }
682
683 pub fn attach_domain(&self, seed: &DomainSeed) -> Result<Self, DbError> {
685 self.inner.credentials.store_domain(seed)?;
686 self.inner
687 .store
688 .trust_author(seed.domain_id(), self.author_id())?;
689 self.ensure_change_channel(seed.domain_id());
690 self.domain(seed.domain_id())
691 }
692
693 pub fn create_domain(&self, mode: ConsistencyMode) -> Result<Self, DbError> {
695 let seed = DomainSeed::generate()?;
696 let domain = self.attach_domain(&seed)?;
697 let mut descriptor_nonce = [0_u8; 32];
698 let mut capability_nonce = [0_u8; 32];
699 getrandom::fill(&mut descriptor_nonce).map_err(|_| DbError::RandomnessUnavailable)?;
700 getrandom::fill(&mut capability_nonce).map_err(|_| DbError::RandomnessUnavailable)?;
701 let signer = self.inner.credentials.signer();
702 let descriptor =
703 DomainDescriptor::issue(seed.domain_id(), mode, &signer, descriptor_nonce)?;
704 let root = CapabilityCertificate::issue_root(
705 seed.domain_id(),
706 PermissionSet::all(),
707 &signer,
708 capability_nonce,
709 )?;
710 self.inner.store.bootstrap_domain(&descriptor, &root)?;
711 Ok(domain)
712 }
713
714 pub fn domain_descriptor(&self) -> Result<Option<DomainDescriptor>, DbError> {
716 self.inner
717 .store
718 .domain_descriptor(self.domain_id())
719 .map_err(DbError::from)
720 }
721
722 pub fn rotate_epoch(
724 &self,
725 revoked_subjects: &[AuthorId],
726 ) -> Result<(Self, ControlTransition), DbError> {
727 let controller = self.inner.store.current_controller(self.domain_id())?;
728 self.rotate_epoch_with_controller(revoked_subjects, controller)
729 }
730
731 pub fn rotate_epoch_with_controller(
733 &self,
734 revoked_subjects: &[AuthorId],
735 next_controller: AuthorId,
736 ) -> Result<(Self, ControlTransition), DbError> {
737 let _control_writer = self
738 .inner
739 .control_gate
740 .try_write()
741 .map_err(|_| DbError::Operation("control plane is busy".into()))?;
742 let active_epoch = self.inner.store.current_epoch(self.domain_id())?;
743 if self.epoch() != active_epoch {
744 return Err(DbError::StaleEpoch {
745 handle: self.epoch(),
746 active: active_epoch,
747 });
748 }
749 let author_capability = self
750 .inner
751 .store
752 .active_capability(self.domain_id(), self.author_id())?
753 .ok_or(DbError::UnauthorizedDevice)?;
754 if self.inner.store.current_controller(self.domain_id())? != self.author_id() {
755 return Err(DbError::UnauthorizedDevice);
756 }
757 let head = self.inner.store.control_head(self.domain_id())?;
758 let sequence = head.as_ref().map_or(Ok(1), |value| {
759 value
760 .sequence()
761 .checked_add(1)
762 .ok_or(DbError::SequenceExhausted)
763 })?;
764 let predecessor = head.as_ref().map(ControlTransition::id).transpose()?;
765 let next_seed = self.domain_seed().next_epoch()?;
766 let writer = self
767 .inner
768 .store
769 .current_writer(self.domain_id())?
770 .ok_or(DbError::UnauthorizedDevice)?;
771 let next_controller_capability = self
772 .inner
773 .store
774 .active_capability(self.domain_id(), next_controller)?
775 .ok_or(DbError::UnauthorizedDevice)?;
776 let mut nonce = [0_u8; 32];
777 getrandom::fill(&mut nonce).map_err(|_| DbError::RandomnessUnavailable)?;
778 let control = ControlTransition::issue(
779 self.domain_id(),
780 sequence,
781 predecessor,
782 active_epoch,
783 self.inner.store.frontier(self.domain_id())?,
784 revoked_subjects.to_vec(),
785 writer,
786 next_controller,
787 next_controller_capability,
788 next_seed.distribution_digest(),
789 author_capability,
790 nonce,
791 &self.inner.credentials.signer(),
792 )?;
793 self.inner.store.observe_control(&control)?;
794 self.inner.credentials.stage_domain(&next_seed)?;
795 if let Err(error) = self.inner.store.apply_control(&control) {
796 return Err(DbError::Store(error));
797 }
798 self.inner.credentials.activate_domain(&next_seed)?;
799 self.set_domain_seed(&next_seed);
800 Ok((self.domain(self.domain_id())?, control))
801 }
802
803 pub fn apply_epoch_rotation(
805 &self,
806 control: &ControlTransition,
807 next_seed: &DomainSeed,
808 ) -> Result<Self, DbError> {
809 self.apply_epoch_rotation_with_capabilities(control, next_seed, &[])
810 }
811
812 pub(crate) fn apply_epoch_rotation_with_capabilities(
813 &self,
814 control: &ControlTransition,
815 next_seed: &DomainSeed,
816 successor_chain: &[CapabilityCertificate],
817 ) -> Result<Self, DbError> {
818 let _control_writer = self
819 .inner
820 .control_gate
821 .try_write()
822 .map_err(|_| DbError::Operation("control plane is busy".into()))?;
823 if control.domain_id() != self.domain_id()
824 || next_seed.domain_id() != self.domain_id()
825 || next_seed.epoch() != control.new_epoch()
826 || next_seed.distribution_digest() != control.key_distribution_digest()
827 || control.revoked_subjects().contains(&self.author_id())
828 {
829 return Err(DbError::UnauthorizedDevice);
830 }
831 if successor_chain.is_empty() {
832 self.inner.store.observe_control(control)?;
833 }
834 self.inner.credentials.stage_domain(next_seed)?;
835 let apply_result = if successor_chain.is_empty() {
836 self.inner.store.apply_control(control)
837 } else {
838 self.inner
839 .store
840 .apply_control_bundle(control, successor_chain)
841 };
842 if let Err(error) = apply_result {
843 return Err(DbError::Store(error));
844 }
845 self.inner.credentials.activate_domain(next_seed)?;
846 self.set_domain_seed(next_seed);
847 self.domain(self.domain_id())
848 }
849
850 #[allow(clippy::too_many_arguments)]
852 pub fn issue_capability(
853 &self,
854 subject: AuthorId,
855 permissions: PermissionSet,
856 delegation_permissions: PermissionSet,
857 valid_from_epoch: u64,
858 valid_through_epoch: Option<u64>,
859 ) -> Result<CapabilityCertificate, DbError> {
860 let _control_writer = self
861 .inner
862 .control_gate
863 .try_write()
864 .map_err(|_| DbError::Operation("control plane is busy".into()))?;
865 self.issue_capability_guarded(
866 subject,
867 permissions,
868 delegation_permissions,
869 valid_from_epoch,
870 valid_through_epoch,
871 )
872 }
873
874 fn issue_capability_guarded(
875 &self,
876 subject: AuthorId,
877 permissions: PermissionSet,
878 delegation_permissions: PermissionSet,
879 valid_from_epoch: u64,
880 valid_through_epoch: Option<u64>,
881 ) -> Result<CapabilityCertificate, DbError> {
882 let active_epoch = self.inner.store.current_epoch(self.domain_id())?;
883 if self.epoch() != active_epoch {
884 return Err(DbError::StaleEpoch {
885 handle: self.epoch(),
886 active: active_epoch,
887 });
888 }
889 let issuer_id = self
890 .inner
891 .store
892 .active_capability(self.domain_id(), self.author_id())?
893 .ok_or(DbError::UnauthorizedDevice)?;
894 if !self.inner.store.authorize_capability(
895 self.domain_id(),
896 self.author_id(),
897 issuer_id,
898 Permission::Invite,
899 active_epoch,
900 )? {
901 return Err(DbError::UnauthorizedDevice);
902 }
903 let issuer = self
904 .inner
905 .store
906 .capability(issuer_id)?
907 .ok_or(DbError::UnauthorizedDevice)?;
908 let control_head = self.inner.store.control_head(self.domain_id())?;
909 let control_sequence = control_head.as_ref().map_or(0, ControlTransition::sequence);
910 let control_head_id = control_head
911 .as_ref()
912 .map(ControlTransition::id)
913 .transpose()?;
914 let mut nonce = [0_u8; 32];
915 getrandom::fill(&mut nonce).map_err(|_| DbError::RandomnessUnavailable)?;
916 let capability = issuer.delegate_at_control(
917 &self.inner.credentials.signer(),
918 subject,
919 permissions,
920 delegation_permissions,
921 control_sequence,
922 control_head_id,
923 valid_from_epoch,
924 valid_through_epoch,
925 nonce,
926 )?;
927 self.inner.store.install_capability(&capability)?;
928 self.inner.store.trust_author(self.domain_id(), subject)?;
929 Ok(capability)
930 }
931
932 pub fn active_capability(&self) -> Result<Option<CapabilityCertificate>, DbError> {
934 self.inner
935 .store
936 .active_capability(self.domain_id(), self.author_id())?
937 .map(|id| self.inner.store.capability(id))
938 .transpose()
939 .map(Option::flatten)
940 .map_err(DbError::from)
941 }
942
943 pub(crate) fn capability_for_permission(
944 &self,
945 subject: AuthorId,
946 permission: Permission,
947 epoch: u64,
948 ) -> Result<CapabilityId, DbError> {
949 if !self
950 .inner
951 .store
952 .is_author_trusted(self.domain_id(), subject)?
953 {
954 return Err(DbError::UnauthorizedDevice);
955 }
956 if self
957 .inner
958 .store
959 .domain_descriptor(self.domain_id())?
960 .is_none()
961 {
962 return Ok(private_capability(self.domain_id()));
963 }
964 let capability_id = self
965 .inner
966 .store
967 .active_capability(self.domain_id(), subject)?
968 .ok_or(DbError::UnauthorizedDevice)?;
969 if !self.inner.store.authorize_capability(
970 self.domain_id(),
971 subject,
972 capability_id,
973 permission,
974 epoch,
975 )? {
976 return Err(DbError::UnauthorizedDevice);
977 }
978 Ok(capability_id)
979 }
980
981 pub(crate) fn blob_engine(&self) -> BlobEngine {
982 BlobEngine::new_at_epoch(
983 Arc::new(DomainBlobStore {
984 blobs: self.inner.blobs.clone(),
985 metadata: self.inner.store.clone(),
986 domain_id: self.domain_id(),
987 }),
988 self.domain_id(),
989 self.epoch(),
990 self.domain_key(),
991 )
992 .with_fetch_scheduler(self.inner.blob_scheduler.clone())
993 }
994
995 pub(crate) fn blob_engine_for_epoch(&self, epoch: u64) -> Result<BlobEngine, DbError> {
996 Ok(BlobEngine::new_at_epoch(
997 Arc::new(DomainBlobStore {
998 blobs: self.inner.blobs.clone(),
999 metadata: self.inner.store.clone(),
1000 domain_id: self.domain_id(),
1001 }),
1002 self.domain_id(),
1003 epoch,
1004 self.domain_key_for(epoch)?,
1005 )
1006 .with_fetch_scheduler(self.inner.blob_scheduler.clone()))
1007 }
1008
1009 pub fn invite(
1011 &self,
1012 subject: AuthorId,
1013 permissions: PermissionSet,
1014 delegation_permissions: PermissionSet,
1015 valid_through_epoch: Option<u64>,
1016 ) -> Result<Invitation, DbError> {
1017 let _control_writer = self
1018 .inner
1019 .control_gate
1020 .try_write()
1021 .map_err(|_| DbError::Operation("control plane is busy".into()))?;
1022 let capability = self.issue_capability_guarded(
1023 subject,
1024 permissions,
1025 delegation_permissions,
1026 self.epoch(),
1027 valid_through_epoch,
1028 )?;
1029 let descriptor = self
1030 .domain_descriptor()?
1031 .ok_or(DbError::UnauthorizedDevice)?;
1032 let chain = self.inner.store.capability_chain(capability.id()?)?;
1033 let head = self.inner.store.control_head(self.domain_id())?;
1034 let controller_capability = head.as_ref().map_or_else(
1035 || {
1036 self.inner
1037 .store
1038 .active_capability(self.domain_id(), descriptor.owner())?
1039 .ok_or(DbError::UnauthorizedDevice)
1040 },
1041 |control| Ok(control.controller_capability()),
1042 )?;
1043 let controller_chain = self.inner.store.capability_chain(controller_capability)?;
1044 let issuer_capability = capability
1045 .issuer_capability()
1046 .ok_or(DbError::UnauthorizedDevice)?;
1047 let active_chains = self
1048 .inner
1049 .store
1050 .active_capabilities(self.domain_id())?
1051 .into_iter()
1052 .map(|id| self.inner.store.capability_chain(id).map_err(DbError::from))
1053 .collect::<Result<Vec<_>, _>>()?;
1054 Invitation::issue_at_checkpoint_with_evidence(
1055 descriptor,
1056 chain,
1057 controller_chain,
1058 &active_chains,
1059 self.domain_seed(),
1060 head,
1061 self.inner.store.subject_revocations(self.domain_id())?,
1062 issuer_capability,
1063 subject,
1064 &self.inner.credentials.signer(),
1065 )
1066 .map_err(DbError::from)
1067 }
1068
1069 pub fn import_invitation(&self, invitation: &Invitation) -> Result<Self, DbError> {
1071 let _control_writer = self
1072 .inner
1073 .control_gate
1074 .try_write()
1075 .map_err(|_| DbError::Operation("control plane is busy".into()))?;
1076 self.import_invitation_guarded(invitation)
1077 }
1078
1079 fn import_invitation_guarded(&self, invitation: &Invitation) -> Result<Self, DbError> {
1080 if invitation.subject() != self.author_id() {
1081 return Err(DbError::UnauthorizedDevice);
1082 }
1083 let authority_exists = self
1084 .inner
1085 .store
1086 .domain_descriptor(invitation.descriptor().domain_id())?
1087 .is_some();
1088 if authority_exists {
1089 let domain_id = invitation.descriptor().domain_id();
1090 let current_head = self.inner.store.control_head(domain_id)?;
1091 let current_head_id = current_head
1092 .as_ref()
1093 .map(ControlTransition::id)
1094 .transpose()?;
1095 let checkpoint_head_id = invitation
1096 .authority_checkpoint()
1097 .head()
1098 .map(ControlTransition::id)
1099 .transpose()?;
1100 if self.inner.store.current_epoch(domain_id)? != invitation.domain_seed().epoch()
1101 || current_head_id != checkpoint_head_id
1102 {
1103 return Err(DbError::UnauthorizedDevice);
1104 }
1105 self.inner
1106 .store
1107 .install_capability_chain_atomic(invitation.capability_chain())?;
1108 self.inner
1109 .store
1110 .trust_author(domain_id, invitation.issuer())?;
1111 } else {
1112 self.inner
1113 .store
1114 .import_authority_checkpoint_atomic(invitation)?;
1115 }
1116 self.inner
1117 .credentials
1118 .store_domain(&invitation.domain_seed())?;
1119 self.domain(invitation.descriptor().domain_id())
1120 }
1121
1122 pub fn import_ticket(
1124 &self,
1125 ticket: &InvitationTicket,
1126 secret: &InvitationSecret,
1127 ) -> Result<Self, DbError> {
1128 let invitation = ticket.open(secret, self.author_id())?;
1129 let _control_writer = self
1130 .inner
1131 .control_gate
1132 .try_write()
1133 .map_err(|_| DbError::Operation("control plane is busy".into()))?;
1134 if !self.inner.store.consume_invitation(ticket.id()?)? {
1135 return Err(DbError::InvitationAlreadyUsed);
1136 }
1137 self.import_invitation_guarded(&invitation)
1138 }
1139
1140 pub fn domain(&self, domain_id: iroh_db_core::DomainId) -> Result<Self, DbError> {
1142 let seed = self.inner.credentials.load_domain(domain_id)?;
1143 self.ensure_change_channel(domain_id);
1144 let domain = self
1145 .inner
1146 .domains
1147 .write()
1148 .expect("domain context lock is not poisoned")
1149 .entry(domain_id)
1150 .or_insert_with(|| Arc::new(RwLock::new(seed)))
1151 .clone();
1152 Ok(Self {
1153 inner: self.inner.clone(),
1154 domain,
1155 })
1156 }
1157
1158 pub fn domains(&self) -> Result<Vec<iroh_db_core::DomainId>, DbError> {
1160 self.inner.credentials.domains().map_err(DbError::from)
1161 }
1162
1163 pub fn set_device_group(&self, label: &str, members: &[AuthorId]) -> Result<(), DbError> {
1165 self.inner
1166 .store
1167 .set_device_group(self.domain_id(), label, members)
1168 .map_err(DbError::from)
1169 }
1170
1171 pub fn device_groups(&self) -> Result<Vec<DeviceGroup>, DbError> {
1173 Ok(self
1174 .inner
1175 .store
1176 .device_groups(self.domain_id())?
1177 .into_iter()
1178 .map(|(label, members)| DeviceGroup { label, members })
1179 .collect())
1180 }
1181
1182 pub async fn project<Record, PredicateFn>(
1184 &self,
1185 destination: &IrohDb,
1186 mut include: PredicateFn,
1187 ) -> Result<ProjectionReport, DbError>
1188 where
1189 Record: IrohRecord,
1190 PredicateFn: FnMut(&Record) -> bool,
1191 {
1192 if self.domain_id() == destination.domain_id() {
1193 return Err(DbError::InvalidRemoteCommit(
1194 "projection destination must be another domain".into(),
1195 ));
1196 }
1197 let records = self.collection::<Record>().query().fetch().await?;
1198 let _destination_collection = destination.collection::<Record>();
1199 let selected: Vec<_> = records
1200 .into_iter()
1201 .filter(|record| include(record))
1202 .collect();
1203 if selected.is_empty() {
1204 return Ok(ProjectionReport {
1205 selected: 0,
1206 commit: None,
1207 });
1208 }
1209 let selected_count = selected.len();
1210 let mut transaction = destination.transaction();
1211 for record in selected {
1212 transaction.put(record)?;
1213 }
1214 let batch = transaction.commit().await?;
1215 Ok(ProjectionReport {
1216 selected: selected_count,
1217 commit: Some(batch.commit_id()),
1218 })
1219 }
1220
1221 pub fn trust_device(&self, author: AuthorId) -> Result<(), DbError> {
1223 self.inner.store.trust_author(self.domain_id(), author)?;
1224 Ok(())
1225 }
1226
1227 pub async fn commit_envelopes(&self) -> Result<Vec<CommitEnvelope>, DbError> {
1229 let domain = self.domain_id();
1230 let mut commits = Vec::new();
1231 for commit_id in self.inner.store.list_stored_commit_ids(domain)? {
1232 commits.push(self.inner.store.load_commit(commit_id).await?);
1233 }
1234 Ok(commits)
1235 }
1236
1237 pub async fn accept_remote_commit(
1239 &self,
1240 authenticated_peer: AuthorId,
1241 envelope: CommitEnvelope,
1242 ) -> Result<RemoteApply, DbError> {
1243 let _control_reader = self.inner.control_gate.read().await;
1244 let _writer = self.inner.writer.lock().await;
1245 let header = envelope.header();
1246 let domain_id = self.domain_id();
1247 let commit_id = envelope.commit_id();
1248 if header.domain_id() != domain_id {
1249 return Err(DbError::UnauthorizedDevice);
1250 }
1251 if let Some(outcome) = self.quarantined_remote_apply(header, commit_id)? {
1252 return Ok(outcome);
1253 }
1254 if self
1255 .inner
1256 .store
1257 .contains_commit(self.domain_id(), commit_id)?
1258 {
1259 return Ok(RemoteApply::Duplicate(commit_id));
1260 }
1261 self.validate_remote_commit_authority(authenticated_peer, header, commit_id)?;
1262 let body = open_commit(&envelope, &self.domain_key_for(header.epoch())?)?;
1263 if let Some(existing) = self.inner.store.conflicting_author_commit(&envelope)? {
1264 return self.quarantine_equivocation(envelope, existing).await;
1265 }
1266 let adapters = self
1267 .inner
1268 .adapters
1269 .read()
1270 .expect("record adapter lock is not poisoned")
1271 .clone();
1272 let migration = decode_migration_body(&envelope, &body)?;
1273 if let Some(outcome) = self
1274 .accept_remote_migration(&envelope, migration, &adapters)
1275 .await?
1276 {
1277 return Ok(outcome);
1278 }
1279 self.validate_consistency(header.author(), &body)?;
1280 if self.commit_requires_schema_rebase(&body)? {
1281 let context = self.inclusive_commit_context(&envelope).await?;
1282 return match self
1283 .inner
1284 .store
1285 .persist_commit_with_reconciliation(&envelope, &[], &context, &[])
1286 .await?
1287 {
1288 ApplyOutcome::Duplicate(id) => Ok(RemoteApply::Duplicate(id)),
1289 ApplyOutcome::Applied(id) => {
1290 self.inner.telemetry.record_schema_rebase();
1291 Ok(RemoteApply::SchemaRebaseRequired { commit: id })
1292 }
1293 };
1294 }
1295 let converged = self
1296 .converged_mutations(&envelope, &body, &adapters)
1297 .await?;
1298 let changes = converged
1299 .mutations
1300 .iter()
1301 .map(|mutation| Change {
1302 collection_id: mutation.schema().collection_id(),
1303 record_id: mutation.record_id().to_vec(),
1304 kind: if mutation.state().is_some() {
1305 ChangeKind::Upserted
1306 } else {
1307 ChangeKind::Deleted
1308 },
1309 })
1310 .collect();
1311 match self
1312 .inner
1313 .store
1314 .persist_commit_with_reconciliation(
1315 &envelope,
1316 &converged.mutations,
1317 &converged.context,
1318 &converged.record_heads,
1319 )
1320 .await?
1321 {
1322 ApplyOutcome::Duplicate(id) => Ok(RemoteApply::Duplicate(id)),
1323 ApplyOutcome::Applied(id) => {
1324 let batch = ChangeBatch {
1325 commit_id: id,
1326 sequence: header.author_sequence(),
1327 frontier: self.inner.store.frontier(self.domain_id())?,
1328 changes,
1329 };
1330 let _no_subscribers = self.changes().send(batch);
1331 Ok(RemoteApply::Applied(id))
1332 }
1333 }
1334 }
1335
1336 fn validate_remote_commit_authority(
1337 &self,
1338 authenticated_peer: AuthorId,
1339 header: &CommitHeader,
1340 commit_id: CommitId,
1341 ) -> Result<(), DbError> {
1342 let domain_id = self.domain_id();
1343 let active_epoch = self.inner.store.current_epoch(domain_id)?;
1344 if header.epoch() < active_epoch {
1345 return Err(DbError::RebaseRequired(commit_id));
1346 }
1347 if header.epoch() != active_epoch
1348 || !self
1349 .inner
1350 .store
1351 .is_author_trusted(domain_id, authenticated_peer)?
1352 || !self
1353 .inner
1354 .store
1355 .is_author_trusted(domain_id, header.author())?
1356 {
1357 return Err(DbError::UnauthorizedDevice);
1358 }
1359 if self.inner.store.domain_descriptor(domain_id)?.is_some()
1360 && !self.inner.store.authorize_capability(
1361 domain_id,
1362 header.author(),
1363 header.capability_id(),
1364 Permission::Write,
1365 header.epoch(),
1366 )?
1367 {
1368 return Err(DbError::UnauthorizedDevice);
1369 }
1370 Ok(())
1371 }
1372
1373 async fn accept_remote_migration(
1374 &self,
1375 envelope: &CommitEnvelope,
1376 migration: MigrationBody,
1377 adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
1378 ) -> Result<Option<RemoteApply>, DbError> {
1379 let header = envelope.header();
1380 match migration {
1381 MigrationBody::Ordinary => Ok(None),
1382 MigrationBody::Stages(stage_batch) => {
1383 self.validate_remote_migration_authority(header)?;
1384 validate_staged_records(&stage_batch, adapters)?;
1385 let context = self.inclusive_commit_context(envelope).await?;
1386 let outcome = self
1387 .inner
1388 .store
1389 .persist_schema_stages_with_context(envelope, &stage_batch, &context)
1390 .await?;
1391 Ok(Some(match outcome {
1392 ApplyOutcome::Duplicate(id) => RemoteApply::Duplicate(id),
1393 ApplyOutcome::Applied(id) => RemoteApply::Applied(id),
1394 }))
1395 }
1396 MigrationBody::Activation(activation) => {
1397 self.validate_remote_migration_authority(header)?;
1398 self.validate_activation_cut(envelope, &activation).await?;
1399 let before = self.inner.store.export_materialized(self.domain_id())?;
1400 let context = self.inclusive_commit_context(envelope).await?;
1401 let outcome = self
1402 .inner
1403 .store
1404 .persist_schema_activation_with_context(envelope, &activation, &context)
1405 .await?;
1406 Ok(Some(match outcome {
1407 ApplyOutcome::Duplicate(id) => RemoteApply::Duplicate(id),
1408 ApplyOutcome::Applied(id) => {
1409 let after = self.inner.store.export_materialized(self.domain_id())?;
1410 let changes = materialization_changes(&before, &after);
1411 if !changes.is_empty() {
1412 let _no_subscribers = self.changes().send(ChangeBatch {
1413 commit_id: id,
1414 sequence: header.author_sequence(),
1415 frontier: self.inner.store.frontier(self.domain_id())?,
1416 changes,
1417 });
1418 }
1419 RemoteApply::Applied(id)
1420 }
1421 }))
1422 }
1423 }
1424 }
1425
1426 fn validate_remote_migration_authority(&self, header: &CommitHeader) -> Result<(), DbError> {
1427 if self
1428 .inner
1429 .store
1430 .domain_descriptor(self.domain_id())?
1431 .is_some()
1432 && (self.inner.store.current_controller(self.domain_id())? != header.author()
1433 || !self.inner.store.authorize_capability(
1434 self.domain_id(),
1435 header.author(),
1436 header.capability_id(),
1437 Permission::Admin,
1438 header.epoch(),
1439 )?)
1440 {
1441 return Err(DbError::UnauthorizedDevice);
1442 }
1443 Ok(())
1444 }
1445
1446 fn commit_requires_schema_rebase(&self, body: &CommitBody) -> Result<bool, DbError> {
1447 let mut older = false;
1448 let mut current = false;
1449 for operation in body.operations() {
1450 let mutation = MaterializedRecord::decode_canonical(operation.payload())?;
1451 let Some(activation) = self
1452 .inner
1453 .store
1454 .active_schema_activation(self.domain_id(), operation.collection_id())?
1455 else {
1456 current = true;
1457 continue;
1458 };
1459 if mutation.schema().version() < activation.to().version() {
1460 older = true;
1461 } else {
1462 current = true;
1463 }
1464 }
1465 if older && current {
1466 return Err(DbError::InvalidRemoteCommit(
1467 "one commit crosses an active schema boundary".into(),
1468 ));
1469 }
1470 Ok(older)
1471 }
1472
1473 async fn validate_activation_cut(
1474 &self,
1475 incoming: &CommitEnvelope,
1476 activation: &SchemaActivation,
1477 ) -> Result<(), DbError> {
1478 let mut graph = BTreeMap::<CommitId, Vec<CommitId>>::new();
1479 let mut evidence = BTreeMap::<Vec<u8>, Vec<(CommitId, SchemaStage)>>::new();
1480 for commit_id in self.inner.store.list_stored_commit_ids(self.domain_id())? {
1481 let envelope = self.inner.store.load_commit(commit_id).await?;
1482 graph.insert(commit_id, envelope.header().dependencies().to_vec());
1483 let body = open_commit(&envelope, &self.domain_key_for(envelope.header().epoch())?)?;
1484 if let MigrationBody::Stages(stages) = decode_migration_body(&envelope, &body)? {
1485 for stage in stages {
1486 if stage.migration_id() == activation.migration_id() {
1487 evidence
1488 .entry(stage.source_record_id().to_vec())
1489 .or_default()
1490 .push((commit_id, stage));
1491 }
1492 }
1493 }
1494 }
1495 graph.insert(
1496 incoming.commit_id(),
1497 incoming.header().dependencies().to_vec(),
1498 );
1499 let durable = self
1500 .inner
1501 .store
1502 .schema_stages(self.domain_id(), activation.migration_id())?;
1503 for stage in durable {
1504 let causally_selected =
1505 evidence
1506 .get(stage.source_record_id())
1507 .is_some_and(|candidates| {
1508 candidates.iter().any(|(commit_id, candidate)| {
1509 candidate == &stage
1510 && is_ancestor(*commit_id, incoming.commit_id(), &graph)
1511 })
1512 });
1513 if !causally_selected {
1514 return Err(DbError::InvalidRemoteCommit(
1515 "activation does not causally depend on its complete staging set".into(),
1516 ));
1517 }
1518 }
1519 let selected_ids: Vec<_> = graph
1520 .keys()
1521 .copied()
1522 .filter(|commit_id| {
1523 *commit_id != incoming.commit_id()
1524 && is_ancestor(*commit_id, incoming.commit_id(), &graph)
1525 })
1526 .collect();
1527 let adapters = self
1528 .inner
1529 .adapters
1530 .read()
1531 .expect("record adapter lock is not poisoned")
1532 .clone();
1533 let source_records = self.rebuild_commit_ids(&selected_ids, &adapters).await?;
1534 let sources: BTreeMap<_, _> = source_records
1535 .iter()
1536 .filter(|record| {
1537 record.schema().collection_id() == activation.from().collection_id()
1538 && record.schema().schema_id() == activation.from().schema_id()
1539 && record.state().is_some()
1540 })
1541 .map(|record| (record.record_id().to_vec(), record))
1542 .collect();
1543 let durable = self
1544 .inner
1545 .store
1546 .schema_stages(self.domain_id(), activation.migration_id())?;
1547 if sources.len() != durable.len()
1548 || durable.iter().any(|stage| {
1549 sources
1550 .get(stage.source_record_id())
1551 .and_then(|record| record.state())
1552 .is_none_or(|state| {
1553 BlobHash::from_bytes(*blake3::hash(state).as_bytes())
1554 != stage.source_digest()
1555 })
1556 })
1557 {
1558 return Err(DbError::InvalidRemoteCommit(
1559 "activation staging does not exactly cover its source-state cut".into(),
1560 ));
1561 }
1562 Ok(())
1563 }
1564
1565 fn quarantined_remote_apply(
1566 &self,
1567 header: &CommitHeader,
1568 commit_id: CommitId,
1569 ) -> Result<Option<RemoteApply>, DbError> {
1570 let commit_quarantined = self.inner.store.is_commit_quarantined(commit_id)?;
1571 let author_quarantined = self
1572 .inner
1573 .store
1574 .author_quarantine(header.domain_id(), header.author())?
1575 .is_some_and(|from_sequence| header.author_sequence() >= from_sequence);
1576 Ok(
1577 (commit_quarantined || author_quarantined).then_some(RemoteApply::Quarantined {
1578 author: header.author(),
1579 sequence: header.author_sequence(),
1580 }),
1581 )
1582 }
1583
1584 async fn quarantine_equivocation(
1585 &self,
1586 evidence: CommitEnvelope,
1587 existing: CommitId,
1588 ) -> Result<RemoteApply, DbError> {
1589 let author = evidence.header().author();
1590 let sequence = evidence.header().author_sequence();
1591 let commit_ids = self.inner.store.list_stored_commit_ids(self.domain_id())?;
1592 let mut graph = BTreeMap::<CommitId, Vec<CommitId>>::new();
1593 let mut headers = BTreeMap::<CommitId, (AuthorId, u64)>::new();
1594 for commit_id in &commit_ids {
1595 let envelope = self.inner.store.load_commit(*commit_id).await?;
1596 graph.insert(*commit_id, envelope.header().dependencies().to_vec());
1597 headers.insert(
1598 *commit_id,
1599 (
1600 envelope.header().author(),
1601 envelope.header().author_sequence(),
1602 ),
1603 );
1604 }
1605 let mut invalid = BTreeSet::from([existing]);
1606 loop {
1607 let mut changed = false;
1608 for (commit_id, dependencies) in &graph {
1609 if !invalid.contains(commit_id)
1610 && dependencies
1611 .iter()
1612 .any(|dependency| invalid.contains(dependency))
1613 {
1614 changed |= invalid.insert(*commit_id);
1615 }
1616 }
1617 if !changed {
1618 break;
1619 }
1620 }
1621 let accepted: Vec<_> = commit_ids
1622 .into_iter()
1623 .filter(|commit_id| !invalid.contains(commit_id))
1624 .collect();
1625 let adapters = self
1626 .inner
1627 .adapters
1628 .read()
1629 .expect("record adapter lock is not poisoned")
1630 .clone();
1631 let mutations = self.rebuild_commit_ids(&accepted, &adapters).await?;
1632 let baseline = self.inner.store.snapshot_baseline(self.domain_id())?;
1633 let metadata = quarantine_metadata(baseline.as_ref(), &accepted, &graph, &headers);
1634 let invalid: Vec<_> = invalid.into_iter().collect();
1635 let previous_records = self.inner.store.export_materialized(self.domain_id())?;
1636 self.inner
1637 .store
1638 .quarantine_equivocation(
1639 &evidence,
1640 &invalid,
1641 &metadata.frontier,
1642 &metadata.author_sequences,
1643 &metadata.author_heads,
1644 &mutations,
1645 )
1646 .await?;
1647 self.inner.telemetry.record_equivocation_quarantine();
1648 let changes = materialization_changes(&previous_records, &mutations);
1649 if !changes.is_empty() {
1650 let _no_subscribers = self.changes().send(ChangeBatch {
1651 commit_id: evidence.commit_id(),
1652 sequence,
1653 frontier: metadata.frontier,
1654 changes,
1655 });
1656 }
1657 Ok(RemoteApply::Quarantined { author, sequence })
1658 }
1659
1660 async fn converged_mutations(
1661 &self,
1662 incoming: &CommitEnvelope,
1663 incoming_body: &CommitBody,
1664 adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
1665 ) -> Result<ConvergedCommit, DbError> {
1666 let domain = self.domain_id();
1667 for dependency in incoming.header().dependencies() {
1668 if !self.inner.store.contains_commit(domain, *dependency)? {
1669 return Err(DbError::InvalidRemoteCommit(
1670 "commit dependency is missing".into(),
1671 ));
1672 }
1673 }
1674
1675 let dependency_context = self
1676 .dependency_context(incoming.header().dependencies())
1677 .await?;
1678 let affected =
1679 validate_incoming_operations(incoming, incoming_body, adapters, &dependency_context)?;
1680 let incoming_id = incoming.commit_id();
1681 let mut mutations = Vec::with_capacity(affected.len());
1682 let mut replacements = Vec::with_capacity(affected.len());
1683 for (key, _) in affected {
1684 let incoming_mutation = incoming_body
1685 .operations()
1686 .iter()
1687 .find(|operation| {
1688 operation.collection_id() == key.0 && operation.record_id() == key.1
1689 })
1690 .ok_or_else(|| DbError::InvalidRemoteCommit("missing record operation".into()))
1691 .and_then(|operation| {
1692 MaterializedRecord::decode_canonical(operation.payload()).map_err(DbError::from)
1693 })?;
1694 let mut heads = self.inner.store.record_heads(domain, key.0, &key.1)?;
1695 heads.retain(|head| {
1696 !dependency_context.covers(&Dot::new(head.author(), head.sequence(), 0))
1697 });
1698 heads.push(RecordHead::new(
1699 incoming_id,
1700 incoming.header().author(),
1701 incoming.header().author_sequence(),
1702 incoming_mutation,
1703 ));
1704 heads.sort_unstable_by_key(RecordHead::commit_id);
1705 let entries: Vec<_> = heads
1706 .iter()
1707 .map(|head| (head.commit_id(), head.mutation().clone()))
1708 .collect();
1709 mutations.push(resolve_causal_record_heads(&key, &entries, adapters)?);
1710 replacements.push(RecordHeadSet::new(key.0, key.1, heads)?);
1711 }
1712 let mut context = dependency_context;
1713 context.observe(Dot::new(
1714 incoming.header().author(),
1715 incoming.header().author_sequence(),
1716 0,
1717 ));
1718 Ok(ConvergedCommit {
1719 mutations,
1720 context,
1721 record_heads: replacements,
1722 })
1723 }
1724
1725 async fn dependency_context(
1726 &self,
1727 dependencies: &[CommitId],
1728 ) -> Result<VersionVector, DbError> {
1729 self.ensure_reconciliation_index().await?;
1730 let mut context = VersionVector::new();
1731 let baseline = self.inner.store.snapshot_baseline(self.domain_id())?;
1732 for &commit_id in dependencies {
1733 if let Some(dependency) = self
1734 .inner
1735 .store
1736 .commit_context(self.domain_id(), commit_id)?
1737 {
1738 context.merge(&dependency);
1739 } else if self
1740 .inner
1741 .store
1742 .contains_commit(self.domain_id(), commit_id)?
1743 {
1744 let baseline = baseline.as_ref().ok_or_else(|| {
1745 DbError::InvalidSnapshot(
1746 "accepted dependency has no indexed or snapshot causal context".into(),
1747 )
1748 })?;
1749 for &(author, sequence) in baseline.author_sequences() {
1750 context.observe(Dot::new(author, sequence, 0));
1751 }
1752 } else {
1753 return Err(DbError::InvalidRemoteCommit(
1754 "commit dependency is missing".into(),
1755 ));
1756 }
1757 }
1758 Ok(context)
1759 }
1760
1761 async fn inclusive_commit_context(
1762 &self,
1763 envelope: &CommitEnvelope,
1764 ) -> Result<VersionVector, DbError> {
1765 let mut context = self
1766 .dependency_context(envelope.header().dependencies())
1767 .await?;
1768 context.observe(Dot::new(
1769 envelope.header().author(),
1770 envelope.header().author_sequence(),
1771 0,
1772 ));
1773 Ok(context)
1774 }
1775
1776 #[allow(clippy::too_many_lines)]
1777 async fn ensure_reconciliation_index(&self) -> Result<(), DbError> {
1778 let domain_id = self.domain_id();
1779 if self.inner.store.reconciliation_index_ready(domain_id)? {
1780 return Ok(());
1781 }
1782 let commit_ids = self.inner.store.list_stored_commit_ids(domain_id)?;
1783 if commit_ids.len() > MAX_RECONCILE_COMMITS {
1784 return Err(DbError::InvalidRemoteCommit(
1785 "reconciliation index rebuild budget exceeded".into(),
1786 ));
1787 }
1788 self.inner
1789 .telemetry
1790 .add_reconciliation_commits_scanned(commit_ids.len());
1791
1792 let mut commits = BTreeMap::<CommitId, (CommitEnvelope, CommitBody)>::new();
1793 for commit_id in commit_ids {
1794 let envelope = self.inner.store.load_commit(commit_id).await?;
1795 if envelope.header().domain_id() != domain_id {
1796 return Err(DbError::InvalidRemoteCommit(
1797 "indexed commit belongs to another domain".into(),
1798 ));
1799 }
1800 let body = open_commit(&envelope, &self.domain_key_for(envelope.header().epoch())?)?;
1801 commits.insert(commit_id, (envelope, body));
1802 }
1803
1804 let baseline = self.inner.store.snapshot_baseline(domain_id)?;
1805 let mut baseline_context = VersionVector::new();
1806 if let Some(baseline) = &baseline {
1807 for &(author, sequence) in baseline.author_sequences() {
1808 baseline_context.observe(Dot::new(author, sequence, 0));
1809 }
1810 }
1811 let mut pending: BTreeSet<_> = commits.keys().copied().collect();
1812 let mut contexts = BTreeMap::<CommitId, VersionVector>::new();
1813 let mut record_heads = BTreeMap::<RecordTarget, Vec<RecordHead>>::new();
1814 while !pending.is_empty() {
1815 let next = pending.iter().copied().find(|commit_id| {
1816 commits[commit_id]
1817 .0
1818 .header()
1819 .dependencies()
1820 .iter()
1821 .all(|dependency| {
1822 contexts.contains_key(dependency) || !commits.contains_key(dependency)
1823 })
1824 });
1825 let Some(commit_id) = next else {
1826 return Err(DbError::InvalidRemoteCommit(
1827 "commit history contains a dependency cycle".into(),
1828 ));
1829 };
1830 let (envelope, body) = &commits[&commit_id];
1831 let mut dependency_context = VersionVector::new();
1832 for dependency in envelope.header().dependencies() {
1833 if let Some(context) = contexts.get(dependency) {
1834 dependency_context.merge(context);
1835 } else if self.inner.store.contains_commit(domain_id, *dependency)?
1836 && baseline.is_some()
1837 {
1838 dependency_context.merge(&baseline_context);
1839 } else {
1840 return Err(DbError::InvalidRemoteCommit(
1841 "commit history has an unindexed dependency".into(),
1842 ));
1843 }
1844 }
1845
1846 if matches!(
1847 decode_migration_body(envelope, body)?,
1848 MigrationBody::Ordinary
1849 ) {
1850 for operation in body.operations() {
1851 if !matches!(
1852 operation.kind(),
1853 OperationKind::FieldStateMerge | OperationKind::RecordDelete
1854 ) {
1855 return Err(DbError::InvalidRemoteCommit(
1856 "accepted record commit contains an unsupported operation".into(),
1857 ));
1858 }
1859 let mutation = MaterializedRecord::decode_canonical(operation.payload())?;
1860 let key = (operation.collection_id(), operation.record_id().to_vec());
1861 let heads = record_heads.entry(key).or_default();
1862 heads.retain(|head| {
1863 !dependency_context.covers(&Dot::new(head.author(), head.sequence(), 0))
1864 });
1865 heads.push(RecordHead::new(
1866 commit_id,
1867 envelope.header().author(),
1868 envelope.header().author_sequence(),
1869 mutation,
1870 ));
1871 }
1872 }
1873 let mut context = dependency_context;
1874 context.observe(Dot::new(
1875 envelope.header().author(),
1876 envelope.header().author_sequence(),
1877 0,
1878 ));
1879 contexts.insert(commit_id, context);
1880 pending.remove(&commit_id);
1881 }
1882
1883 let contexts: Vec<_> = contexts.into_iter().collect();
1884 let heads: Vec<_> = record_heads.into_values().flatten().collect();
1885 self.inner
1886 .store
1887 .replace_reconciliation_index(domain_id, &contexts, &heads)?;
1888 Ok(())
1889 }
1890
1891 pub(crate) async fn rebuild_mutations(&self) -> Result<Vec<MaterializedRecord>, DbError> {
1892 let domain = self.domain_id();
1893 let commit_ids = self.inner.store.list_stored_commit_ids(domain)?;
1894 if commit_ids.len() > MAX_RECONCILE_COMMITS {
1895 return Err(DbError::InvalidSnapshot(
1896 "rebuild commit budget exceeded".into(),
1897 ));
1898 }
1899 let adapters = self
1900 .inner
1901 .adapters
1902 .read()
1903 .expect("record adapter lock is not poisoned")
1904 .clone();
1905 self.rebuild_commit_ids(&commit_ids, &adapters).await
1906 }
1907
1908 async fn rebuild_commit_ids(
1909 &self,
1910 commit_ids: &[CommitId],
1911 adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
1912 ) -> Result<Vec<MaterializedRecord>, DbError> {
1913 let Some(base_records) = self.verified_snapshot_baseline_records().await? else {
1914 return self.materialize_commit_ids(commit_ids, adapters).await;
1915 };
1916 let tail = self.materialize_commit_ids(commit_ids, adapters).await?;
1917 let mut rebuilt = BTreeMap::<(CollectionId, Vec<u8>), MaterializedRecord>::new();
1918 for record in base_records {
1919 let collection_id = record.schema().collection_id();
1920 let adapter = *adapters
1921 .get(&(collection_id, record.schema().schema_id()))
1922 .ok_or(DbError::UnknownRecordSchema(collection_id))?;
1923 if record.state().is_none() {
1924 return Err(DbError::InvalidSnapshot(
1925 "snapshot contains a deleted materialized record".into(),
1926 ));
1927 }
1928 let canonical = (adapter.merge)(None, &record)?;
1929 rebuilt.insert((collection_id, canonical.record_id().to_vec()), canonical);
1930 }
1931 for mutation in tail {
1932 let collection_id = mutation.schema().collection_id();
1933 if self
1934 .inner
1935 .store
1936 .active_schema_activation(self.domain_id(), collection_id)?
1937 .is_some_and(|activation| mutation.schema().version() < activation.to().version())
1938 {
1939 continue;
1940 }
1941 let key = (collection_id, mutation.record_id().to_vec());
1942 if mutation.state().is_none() {
1943 rebuilt.remove(&key);
1944 continue;
1945 }
1946 let existing = rebuilt.get(&key);
1947 if existing.is_some_and(|value| value.schema().version() > mutation.schema().version())
1948 {
1949 continue;
1950 }
1951 let adapter = *adapters
1952 .get(&(collection_id, mutation.schema().schema_id()))
1953 .ok_or(DbError::UnknownRecordSchema(collection_id))?;
1954 let existing_state = existing
1955 .filter(|value| value.schema().schema_id() == mutation.schema().schema_id())
1956 .and_then(MaterializedRecord::state);
1957 let merged = (adapter.merge)(existing_state, &mutation)?;
1958 rebuilt.insert(key, merged);
1959 }
1960 Ok(rebuilt.into_values().collect())
1961 }
1962
1963 async fn materialize_commit_ids(
1964 &self,
1965 commit_ids: &[CommitId],
1966 adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
1967 ) -> Result<Vec<MaterializedRecord>, DbError> {
1968 let mut graph = BTreeMap::<CommitId, Vec<CommitId>>::new();
1969 let mut candidates = RecordCandidates::new();
1970 let mut stages_by_record = BTreeMap::<(MigrationId, Vec<u8>), SchemaStage>::new();
1971 let mut activations = Vec::<(CommitId, SchemaActivation)>::new();
1972 for &commit_id in commit_ids {
1973 let envelope = self.inner.store.load_commit(commit_id).await?;
1974 graph.insert(commit_id, envelope.header().dependencies().to_vec());
1975 let body = open_commit(&envelope, &self.domain_key_for(envelope.header().epoch())?)?;
1976 match decode_migration_body(&envelope, &body)? {
1977 MigrationBody::Ordinary => {
1978 let dependency_context = self
1979 .dependency_context(envelope.header().dependencies())
1980 .await?;
1981 let affected = validate_incoming_operations(
1982 &envelope,
1983 &body,
1984 adapters,
1985 &dependency_context,
1986 )?;
1987 collect_affected_operations(commit_id, &body, &affected, &mut candidates)?;
1988 }
1989 MigrationBody::Stages(stage_batch) => {
1990 for stage in stage_batch {
1991 let key = (stage.migration_id(), stage.source_record_id().to_vec());
1992 if let Some(existing) = stages_by_record.insert(key, stage.clone())
1993 && existing != stage
1994 {
1995 return Err(DbError::InvalidRemoteCommit(
1996 "migration history contains conflicting stages".into(),
1997 ));
1998 }
1999 }
2000 }
2001 MigrationBody::Activation(activation) => {
2002 activations.push((commit_id, *activation));
2003 }
2004 }
2005 }
2006
2007 let mut active = BTreeMap::<CollectionId, (CommitId, SchemaActivation)>::new();
2008 for (commit_id, activation) in activations {
2009 let collection = activation.to().collection_id();
2010 if let Some((_, existing)) = active.get(&collection) {
2011 if existing.from().schema_id() == activation.from().schema_id()
2012 && existing != &activation
2013 {
2014 return Err(DbError::InvalidRemoteCommit(
2015 "collection has incompatible schema activation siblings".into(),
2016 ));
2017 }
2018 if existing.to().version() >= activation.to().version() {
2019 continue;
2020 }
2021 }
2022 active.insert(collection, (commit_id, activation));
2023 }
2024
2025 for (&collection, (activation_commit, activation)) in &active {
2026 let activation_stages: Vec<_> = stages_by_record
2027 .values()
2028 .filter(|stage| stage.migration_id() == activation.migration_id())
2029 .cloned()
2030 .collect();
2031 if u64::try_from(activation_stages.len()).ok() != Some(activation.staged_records())
2032 || staging_digest(&activation_stages)? != activation.staging_digest()
2033 {
2034 return Err(DbError::InvalidRemoteCommit(
2035 "activation staging set is incomplete during rebuild".into(),
2036 ));
2037 }
2038 for stage in activation_stages {
2039 let target = MaterializedRecord::decode_canonical(stage.target_record())?;
2040 candidates
2041 .entry((collection, target.record_id().to_vec()))
2042 .or_default()
2043 .push((*activation_commit, target));
2044 }
2045 }
2046
2047 for (key, entries) in &mut candidates {
2048 let Some((activation_commit, activation)) = active.get(&key.0) else {
2049 continue;
2050 };
2051 entries.retain(|(commit_id, mutation)| {
2052 mutation.schema().version() >= activation.to().version()
2053 && (*commit_id == *activation_commit
2054 || is_ancestor(*activation_commit, *commit_id, &graph))
2055 });
2056 }
2057 let mut mutations = Vec::with_capacity(candidates.len());
2058 for (key, entries) in candidates {
2059 if entries.is_empty() {
2060 continue;
2061 }
2062 mutations.push(resolve_record_heads(&key, &entries, &graph, adapters)?);
2063 }
2064 Ok(mutations)
2065 }
2066
2067 pub async fn close(self) -> Result<(), DbError> {
2069 let inner = Arc::try_unwrap(self.inner).map_err(|_| DbError::HandlesOpen)?;
2070 let DbInner { store, blobs, .. } = inner;
2071 drop(store);
2072 blobs
2073 .shutdown()
2074 .await
2075 .map_err(StoreError::from)
2076 .map_err(DbError::from)
2077 }
2078
2079 pub(crate) fn domain_key(&self) -> iroh_db_security::DomainKey {
2080 self.domain_seed().domain_key()
2081 }
2082
2083 fn domain_key_for(&self, epoch: u64) -> Result<iroh_db_security::DomainKey, DbError> {
2084 self.inner
2085 .credentials
2086 .load_domain_epoch(self.domain_id(), epoch)
2087 .map(|seed| seed.domain_key())
2088 .map_err(DbError::from)
2089 }
2090
2091 pub(crate) fn epoch(&self) -> u64 {
2092 self.domain
2093 .read()
2094 .expect("domain seed lock is not poisoned")
2095 .epoch()
2096 }
2097
2098 fn set_domain_seed(&self, seed: &DomainSeed) {
2099 *self
2100 .domain
2101 .write()
2102 .expect("domain seed lock is not poisoned") = seed.clone();
2103 }
2104
2105 fn ensure_change_channel(&self, domain_id: iroh_db_core::DomainId) {
2106 let mut changes = self
2107 .inner
2108 .changes
2109 .write()
2110 .expect("change channel lock is not poisoned");
2111 changes.entry(domain_id).or_insert_with(|| {
2112 let (sender, _) = broadcast::channel(CHANGE_CAPACITY);
2113 sender
2114 });
2115 }
2116
2117 fn changes(&self) -> broadcast::Sender<ChangeBatch> {
2118 self.ensure_change_channel(self.domain_id());
2119 self.inner
2120 .changes
2121 .read()
2122 .expect("change channel lock is not poisoned")
2123 .get(&self.domain_id())
2124 .expect("domain change channel exists")
2125 .clone()
2126 }
2127
2128 pub async fn run_migrations(&self) -> Result<MigrationReport, DbError> {
2130 let steps = self
2131 .inner
2132 .migrations
2133 .read()
2134 .expect("migration registry lock is not poisoned")
2135 .clone();
2136 let mut report = MigrationReport::default();
2137 for domain_id in self.domains()? {
2138 let domain = self.domain(domain_id)?;
2139 for step in &steps {
2140 report.absorb(domain.run_migration_step(step).await?);
2141 }
2142 }
2143 Ok(report)
2144 }
2145
2146 async fn run_migration_step(&self, step: &MigrationStep) -> Result<MigrationReport, DbError> {
2147 let _control_reader = self.inner.control_gate.read().await;
2148 let _writer = self.inner.writer.lock().await;
2149 let domain_id = self.domain_id();
2150 let collection_id = step.from.collection_id();
2151 if let Some(active) = self
2152 .inner
2153 .store
2154 .active_schema_activation(domain_id, collection_id)?
2155 && active.to().version() >= step.to.version()
2156 {
2157 let registered =
2158 self.inner
2159 .store
2160 .registered_schema(domain_id, collection_id, step.to.version())?;
2161 if registered.is_some_and(|schema| schema.schema_id() == step.to.schema_id()) {
2162 return Ok(MigrationReport {
2163 already_activated: 1,
2164 ..MigrationReport::default()
2165 });
2166 }
2167 return Err(MigrationError::InvalidRegistration(
2168 "active migration chain contains an incompatible historical descriptor".into(),
2169 )
2170 .into());
2171 }
2172 let Some(latest) = self
2173 .inner
2174 .store
2175 .latest_registered_schema(domain_id, collection_id)?
2176 else {
2177 return Ok(MigrationReport::default());
2178 };
2179 if latest.schema_id() != step.from.schema_id() {
2180 return Err(MigrationError::InvalidRegistration(format!(
2181 "durable schema version {} does not match registered source version {}",
2182 latest.version(),
2183 step.from.version()
2184 ))
2185 .into());
2186 }
2187 self.require_migration_coordinator()?;
2188 let records = self.inner.store.list_records(domain_id, collection_id)?;
2189 let migration_id =
2190 migration_id(domain_id, &step.from, &step.to, source_cut_digest(&records));
2191 let mut stages = Vec::with_capacity(records.len());
2192 for record in records {
2193 let target = (step.transform)(record.state())?;
2194 let source_digest = BlobHash::from_bytes(*blake3::hash(record.state()).as_bytes());
2195 stages.push(SchemaStage::new(
2196 migration_id,
2197 step.from.clone(),
2198 step.to.clone(),
2199 record.record_id().to_vec(),
2200 source_digest,
2201 target.materialized.encode_canonical()?,
2202 )?);
2203 }
2204 let existing: BTreeSet<_> = self
2205 .inner
2206 .store
2207 .schema_stages(domain_id, migration_id)?
2208 .into_iter()
2209 .map(|stage| stage.source_record_id().to_vec())
2210 .collect();
2211 let missing: Vec<_> = stages
2212 .iter()
2213 .filter(|stage| !existing.contains(stage.source_record_id()))
2214 .cloned()
2215 .collect();
2216 for batch in missing.chunks(256) {
2217 self.commit_schema_stages_locked(batch).await?;
2218 }
2219 let activation = SchemaActivation::new(
2220 migration_id,
2221 step.from.clone(),
2222 step.to.clone(),
2223 u64::try_from(stages.len()).map_err(|_| {
2224 MigrationError::InvalidRegistration("migration record count overflow".into())
2225 })?,
2226 staging_digest(&stages)?,
2227 )?;
2228 let activation_commit = self.commit_schema_activation_locked(&activation).await?;
2229 Ok(MigrationReport {
2230 staged: missing.len(),
2231 already_staged: stages.len().saturating_sub(missing.len()),
2232 activated: 1,
2233 already_activated: 0,
2234 activation_commits: vec![activation_commit],
2235 })
2236 }
2237
2238 fn require_migration_coordinator(&self) -> Result<(), DbError> {
2239 if self
2240 .inner
2241 .store
2242 .domain_descriptor(self.domain_id())?
2243 .is_some()
2244 && self.inner.store.current_controller(self.domain_id())? != self.author_id()
2245 {
2246 return Err(DbError::UnauthorizedDevice);
2247 }
2248 Ok(())
2249 }
2250
2251 async fn commit_schema_stages_locked(
2252 &self,
2253 stages: &[SchemaStage],
2254 ) -> Result<CommitId, DbError> {
2255 if stages.is_empty() {
2256 return Err(DbError::EmptyTransaction);
2257 }
2258 let operations = stages
2259 .iter()
2260 .map(|stage| {
2261 Operation::new(
2262 stage.to().collection_id(),
2263 stage.source_record_id().to_vec(),
2264 0,
2265 OperationKind::SchemaStage,
2266 stage.encode_canonical(),
2267 )
2268 })
2269 .collect::<Result<Vec<_>, _>>()?;
2270 let schemas = vec![stages[0].from().schema_id(), stages[0].to().schema_id()];
2271 let envelope = self.seal_migration_commit_locked(operations, schemas, Permission::Admin)?;
2272 let context = self.inclusive_commit_context(&envelope).await?;
2273 self.inner
2274 .store
2275 .persist_schema_stages_with_context(&envelope, stages, &context)
2276 .await?;
2277 Ok(envelope.commit_id())
2278 }
2279
2280 async fn commit_schema_activation_locked(
2281 &self,
2282 activation: &SchemaActivation,
2283 ) -> Result<CommitId, DbError> {
2284 let operation = Operation::new(
2285 activation.to().collection_id(),
2286 Vec::new(),
2287 0,
2288 OperationKind::SchemaActivate,
2289 activation.encode_canonical(),
2290 )?;
2291 let envelope = self.seal_migration_commit_locked(
2292 vec![operation],
2293 vec![activation.from().schema_id(), activation.to().schema_id()],
2294 Permission::Admin,
2295 )?;
2296 self.validate_activation_cut(&envelope, activation).await?;
2297 let before = self.inner.store.export_materialized(self.domain_id())?;
2298 let context = self.inclusive_commit_context(&envelope).await?;
2299 self.inner
2300 .store
2301 .persist_schema_activation_with_context(&envelope, activation, &context)
2302 .await?;
2303 let after = self.inner.store.export_materialized(self.domain_id())?;
2304 let changes = materialization_changes(&before, &after);
2305 if !changes.is_empty() {
2306 let _no_subscribers = self.changes().send(ChangeBatch {
2307 commit_id: envelope.commit_id(),
2308 sequence: envelope.header().author_sequence(),
2309 frontier: self.inner.store.frontier(self.domain_id())?,
2310 changes,
2311 });
2312 }
2313 Ok(envelope.commit_id())
2314 }
2315
2316 fn seal_migration_commit_locked(
2317 &self,
2318 operations: Vec<Operation>,
2319 schemas: Vec<SchemaId>,
2320 permission: Permission,
2321 ) -> Result<CommitEnvelope, DbError> {
2322 self.require_migration_coordinator()?;
2323 let domain_id = self.domain_id();
2324 let active_epoch = self.inner.store.current_epoch(domain_id)?;
2325 if self.epoch() != active_epoch {
2326 return Err(DbError::StaleEpoch {
2327 handle: self.epoch(),
2328 active: active_epoch,
2329 });
2330 }
2331 let signer = self.inner.credentials.signer();
2332 let sequence = self
2333 .inner
2334 .store
2335 .last_sequence(domain_id, signer.author())?
2336 .map_or(Ok(0), |value| {
2337 value.checked_add(1).ok_or(DbError::SequenceExhausted)
2338 })?;
2339 let dependencies = self.inner.store.frontier(domain_id)?;
2340 let capability_id = match self
2341 .inner
2342 .store
2343 .active_capability(domain_id, signer.author())?
2344 {
2345 Some(capability_id)
2346 if self.inner.store.authorize_capability(
2347 domain_id,
2348 signer.author(),
2349 capability_id,
2350 permission,
2351 self.epoch(),
2352 )? =>
2353 {
2354 capability_id
2355 }
2356 Some(_) | None if self.inner.store.domain_descriptor(domain_id)?.is_some() => {
2357 return Err(DbError::UnauthorizedDevice);
2358 }
2359 None => private_capability(domain_id),
2360 Some(_) => return Err(DbError::UnauthorizedDevice),
2361 };
2362 let mut nonce = [0_u8; 24];
2363 let mut transaction_id = [0_u8; 32];
2364 getrandom::fill(&mut nonce).map_err(|_| DbError::RandomnessUnavailable)?;
2365 getrandom::fill(&mut transaction_id).map_err(|_| DbError::RandomnessUnavailable)?;
2366 let body = CommitBody::new(TransactionId::from_bytes(transaction_id), operations)?;
2367 let header = CommitHeader::new(
2368 domain_id,
2369 self.epoch(),
2370 signer.author(),
2371 sequence,
2372 dependencies,
2373 capability_id,
2374 schemas,
2375 nonce,
2376 )?;
2377 seal_commit(header, &body, &self.domain_key(), &signer).map_err(DbError::from)
2378 }
2379
2380 #[tracing::instrument(name = "iroh_db.apply", skip_all)]
2381 async fn apply(&self, prepared: Vec<PreparedRecord>) -> Result<ChangeBatch, DbError> {
2382 let started = Instant::now();
2383 let result = self.apply_inner(prepared).await;
2384 self.inner
2385 .telemetry
2386 .observe_local_apply(started.elapsed(), result.is_err());
2387 result
2388 }
2389
2390 async fn apply_inner(&self, prepared: Vec<PreparedRecord>) -> Result<ChangeBatch, DbError> {
2391 if prepared.is_empty() {
2392 return Err(DbError::EmptyTransaction);
2393 }
2394 validate_prepared_targets(&prepared)?;
2395 let _control_reader = self.inner.control_gate.read().await;
2396 let _writer = self.inner.writer.lock().await;
2397 let domain_id = self.domain_id();
2398 let active_epoch = self.inner.store.current_epoch(domain_id)?;
2399 if self.epoch() != active_epoch {
2400 return Err(DbError::StaleEpoch {
2401 handle: self.epoch(),
2402 active: active_epoch,
2403 });
2404 }
2405 self.validate_schema_writes(&prepared)?;
2406 let signer = self.inner.credentials.signer();
2407 let sequence = self
2408 .inner
2409 .store
2410 .last_sequence(domain_id, signer.author())?
2411 .map_or(Ok(0), |value| {
2412 value.checked_add(1).ok_or(DbError::SequenceExhausted)
2413 })?;
2414 let dependencies = self.inner.store.frontier(domain_id)?;
2415 let dependency_context = self.dependency_context(&dependencies).await?;
2416 let mut nonce = [0_u8; 24];
2417 let mut transaction_id = [0_u8; 32];
2418 getrandom::fill(&mut nonce).map_err(|_| DbError::RandomnessUnavailable)?;
2419 getrandom::fill(&mut transaction_id).map_err(|_| DbError::RandomnessUnavailable)?;
2420
2421 let adapters = self
2422 .inner
2423 .adapters
2424 .read()
2425 .expect("record adapter lock is not poisoned")
2426 .clone();
2427 let operations = canonical_operations(
2428 &prepared,
2429 &adapters,
2430 signer.author(),
2431 sequence,
2432 &dependency_context,
2433 )?;
2434 let schemas: Vec<SchemaId> = prepared.iter().map(|entry| entry.schema_id).collect();
2435 let body = CommitBody::new(TransactionId::from_bytes(transaction_id), operations)?;
2436 self.validate_consistency(signer.author(), &body)?;
2437 let capability_id = match self
2438 .inner
2439 .store
2440 .active_capability(domain_id, signer.author())?
2441 {
2442 Some(capability_id)
2443 if self.inner.store.authorize_capability(
2444 domain_id,
2445 signer.author(),
2446 capability_id,
2447 Permission::Write,
2448 self.epoch(),
2449 )? =>
2450 {
2451 capability_id
2452 }
2453 Some(_) | None if self.inner.store.domain_descriptor(domain_id)?.is_some() => {
2454 return Err(DbError::UnauthorizedDevice);
2455 }
2456 None => private_capability(domain_id),
2457 Some(_) => return Err(DbError::UnauthorizedDevice),
2458 };
2459 let header = CommitHeader::new(
2460 domain_id,
2461 self.epoch(),
2462 signer.author(),
2463 sequence,
2464 dependencies,
2465 capability_id,
2466 schemas,
2467 nonce,
2468 )?;
2469 let envelope = seal_commit(header, &body, &self.domain_key(), &signer)?;
2470 let converged = self
2471 .converged_mutations(&envelope, &body, &adapters)
2472 .await?;
2473 self.inner
2474 .store
2475 .persist_commit_with_reconciliation(
2476 &envelope,
2477 &converged.mutations,
2478 &converged.context,
2479 &converged.record_heads,
2480 )
2481 .await?;
2482 let batch = ChangeBatch {
2483 commit_id: envelope.commit_id(),
2484 sequence,
2485 frontier: self.inner.store.frontier(domain_id)?,
2486 changes: prepared.into_iter().map(|entry| entry.change).collect(),
2487 };
2488 let _no_subscribers = self.changes().send(batch.clone());
2489 Ok(batch)
2490 }
2491
2492 fn validate_schema_writes(&self, prepared: &[PreparedRecord]) -> Result<(), DbError> {
2493 for entry in prepared {
2494 let schema = entry.materialized.schema();
2495 self.ensure_collection_available(schema.collection_id())?;
2496 let Some(stored) = self
2497 .inner
2498 .store
2499 .latest_registered_schema(self.domain_id(), schema.collection_id())?
2500 else {
2501 continue;
2502 };
2503 if stored.schema_id() == schema.schema_id() {
2504 continue;
2505 }
2506 return Err(DbError::SchemaMigrationRequired {
2507 collection_id: schema.collection_id(),
2508 stored_version: stored.version(),
2509 requested_version: schema.version(),
2510 });
2511 }
2512 Ok(())
2513 }
2514
2515 fn ensure_collection_available(&self, collection_id: CollectionId) -> Result<(), DbError> {
2516 if self
2517 .inner
2518 .store
2519 .migration_frozen(self.domain_id(), collection_id)?
2520 {
2521 return Err(DbError::SchemaMigrationFrozen(collection_id));
2522 }
2523 Ok(())
2524 }
2525
2526 fn validate_consistency(&self, author: AuthorId, body: &CommitBody) -> Result<(), DbError> {
2527 let Some(descriptor) = self.inner.store.domain_descriptor(self.domain_id())? else {
2528 return Ok(());
2529 };
2530 match descriptor.mode() {
2531 ConsistencyMode::MultiWriter => Ok(()),
2532 ConsistencyMode::SingleWriter
2533 if self.inner.store.current_writer(self.domain_id())? == Some(author) =>
2534 {
2535 Ok(())
2536 }
2537 ConsistencyMode::SingleWriter => Err(DbError::UnauthorizedDevice),
2538 ConsistencyMode::AppendOnly => {
2539 for operation in body.operations() {
2540 if operation.kind() != OperationKind::FieldStateMerge
2541 || self
2542 .inner
2543 .store
2544 .get_record(
2545 self.domain_id(),
2546 operation.collection_id(),
2547 operation.record_id(),
2548 )?
2549 .is_some()
2550 {
2551 return Err(DbError::InvalidRemoteCommit(
2552 "append-only domains reject updates and deletes".into(),
2553 ));
2554 }
2555 let mutation = MaterializedRecord::decode_canonical(operation.payload())?;
2556 if mutation
2557 .schema()
2558 .fields()
2559 .iter()
2560 .filter(|field| !field.is_record_id())
2561 .any(|field| field.crdt() != iroh_db_core::CrdtKind::Immutable)
2562 {
2563 return Err(DbError::InvalidRemoteCommit(
2564 "append-only records require immutable fields".into(),
2565 ));
2566 }
2567 }
2568 Ok(())
2569 }
2570 }
2571 }
2572}
2573
2574pub struct Blobs {
2576 db: IrohDb,
2577}
2578
2579impl Blobs {
2580 pub async fn import_bytes(
2582 &self,
2583 bytes: Vec<u8>,
2584 media_type: Option<String>,
2585 ) -> Result<BlobRef, DbError> {
2586 self.engine()
2587 .import_bytes(bytes, media_type)
2588 .await
2589 .map_err(DbError::from)
2590 }
2591
2592 pub async fn import_reader(
2594 &self,
2595 reader: impl AsyncRead + Unpin,
2596 media_type: Option<String>,
2597 ) -> Result<BlobRef, DbError> {
2598 self.engine()
2599 .import_reader(reader, media_type)
2600 .await
2601 .map_err(DbError::from)
2602 }
2603
2604 pub async fn open(&self, reference: &BlobRef) -> Result<BlobStream, DbError> {
2606 self.engine().open(reference).await.map_err(DbError::from)
2607 }
2608
2609 fn engine(&self) -> BlobEngine {
2610 self.db.blob_engine()
2611 }
2612}
2613
2614pub struct Collection<Record> {
2616 db: IrohDb,
2617 marker: PhantomData<fn() -> Record>,
2618}
2619
2620impl<Record: IrohRecord> Collection<Record> {
2621 pub async fn put(&self, record: Record) -> Result<ChangeBatch, DbError> {
2623 self.db.apply(vec![prepare_upsert(&record)?]).await
2624 }
2625
2626 #[allow(clippy::unused_async)]
2628 pub async fn get(&self, id: &Record::Id) -> Result<Option<Record>, DbError> {
2629 let schema = Record::schema()?;
2630 self.db
2631 .ensure_collection_available(schema.collection_id())?;
2632 self.db
2633 .inner
2634 .store
2635 .get_record(
2636 self.db.domain_id(),
2637 schema.collection_id(),
2638 &id.encode_key()?,
2639 )?
2640 .map(|bytes| Record::decode_record(&bytes).map_err(DbError::from))
2641 .transpose()
2642 }
2643
2644 pub async fn delete(&self, id: &Record::Id) -> Result<ChangeBatch, DbError> {
2646 self.db.apply(vec![prepare_delete::<Record>(id)?]).await
2647 }
2648
2649 pub fn query(&self) -> Query<Record> {
2651 Query {
2652 db: self.db.clone(),
2653 filter: None,
2654 order: Vec::new(),
2655 limit: None,
2656 after: None,
2657 marker: PhantomData,
2658 }
2659 }
2660
2661 pub fn subscribe(&self) -> Result<Subscription<Record>, DbError> {
2663 let schema = Record::schema()?;
2664 self.db
2665 .ensure_collection_available(schema.collection_id())?;
2666 let receiver = self.db.changes().subscribe();
2667 let initial = self
2668 .db
2669 .inner
2670 .store
2671 .list_records(self.db.domain_id(), schema.collection_id())?
2672 .into_iter()
2673 .map(|entry| Record::decode_record(entry.state()).map_err(DbError::from))
2674 .collect::<Result<Vec<_>, _>>()?;
2675 Ok(Subscription {
2676 collection_id: schema.collection_id(),
2677 initial: Some(initial),
2678 receiver,
2679 })
2680 }
2681}
2682
2683pub struct Query<Record> {
2685 db: IrohDb,
2686 filter: Option<Predicate<Record>>,
2687 order: Vec<OrderClause<Record>>,
2688 limit: Option<usize>,
2689 after: Option<Cursor>,
2690 marker: PhantomData<fn() -> Record>,
2691}
2692
2693#[derive(Debug)]
2695pub enum Predicate<Record> {
2696 Equal(EqualityFilter<Record>),
2698 Range(RangeFilter<Record>),
2700}
2701
2702impl<Record> From<EqualityFilter<Record>> for Predicate<Record> {
2703 fn from(value: EqualityFilter<Record>) -> Self {
2704 Self::Equal(value)
2705 }
2706}
2707
2708impl<Record> From<RangeFilter<Record>> for Predicate<Record> {
2709 fn from(value: RangeFilter<Record>) -> Self {
2710 Self::Range(value)
2711 }
2712}
2713
2714#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2716pub enum OrderDirection {
2717 Ascending,
2719 Descending,
2721}
2722
2723type ValueComparator =
2724 dyn Fn(&[Vec<u8>], &[Vec<u8>]) -> Result<Ordering, RecordError> + Send + Sync;
2725type EncodedCandidate = (Vec<u8>, Vec<u8>);
2726
2727struct OrderClause<Record> {
2728 field_id: u32,
2729 direction: OrderDirection,
2730 compare: Arc<ValueComparator>,
2731 marker: PhantomData<fn() -> Record>,
2732}
2733
2734#[derive(Clone, PartialEq, Eq)]
2736pub struct Cursor(Vec<u8>);
2737
2738impl Cursor {
2739 pub fn from_bytes(bytes: Vec<u8>) -> Self {
2741 Self(bytes)
2742 }
2743
2744 pub fn as_bytes(&self) -> &[u8] {
2746 &self.0
2747 }
2748}
2749
2750impl std::fmt::Debug for Cursor {
2751 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2752 formatter.write_str("Cursor([opaque])")
2753 }
2754}
2755
2756#[derive(Debug)]
2758pub struct Page<Record> {
2759 items: Vec<Record>,
2760 next_cursor: Option<Cursor>,
2761}
2762
2763impl<Record> Page<Record> {
2764 pub fn items(&self) -> &[Record] {
2766 &self.items
2767 }
2768
2769 pub fn into_items(self) -> Vec<Record> {
2771 self.items
2772 }
2773
2774 pub const fn next_cursor(&self) -> Option<&Cursor> {
2776 self.next_cursor.as_ref()
2777 }
2778}
2779
2780impl<Record: IrohRecord> Query<Record> {
2781 #[must_use]
2783 pub fn filter(mut self, filter: impl Into<Predicate<Record>>) -> Self {
2784 self.filter = Some(filter.into());
2785 self
2786 }
2787
2788 #[must_use]
2790 pub fn order_by<Value>(
2791 mut self,
2792 field: TypedField<Record, Value>,
2793 direction: OrderDirection,
2794 ) -> Self
2795 where
2796 Value: RecordKey + Ord + Send + Sync + 'static,
2797 {
2798 self.order.clear();
2799 self.order.push(OrderClause {
2800 field_id: field.field_id(),
2801 direction,
2802 compare: Arc::new(compare_visible::<Value>),
2803 marker: PhantomData,
2804 });
2805 self
2806 }
2807
2808 #[must_use]
2810 pub fn then_by<Value>(
2811 mut self,
2812 field: TypedField<Record, Value>,
2813 direction: OrderDirection,
2814 ) -> Self
2815 where
2816 Value: RecordKey + Ord + Send + Sync + 'static,
2817 {
2818 self.order.push(OrderClause {
2819 field_id: field.field_id(),
2820 direction,
2821 compare: Arc::new(compare_visible::<Value>),
2822 marker: PhantomData,
2823 });
2824 self
2825 }
2826
2827 #[must_use]
2829 pub const fn limit(mut self, limit: usize) -> Self {
2830 self.limit = Some(limit);
2831 self
2832 }
2833
2834 #[must_use]
2836 pub fn after(mut self, cursor: Cursor) -> Self {
2837 self.after = Some(cursor);
2838 self
2839 }
2840
2841 #[allow(clippy::unused_async)]
2843 pub async fn fetch(self) -> Result<Vec<Record>, DbError> {
2844 Ok(self.fetch_page().await?.into_items())
2845 }
2846
2847 #[allow(clippy::unused_async)]
2849 #[tracing::instrument(name = "iroh_db.query", skip_all)]
2850 pub async fn fetch_page(self) -> Result<Page<Record>, DbError> {
2851 let telemetry = self.db.inner.telemetry.clone();
2852 let started = Instant::now();
2853 let result = self.fetch_page_inner();
2854 telemetry.observe_query(started.elapsed(), result.is_err());
2855 result
2856 }
2857
2858 fn fetch_page_inner(self) -> Result<Page<Record>, DbError> {
2859 let schema = Record::schema()?;
2860 self.db
2861 .ensure_collection_available(schema.collection_id())?;
2862 let domain = self.db.domain_id();
2863 let fingerprint = self.fingerprint(&schema)?;
2864 let candidates = self.candidates(domain, schema.collection_id(), &schema)?;
2865 self.db
2866 .inner
2867 .telemetry
2868 .add_index_records_scanned(candidates.len());
2869 let mut records = Vec::new();
2870 for (record_id, bytes) in candidates {
2871 let record = Record::decode_record(&bytes)?;
2872 if self.matches(&record)? {
2873 records.push(QueryRecord { record_id, record });
2874 }
2875 }
2876 if !self.order.is_empty() {
2877 let mut sort_error = None;
2878 records.sort_by(|left, right| {
2879 let result = compare_records(left, right, &self.order);
2880 match result {
2881 Ok(value) => value,
2882 Err(error) => {
2883 sort_error.get_or_insert(error);
2884 Ordering::Equal
2885 }
2886 }
2887 });
2888 if let Some(error) = sort_error {
2889 return Err(error);
2890 }
2891 }
2892
2893 let start = match &self.after {
2894 Some(cursor) => {
2895 let last_id = self.decode_cursor(cursor, schema.collection_id(), fingerprint)?;
2896 records
2897 .iter()
2898 .position(|entry| entry.record_id == last_id)
2899 .map(|position| position + 1)
2900 .ok_or_else(|| DbError::InvalidCursor("cursor position is stale".into()))?
2901 }
2902 None => 0,
2903 };
2904 let limit = self.limit.unwrap_or(usize::MAX);
2905 let end = start.saturating_add(limit).min(records.len());
2906 let has_more = end < records.len();
2907 let selected = &records[start..end];
2908 let next_cursor = if has_more {
2909 selected
2910 .last()
2911 .map(|entry| {
2912 self.encode_cursor(schema.collection_id(), fingerprint, &entry.record_id)
2913 })
2914 .transpose()?
2915 } else {
2916 None
2917 };
2918 let items = records
2919 .drain(start..end)
2920 .map(|entry| entry.record)
2921 .collect();
2922 Ok(Page { items, next_cursor })
2923 }
2924
2925 fn candidates(
2926 &self,
2927 domain: iroh_db_core::DomainId,
2928 collection: CollectionId,
2929 schema: &iroh_db_core::SchemaDescriptor,
2930 ) -> Result<Vec<EncodedCandidate>, DbError> {
2931 if let Some(Predicate::Equal(filter)) = &self.filter {
2932 let value = filter
2933 .encoded_value()
2934 .map_err(|error| DbError::InvalidQuery(error.clone()))?;
2935 let indexed = schema
2936 .fields()
2937 .iter()
2938 .any(|field| field.id() == filter.field_id() && field.is_indexed());
2939 if indexed {
2940 return self
2941 .db
2942 .inner
2943 .store
2944 .lookup_equal(domain, collection, filter.field_id(), value)?
2945 .into_iter()
2946 .filter_map(|id| {
2947 self.db
2948 .inner
2949 .store
2950 .get_record(domain, collection, &id)
2951 .transpose()
2952 .map(|result| result.map(|state| (id, state)))
2953 })
2954 .collect::<Result<Vec<_>, _>>()
2955 .map_err(DbError::from);
2956 }
2957 }
2958 Ok(self
2959 .db
2960 .inner
2961 .store
2962 .list_records(domain, collection)?
2963 .into_iter()
2964 .map(|entry| (entry.record_id().to_vec(), entry.state().to_vec()))
2965 .collect())
2966 }
2967
2968 fn matches(&self, record: &Record) -> Result<bool, DbError> {
2969 match &self.filter {
2970 Some(Predicate::Equal(filter)) => {
2971 let expected = filter
2972 .encoded_value()
2973 .map_err(|error| DbError::InvalidQuery(error.clone()))?;
2974 Ok(record
2975 .field_values(filter.field_id())?
2976 .iter()
2977 .any(|value| value == expected))
2978 }
2979 Some(Predicate::Range(filter)) => filter
2980 .matches(&record.field_values(filter.field_id())?)
2981 .map_err(DbError::InvalidQuery),
2982 None => Ok(true),
2983 }
2984 }
2985
2986 fn fingerprint(&self, schema: &iroh_db_core::SchemaDescriptor) -> Result<[u8; 32], DbError> {
2987 let mut hasher = blake3::Hasher::new();
2988 hasher.update(b"iroh-db/query/v1");
2989 hasher.update(schema.collection_id().as_bytes());
2990 hasher.update(schema.schema_id().as_bytes());
2991 match &self.filter {
2992 None => {
2993 hasher.update(&[0]);
2994 }
2995 Some(Predicate::Equal(filter)) => {
2996 hasher.update(&[1]);
2997 hasher.update(&filter.field_id().to_be_bytes());
2998 hasher.update(
2999 filter
3000 .encoded_value()
3001 .map_err(|error| DbError::InvalidQuery(error.clone()))?,
3002 );
3003 }
3004 Some(Predicate::Range(filter)) => {
3005 hasher.update(&[2, filter.operator() as u8]);
3006 hasher.update(&filter.field_id().to_be_bytes());
3007 hasher.update(
3008 filter
3009 .encoded_bound()
3010 .map_err(|error| DbError::InvalidQuery(error.clone()))?,
3011 );
3012 }
3013 }
3014 hasher.update(
3015 &u64::try_from(self.order.len())
3016 .unwrap_or(u64::MAX)
3017 .to_be_bytes(),
3018 );
3019 for order in &self.order {
3020 hasher.update(&[order.direction as u8]);
3021 hasher.update(&order.field_id.to_be_bytes());
3022 }
3023 Ok(*hasher.finalize().as_bytes())
3024 }
3025
3026 fn encode_cursor(
3027 &self,
3028 collection: CollectionId,
3029 fingerprint: [u8; 32],
3030 record_id: &[u8],
3031 ) -> Result<Cursor, DbError> {
3032 let domain = self.db.domain_id();
3033 let length = u32::try_from(record_id.len())
3034 .map_err(|_| DbError::InvalidCursor("record ID is too large".into()))?;
3035 let mut bytes = Vec::with_capacity(108 + record_id.len());
3036 bytes.extend_from_slice(b"IDBCUR01");
3037 bytes.extend_from_slice(domain.as_bytes());
3038 bytes.extend_from_slice(collection.as_bytes());
3039 bytes.extend_from_slice(&fingerprint);
3040 bytes.extend_from_slice(&length.to_be_bytes());
3041 bytes.extend_from_slice(record_id);
3042 let key = self
3043 .db
3044 .inner
3045 .credentials
3046 .domain_key()
3047 .derive_subkey(domain.as_bytes(), b"iroh-db/query-cursor/v1")?;
3048 bytes.extend_from_slice(blake3::keyed_hash(&key, &bytes).as_bytes());
3049 Ok(Cursor(bytes))
3050 }
3051
3052 fn decode_cursor(
3053 &self,
3054 cursor: &Cursor,
3055 collection: CollectionId,
3056 fingerprint: [u8; 32],
3057 ) -> Result<Vec<u8>, DbError> {
3058 let bytes = cursor.as_bytes();
3059 if bytes.len() < 140 || &bytes[..8] != b"IDBCUR01" {
3060 return Err(DbError::InvalidCursor("malformed cursor".into()));
3061 }
3062 let length = u32::from_be_bytes(
3063 bytes[104..108]
3064 .try_into()
3065 .map_err(|_| DbError::InvalidCursor("malformed cursor".into()))?,
3066 ) as usize;
3067 if bytes.len() != 140 + length {
3068 return Err(DbError::InvalidCursor("malformed cursor".into()));
3069 }
3070 let domain = self.db.domain_id();
3071 if &bytes[8..40] != domain.as_bytes()
3072 || &bytes[40..72] != collection.as_bytes()
3073 || bytes[72..104] != fingerprint
3074 {
3075 return Err(DbError::InvalidCursor(
3076 "cursor belongs to another query".into(),
3077 ));
3078 }
3079 let signed_len = bytes.len() - 32;
3080 let key = self
3081 .db
3082 .inner
3083 .credentials
3084 .domain_key()
3085 .derive_subkey(domain.as_bytes(), b"iroh-db/query-cursor/v1")?;
3086 let expected = blake3::keyed_hash(&key, &bytes[..signed_len]);
3087 if !constant_time_equal(expected.as_bytes(), &bytes[signed_len..]) {
3088 return Err(DbError::InvalidCursor(
3089 "cursor authentication failed".into(),
3090 ));
3091 }
3092 Ok(bytes[108..signed_len].to_vec())
3093 }
3094}
3095
3096struct QueryRecord<Record> {
3097 record_id: Vec<u8>,
3098 record: Record,
3099}
3100
3101fn compare_records<Record: IrohRecord>(
3102 left: &QueryRecord<Record>,
3103 right: &QueryRecord<Record>,
3104 orders: &[OrderClause<Record>],
3105) -> Result<Ordering, DbError> {
3106 for order in orders {
3107 let left_values = left.record.field_values(order.field_id)?;
3108 let right_values = right.record.field_values(order.field_id)?;
3109 let value_order = (order.compare)(&left_values, &right_values)?;
3110 let value_order = match order.direction {
3111 OrderDirection::Ascending => value_order,
3112 OrderDirection::Descending => value_order.reverse(),
3113 };
3114 if value_order != Ordering::Equal {
3115 return Ok(value_order);
3116 }
3117 }
3118 Ok(left.record_id.cmp(&right.record_id))
3119}
3120
3121fn compare_visible<Value: RecordKey + Ord>(
3122 left: &[Vec<u8>],
3123 right: &[Vec<u8>],
3124) -> Result<Ordering, RecordError> {
3125 let left = left
3126 .iter()
3127 .map(|value| Value::decode_key(value))
3128 .collect::<Result<Vec<_>, _>>()?
3129 .into_iter()
3130 .min();
3131 let right = right
3132 .iter()
3133 .map(|value| Value::decode_key(value))
3134 .collect::<Result<Vec<_>, _>>()?
3135 .into_iter()
3136 .min();
3137 Ok(left.cmp(&right))
3138}
3139
3140fn constant_time_equal(left: &[u8], right: &[u8]) -> bool {
3141 if left.len() != right.len() {
3142 return false;
3143 }
3144 left.iter()
3145 .zip(right)
3146 .fold(0_u8, |difference, (left, right)| {
3147 difference | (left ^ right)
3148 })
3149 == 0
3150}
3151
3152#[derive(Debug)]
3154pub enum SubscriptionEvent<Record> {
3155 Initial(Vec<Record>),
3157 Changes(ChangeBatch),
3159}
3160
3161pub struct Subscription<Record> {
3163 collection_id: CollectionId,
3164 initial: Option<Vec<Record>>,
3165 receiver: broadcast::Receiver<ChangeBatch>,
3166}
3167
3168impl<Record> Subscription<Record> {
3169 pub async fn recv(&mut self) -> Result<SubscriptionEvent<Record>, SubscriptionError> {
3171 if let Some(initial) = self.initial.take() {
3172 return Ok(SubscriptionEvent::Initial(initial));
3173 }
3174 loop {
3175 let batch = self.receiver.recv().await.map_err(|error| match error {
3176 broadcast::error::RecvError::Closed => SubscriptionError::Closed,
3177 broadcast::error::RecvError::Lagged(count) => SubscriptionError::Lagged(count),
3178 })?;
3179 let changes: Vec<_> = batch
3180 .changes
3181 .iter()
3182 .filter(|change| change.collection_id == self.collection_id)
3183 .cloned()
3184 .collect();
3185 if !changes.is_empty() {
3186 return Ok(SubscriptionEvent::Changes(ChangeBatch { changes, ..batch }));
3187 }
3188 }
3189 }
3190}
3191
3192pub struct Transaction {
3194 db: IrohDb,
3195 prepared: Vec<PreparedRecord>,
3196}
3197
3198impl Transaction {
3199 #[allow(clippy::needless_pass_by_value)]
3201 pub fn put<Record: IrohRecord>(&mut self, record: Record) -> Result<(), DbError> {
3202 register_record_adapter::<Record>(&self.db);
3203 self.prepared.push(prepare_upsert(&record)?);
3204 Ok(())
3205 }
3206
3207 pub fn delete<Record: IrohRecord>(&mut self, id: &Record::Id) -> Result<(), DbError> {
3209 register_record_adapter::<Record>(&self.db);
3210 self.prepared.push(prepare_delete::<Record>(id)?);
3211 Ok(())
3212 }
3213
3214 pub async fn commit(self) -> Result<ChangeBatch, DbError> {
3216 self.db.apply(self.prepared).await
3217 }
3218}
3219
3220struct PreparedRecord {
3221 materialized: MaterializedRecord,
3222 schema_id: SchemaId,
3223 change: Change,
3224}
3225
3226fn register_record_adapter<Record: IrohRecord>(database: &IrohDb) {
3227 let schema = Record::schema().expect("a record schema is statically valid");
3228 database
3229 .inner
3230 .adapters
3231 .write()
3232 .expect("record adapter lock is not poisoned")
3233 .insert(
3234 (schema.collection_id(), schema.schema_id()),
3235 RecordAdapter {
3236 merge: merge_materialized::<Record>,
3237 canonicalize: canonicalize_materialized::<Record>,
3238 },
3239 );
3240}
3241
3242fn migration_id(
3243 domain_id: iroh_db_core::DomainId,
3244 from: &iroh_db_core::SchemaDescriptor,
3245 to: &iroh_db_core::SchemaDescriptor,
3246 source_cut_digest: BlobHash,
3247) -> MigrationId {
3248 let mut hasher = blake3::Hasher::new();
3249 hasher.update(b"iroh-db/schema-migration/v1");
3250 hasher.update(domain_id.as_bytes());
3251 hasher.update(from.schema_id().as_bytes());
3252 hasher.update(to.schema_id().as_bytes());
3253 hasher.update(source_cut_digest.as_bytes());
3254 MigrationId::from_bytes(*hasher.finalize().as_bytes())
3255}
3256
3257fn source_cut_digest(records: &[StoredRecord]) -> BlobHash {
3258 let mut hasher = blake3::Hasher::new();
3259 hasher.update(b"iroh-db/schema-source-cut/v1");
3260 hasher.update(&(records.len() as u64).to_be_bytes());
3261 for record in records {
3262 hasher.update(&(record.record_id().len() as u64).to_be_bytes());
3263 hasher.update(record.record_id());
3264 hasher.update(&(record.state().len() as u64).to_be_bytes());
3265 hasher.update(record.state());
3266 }
3267 BlobHash::from_bytes(*hasher.finalize().as_bytes())
3268}
3269
3270fn validate_prepared_targets(prepared: &[PreparedRecord]) -> Result<(), DbError> {
3271 let mut targets = BTreeSet::new();
3272 for entry in prepared {
3273 let target = (entry.change.collection_id, entry.change.record_id.clone());
3274 if !targets.insert(target) {
3275 return Err(DbError::InvalidRemoteCommit(
3276 "a commit mutates one record more than once".into(),
3277 ));
3278 }
3279 }
3280 Ok(())
3281}
3282
3283fn canonical_operations(
3284 prepared: &[PreparedRecord],
3285 adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
3286 author: AuthorId,
3287 sequence: u64,
3288 dependency_context: &VersionVector,
3289) -> Result<Vec<Operation>, DbError> {
3290 let mut next_operation = 0;
3291 let mut operations = Vec::with_capacity(prepared.len());
3292 for entry in prepared {
3293 let mutation = if entry.materialized.state().is_some() {
3294 let adapter = adapters
3295 .get(&(entry.change.collection_id, entry.schema_id))
3296 .ok_or(DbError::UnknownRecordSchema(entry.change.collection_id))?;
3297 (adapter.canonicalize)(
3298 &entry.materialized,
3299 author,
3300 sequence,
3301 dependency_context,
3302 &mut next_operation,
3303 )?
3304 } else {
3305 entry.materialized.clone()
3306 };
3307 operations.push(Operation::new(
3308 mutation.schema().collection_id(),
3309 mutation.record_id().to_vec(),
3310 0,
3311 if mutation.state().is_some() {
3312 OperationKind::FieldStateMerge
3313 } else {
3314 OperationKind::RecordDelete
3315 },
3316 mutation.encode_canonical()?,
3317 )?);
3318 }
3319 Ok(operations)
3320}
3321
3322fn prepare_upsert<Record: IrohRecord>(record: &Record) -> Result<PreparedRecord, DbError> {
3323 let schema = Record::schema()?;
3324 let record_id = record.record_id()?;
3325 let state = record.encode_record()?;
3326 let mut indexes = Vec::new();
3327 for field in schema.fields().iter().filter(|field| field.is_indexed()) {
3328 for value in record.field_values(field.id())? {
3329 indexes.push(IndexValue::new(field.id(), value));
3330 }
3331 }
3332 let materialized =
3333 MaterializedRecord::upsert(schema.clone(), record_id.clone(), state, indexes)?;
3334 Ok(PreparedRecord {
3335 materialized,
3336 schema_id: schema.schema_id(),
3337 change: Change {
3338 collection_id: schema.collection_id(),
3339 record_id,
3340 kind: ChangeKind::Upserted,
3341 },
3342 })
3343}
3344
3345fn prepare_delete<Record: IrohRecord>(id: &Record::Id) -> Result<PreparedRecord, DbError> {
3346 let schema = Record::schema()?;
3347 let record_id = id.encode_key()?;
3348 let materialized = MaterializedRecord::delete(schema.clone(), record_id.clone())?;
3349 Ok(PreparedRecord {
3350 materialized,
3351 schema_id: schema.schema_id(),
3352 change: Change {
3353 collection_id: schema.collection_id(),
3354 record_id,
3355 kind: ChangeKind::Deleted,
3356 },
3357 })
3358}
3359
3360type RecordTarget = (CollectionId, Vec<u8>);
3361type RecordCandidates = BTreeMap<RecordTarget, Vec<(CommitId, MaterializedRecord)>>;
3362
3363struct ConvergedCommit {
3364 mutations: Vec<MaterializedRecord>,
3365 context: VersionVector,
3366 record_heads: Vec<RecordHeadSet>,
3367}
3368
3369enum MigrationBody {
3370 Ordinary,
3371 Stages(Vec<SchemaStage>),
3372 Activation(Box<SchemaActivation>),
3373}
3374
3375fn decode_migration_body(
3376 envelope: &CommitEnvelope,
3377 body: &CommitBody,
3378) -> Result<MigrationBody, DbError> {
3379 let has_migration = body.operations().iter().any(|operation| {
3380 matches!(
3381 operation.kind(),
3382 OperationKind::SchemaStage | OperationKind::SchemaActivate
3383 )
3384 });
3385 if !has_migration {
3386 return Ok(MigrationBody::Ordinary);
3387 }
3388 if body
3389 .operations()
3390 .iter()
3391 .all(|operation| operation.kind() == OperationKind::SchemaStage)
3392 {
3393 let mut stages = Vec::with_capacity(body.operations().len());
3394 for operation in body.operations() {
3395 let stage = SchemaStage::decode_canonical(operation.payload())?;
3396 if operation.collection_id() != stage.to().collection_id()
3397 || operation.record_id() != stage.source_record_id()
3398 || operation.field_id() != 0
3399 || !envelope
3400 .header()
3401 .schema_ids()
3402 .contains(&stage.from().schema_id())
3403 || !envelope
3404 .header()
3405 .schema_ids()
3406 .contains(&stage.to().schema_id())
3407 {
3408 return Err(DbError::InvalidRemoteCommit(
3409 "migration stage target or schema header is inconsistent".into(),
3410 ));
3411 }
3412
3413 stages.push(stage);
3414 }
3415 staging_digest(&stages)?;
3416 return Ok(MigrationBody::Stages(stages));
3417 }
3418 if body.operations().len() == 1 && body.operations()[0].kind() == OperationKind::SchemaActivate
3419 {
3420 let operation = &body.operations()[0];
3421 let activation = SchemaActivation::decode_canonical(operation.payload())?;
3422 if operation.collection_id() != activation.to().collection_id()
3423 || !operation.record_id().is_empty()
3424 || operation.field_id() != 0
3425 || !envelope
3426 .header()
3427 .schema_ids()
3428 .contains(&activation.from().schema_id())
3429 || !envelope
3430 .header()
3431 .schema_ids()
3432 .contains(&activation.to().schema_id())
3433 {
3434 return Err(DbError::InvalidRemoteCommit(
3435 "schema activation target or schema header is inconsistent".into(),
3436 ));
3437 }
3438 return Ok(MigrationBody::Activation(Box::new(activation)));
3439 }
3440 Err(DbError::InvalidRemoteCommit(
3441 "migration and record operations cannot share one commit".into(),
3442 ))
3443}
3444
3445fn validate_staged_records(
3446 stages: &[SchemaStage],
3447 adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
3448) -> Result<(), DbError> {
3449 for stage in stages {
3450 if !adapters.contains_key(&(stage.from().collection_id(), stage.from().schema_id())) {
3451 return Err(DbError::UnknownRecordSchema(stage.from().collection_id()));
3452 }
3453 let adapter = adapters
3454 .get(&(stage.to().collection_id(), stage.to().schema_id()))
3455 .ok_or(DbError::UnknownRecordSchema(stage.to().collection_id()))?;
3456 let target = MaterializedRecord::decode_canonical(stage.target_record())?;
3457 if (adapter.merge)(None, &target)? != target {
3458 return Err(DbError::InvalidRemoteCommit(
3459 "staged target is not canonical for its registered record type".into(),
3460 ));
3461 }
3462 }
3463 Ok(())
3464}
3465
3466fn materialization_changes(
3467 before: &[MaterializedRecord],
3468 after: &[MaterializedRecord],
3469) -> Vec<Change> {
3470 let before = present_records(before);
3471 let after = present_records(after);
3472 before
3473 .keys()
3474 .chain(after.keys())
3475 .cloned()
3476 .collect::<BTreeSet<_>>()
3477 .into_iter()
3478 .filter(|target| before.get(target) != after.get(target))
3479 .map(|(collection_id, record_id)| Change {
3480 collection_id,
3481 kind: if after.contains_key(&(collection_id, record_id.clone())) {
3482 ChangeKind::Upserted
3483 } else {
3484 ChangeKind::Deleted
3485 },
3486 record_id,
3487 })
3488 .collect()
3489}
3490
3491fn present_records(values: &[MaterializedRecord]) -> BTreeMap<RecordTarget, &MaterializedRecord> {
3492 values
3493 .iter()
3494 .filter(|record| record.state().is_some())
3495 .map(|record| {
3496 (
3497 (record.schema().collection_id(), record.record_id().to_vec()),
3498 record,
3499 )
3500 })
3501 .collect()
3502}
3503
3504fn validate_incoming_operations(
3505 envelope: &CommitEnvelope,
3506 body: &CommitBody,
3507 adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
3508 dependency_context: &VersionVector,
3509) -> Result<BTreeMap<RecordTarget, RecordAdapter>, DbError> {
3510 let mut affected = BTreeMap::new();
3511 let mut next_operation = 0;
3512 for operation in body.operations() {
3513 if !matches!(
3514 operation.kind(),
3515 OperationKind::FieldStateMerge | OperationKind::RecordDelete
3516 ) {
3517 return Err(DbError::InvalidRemoteCommit(
3518 "unsupported operation kind".into(),
3519 ));
3520 }
3521 let mutation = MaterializedRecord::decode_canonical(operation.payload())?;
3522 if mutation.schema().collection_id() != operation.collection_id()
3523 || mutation.record_id() != operation.record_id()
3524 || !envelope
3525 .header()
3526 .schema_ids()
3527 .contains(&mutation.schema().schema_id())
3528 {
3529 return Err(DbError::InvalidRemoteCommit(
3530 "schema or record target mismatch".into(),
3531 ));
3532 }
3533 let adapter = *adapters
3534 .get(&(operation.collection_id(), mutation.schema().schema_id()))
3535 .ok_or(DbError::UnknownRecordSchema(operation.collection_id()))?;
3536 if mutation.state().is_some() {
3537 let canonical = (adapter.canonicalize)(
3538 &mutation,
3539 envelope.header().author(),
3540 envelope.header().author_sequence(),
3541 dependency_context,
3542 &mut next_operation,
3543 )?;
3544 if canonical != mutation {
3545 return Err(DbError::InvalidRemoteCommit(
3546 "record contains causal metadata not issued by its commit".into(),
3547 ));
3548 }
3549 }
3550 let key = (operation.collection_id(), operation.record_id().to_vec());
3551 if affected.insert(key, adapter).is_some() {
3552 return Err(DbError::InvalidRemoteCommit(
3553 "a commit mutates one record more than once".into(),
3554 ));
3555 }
3556 }
3557 Ok(affected)
3558}
3559
3560fn collect_affected_operations(
3561 commit_id: CommitId,
3562 body: &CommitBody,
3563 affected: &BTreeMap<RecordTarget, RecordAdapter>,
3564 candidates: &mut RecordCandidates,
3565) -> Result<(), DbError> {
3566 for operation in body.operations() {
3567 let key = (operation.collection_id(), operation.record_id().to_vec());
3568 if !affected.contains_key(&key) {
3569 continue;
3570 }
3571 let mutation = MaterializedRecord::decode_canonical(operation.payload())?;
3572 if mutation.schema().collection_id() != operation.collection_id()
3573 || mutation.record_id() != operation.record_id()
3574 {
3575 return Err(DbError::InvalidRemoteCommit(
3576 "accepted operation target is inconsistent".into(),
3577 ));
3578 }
3579 candidates
3580 .entry(key)
3581 .or_default()
3582 .push((commit_id, mutation));
3583 }
3584 Ok(())
3585}
3586
3587fn resolve_record_heads(
3588 key: &RecordTarget,
3589 entries: &[(CommitId, MaterializedRecord)],
3590 graph: &BTreeMap<CommitId, Vec<CommitId>>,
3591 adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
3592) -> Result<MaterializedRecord, DbError> {
3593 let mut heads: Vec<_> = entries
3594 .iter()
3595 .filter(|(candidate, _)| {
3596 !entries
3597 .iter()
3598 .any(|(other, _)| candidate != other && is_ancestor(*candidate, *other, graph))
3599 })
3600 .cloned()
3601 .collect();
3602 heads.sort_unstable_by_key(|(commit_id, _)| *commit_id);
3603 resolve_causal_record_heads(key, &heads, adapters)
3604}
3605
3606fn resolve_causal_record_heads(
3607 key: &RecordTarget,
3608 entries: &[(CommitId, MaterializedRecord)],
3609 adapters: &BTreeMap<(CollectionId, SchemaId), RecordAdapter>,
3610) -> Result<MaterializedRecord, DbError> {
3611 let mut heads: Vec<_> = entries.iter().collect();
3612 heads.sort_unstable_by_key(|(commit_id, _)| *commit_id);
3613 let active_version = heads
3614 .iter()
3615 .map(|(_, mutation)| mutation.schema().version())
3616 .max()
3617 .ok_or_else(|| DbError::InvalidRemoteCommit("record has no causal head".into()))?;
3618 heads.retain(|(_, mutation)| mutation.schema().version() == active_version);
3619 let active_schema = heads[0].1.schema().schema_id();
3620 if heads
3621 .iter()
3622 .any(|(_, mutation)| mutation.schema().schema_id() != active_schema)
3623 {
3624 return Err(DbError::InvalidRemoteCommit(
3625 "concurrent schema descriptors share one version".into(),
3626 ));
3627 }
3628 let adapter = *adapters
3629 .get(&(key.0, active_schema))
3630 .ok_or(DbError::UnknownRecordSchema(key.0))?;
3631 if heads.iter().any(|(_, mutation)| mutation.state().is_some()) {
3632 let mut merged: Option<MaterializedRecord> = None;
3633 for (_, mutation) in heads
3634 .into_iter()
3635 .filter(|(_, mutation)| mutation.state().is_some())
3636 {
3637 merged = Some((adapter.merge)(
3638 merged.as_ref().and_then(MaterializedRecord::state),
3639 mutation,
3640 )?);
3641 }
3642 return merged
3643 .ok_or_else(|| DbError::InvalidRemoteCommit("record head resolution failed".into()));
3644 }
3645 let (_, deletion) = heads
3646 .first()
3647 .ok_or_else(|| DbError::InvalidRemoteCommit("record has no causal head".into()))?;
3648 MaterializedRecord::delete(deletion.schema().clone(), key.1.clone()).map_err(DbError::from)
3649}
3650
3651fn is_ancestor(
3652 ancestor: CommitId,
3653 descendant: CommitId,
3654 graph: &BTreeMap<CommitId, Vec<CommitId>>,
3655) -> bool {
3656 let mut pending = vec![descendant];
3657 let mut visited = BTreeSet::new();
3658 while let Some(commit_id) = pending.pop() {
3659 if !visited.insert(commit_id) {
3660 continue;
3661 }
3662 let Some(dependencies) = graph.get(&commit_id) else {
3663 continue;
3664 };
3665 if dependencies.contains(&ancestor) {
3666 return true;
3667 }
3668 pending.extend(dependencies);
3669 }
3670 false
3671}
3672
3673fn quarantine_metadata(
3674 baseline: Option<&SnapshotBaseline>,
3675 accepted: &[CommitId],
3676 graph: &BTreeMap<CommitId, Vec<CommitId>>,
3677 headers: &BTreeMap<CommitId, (AuthorId, u64)>,
3678) -> QuarantineMetadata {
3679 let mut frontier_candidates: BTreeSet<_> = baseline.map_or_else(BTreeSet::new, |baseline| {
3680 baseline.frontier().iter().copied().collect()
3681 });
3682 frontier_candidates.extend(accepted.iter().copied());
3683 let referenced: BTreeSet<_> = accepted
3684 .iter()
3685 .flat_map(|commit_id| graph[commit_id].iter().copied())
3686 .filter(|dependency| frontier_candidates.contains(dependency))
3687 .collect();
3688 let frontier = frontier_candidates
3689 .into_iter()
3690 .filter(|commit_id| !referenced.contains(commit_id))
3691 .collect();
3692 let mut author_sequences: BTreeMap<AuthorId, u64> = baseline
3693 .map_or_else(BTreeMap::new, |baseline| {
3694 baseline.author_sequences().iter().copied().collect()
3695 });
3696 let mut author_heads = BTreeMap::<AuthorId, (u64, CommitId)>::new();
3697 for commit_id in accepted {
3698 let (author, sequence) = headers[commit_id];
3699 author_sequences
3700 .entry(author)
3701 .and_modify(|current| *current = (*current).max(sequence))
3702 .or_insert(sequence);
3703 author_heads
3704 .entry(author)
3705 .and_modify(|current| {
3706 if sequence > current.0 {
3707 *current = (sequence, *commit_id);
3708 }
3709 })
3710 .or_insert((sequence, *commit_id));
3711 }
3712 QuarantineMetadata {
3713 frontier,
3714 author_sequences: author_sequences.into_iter().collect(),
3715 author_heads: author_heads
3716 .into_iter()
3717 .map(|(author, (sequence, commit_id))| (author, sequence, commit_id))
3718 .collect(),
3719 }
3720}
3721
3722fn merge_materialized<Record: IrohRecord>(
3723 existing: Option<&[u8]>,
3724 incoming: &MaterializedRecord,
3725) -> Result<MaterializedRecord, DbError> {
3726 let schema = Record::schema()?;
3727 if schema.schema_id() != incoming.schema().schema_id() {
3728 return Err(DbError::InvalidRemoteCommit(
3729 "registered schema mismatch".into(),
3730 ));
3731 }
3732 let Some(incoming_state) = incoming.state() else {
3733 return MaterializedRecord::delete(schema, incoming.record_id().to_vec())
3734 .map_err(DbError::from);
3735 };
3736 let mut record = Record::decode_record(incoming_state)?;
3737 if record.record_id()? != incoming.record_id() {
3738 return Err(DbError::InvalidRemoteCommit(
3739 "encoded record ID mismatch".into(),
3740 ));
3741 }
3742 if let Some(existing) = existing {
3743 let existing = Record::decode_record(existing)?;
3744 record.merge_record(&existing)?;
3745 }
3746 let mut indexes = Vec::new();
3747 for field in schema.fields().iter().filter(|field| field.is_indexed()) {
3748 for value in record.field_values(field.id())? {
3749 indexes.push(IndexValue::new(field.id(), value));
3750 }
3751 }
3752 MaterializedRecord::upsert(
3753 schema,
3754 incoming.record_id().to_vec(),
3755 record.encode_record()?,
3756 indexes,
3757 )
3758 .map_err(DbError::from)
3759}
3760
3761fn canonicalize_materialized<Record: IrohRecord>(
3762 incoming: &MaterializedRecord,
3763 author: AuthorId,
3764 sequence: u64,
3765 context: &VersionVector,
3766 next_operation: &mut u32,
3767) -> Result<MaterializedRecord, DbError> {
3768 let schema = Record::schema()?;
3769 if schema.schema_id() != incoming.schema().schema_id() {
3770 return Err(DbError::InvalidRemoteCommit(
3771 "registered schema mismatch".into(),
3772 ));
3773 }
3774 let Some(state) = incoming.state() else {
3775 return MaterializedRecord::delete(schema, incoming.record_id().to_vec())
3776 .map_err(DbError::from);
3777 };
3778 let mut record = Record::decode_record(state)?;
3779 if record.record_id()? != incoming.record_id() {
3780 return Err(DbError::InvalidRemoteCommit(
3781 "encoded record ID mismatch".into(),
3782 ));
3783 }
3784 record.canonicalize_causality(author, sequence, context, next_operation)?;
3785 let mut indexes = Vec::new();
3786 for field in schema.fields().iter().filter(|field| field.is_indexed()) {
3787 for value in record.field_values(field.id())? {
3788 indexes.push(IndexValue::new(field.id(), value));
3789 }
3790 }
3791 MaterializedRecord::upsert(
3792 schema,
3793 incoming.record_id().to_vec(),
3794 record.encode_record()?,
3795 indexes,
3796 )
3797 .map_err(DbError::from)
3798}
3799
3800fn reconcile_domain_epochs(credentials: &dyn KeyProvider, store: &Store) -> Result<(), DbError> {
3801 for domain_id in credentials.domains()? {
3802 if store.domain_descriptor(domain_id)?.is_none() {
3803 continue;
3804 }
3805 let active_epoch = match store.current_epoch(domain_id) {
3806 Ok(epoch) => epoch,
3807 Err(StoreError::ControlFork { .. }) => continue,
3808 Err(error) => return Err(DbError::Store(error)),
3809 };
3810 let current = credentials.load_domain(domain_id)?;
3811 if current.epoch() == active_epoch {
3812 continue;
3813 }
3814 let distance = current.epoch().abs_diff(active_epoch);
3815 if distance != 1 {
3816 return Err(DbError::Operation(format!(
3817 "domain {domain_id} key epoch differs from control by {distance}"
3818 )));
3819 }
3820 let authoritative = credentials.load_domain_epoch(domain_id, active_epoch)?;
3821 if active_epoch > 0 {
3822 let head = store.control_head(domain_id)?.ok_or_else(|| {
3823 DbError::Operation("active epoch has no control transition".into())
3824 })?;
3825 if head.new_epoch() != active_epoch
3826 || head.key_distribution_digest() != authoritative.distribution_digest()
3827 {
3828 return Err(DbError::Operation(
3829 "staged epoch key does not match the active control transition".into(),
3830 ));
3831 }
3832 }
3833 credentials.activate_domain(&authoritative)?;
3834 }
3835 Ok(())
3836}
3837
3838pub(crate) fn private_capability(domain_id: iroh_db_core::DomainId) -> CapabilityId {
3839 let mut hasher = blake3::Hasher::new();
3840 hasher.update(b"iroh-db/private-capability/v1");
3841 hasher.update(domain_id.as_bytes());
3842 CapabilityId::from_bytes(*hasher.finalize().as_bytes())
3843}
3844
3845#[allow(clippy::needless_pass_by_value)]
3846fn blob_metadata_error(error: impl std::fmt::Display) -> BlobStoreError {
3847 BlobStoreError::new(error.to_string())
3848}
3849
3850#[cfg(test)]
3851mod control_recovery_tests {
3852 use super::*;
3853
3854 #[tokio::test]
3855 async fn open_activates_the_staged_key_selected_by_durable_control() {
3856 let directory = tempfile::tempdir().unwrap();
3857 let device = IrohDb::open(directory.path()).await.unwrap();
3858 let domain = device.create_domain(ConsistencyMode::MultiWriter).unwrap();
3859 let domain_id = domain.domain_id();
3860 let old_seed = domain.domain_seed();
3861 let (next, _) = domain.rotate_epoch(&[]).unwrap();
3862 assert_eq!(next.domain_seed().epoch(), 1);
3863
3864 device.inner.credentials.activate_domain(&old_seed).unwrap();
3865 assert_eq!(
3866 device
3867 .inner
3868 .credentials
3869 .load_domain(domain_id)
3870 .unwrap()
3871 .epoch(),
3872 0
3873 );
3874 drop(domain);
3875 drop(next);
3876 device.close().await.unwrap();
3877
3878 let reopened = IrohDb::open(directory.path()).await.unwrap();
3879 let recovered = reopened.domain(domain_id).unwrap();
3880 assert_eq!(recovered.domain_seed().epoch(), 1);
3881 drop(recovered);
3882 reopened.close().await.unwrap();
3883 }
3884
3885 #[tokio::test]
3886 async fn control_mutation_fails_while_a_disclosure_read_permit_is_held() {
3887 let directory = tempfile::tempdir().unwrap();
3888 let device = IrohDb::open(directory.path()).await.unwrap();
3889 let domain = device.create_domain(ConsistencyMode::MultiWriter).unwrap();
3890 let disclosure = device.inner.control_gate.read().await;
3891 assert!(matches!(
3892 domain.rotate_epoch(&[]),
3893 Err(DbError::Operation(message)) if message == "control plane is busy"
3894 ));
3895 assert!(matches!(
3896 domain.invite(
3897 AuthorId::from_bytes([91; 32]),
3898 PermissionSet::from_permissions(&[Permission::Read]),
3899 PermissionSet::empty(),
3900 None,
3901 ),
3902 Err(DbError::Operation(message)) if message == "control plane is busy"
3903 ));
3904 assert!(domain.domain_descriptor().unwrap().is_some());
3905 drop(disclosure);
3906 let (rotated, _) = domain.rotate_epoch(&[]).unwrap();
3907 assert_eq!(rotated.domain_seed().epoch(), 1);
3908 drop(domain);
3909 drop(rotated);
3910 device.close().await.unwrap();
3911 }
3912
3913 #[tokio::test]
3914 async fn snapshot_operations_wait_for_a_stable_authority_position() {
3915 let directory = tempfile::tempdir().unwrap();
3916 let device = IrohDb::open(directory.path()).await.unwrap();
3917 let domain = device.create_domain(ConsistencyMode::MultiWriter).unwrap();
3918 let snapshot = domain.snapshot().await.unwrap();
3919
3920 let authority_mutation = device.inner.control_gate.write().await;
3921 let capture_domain = domain.clone();
3922 let mut capture = tokio::spawn(async move { capture_domain.snapshot().await });
3923 assert!(
3924 tokio::time::timeout(std::time::Duration::from_millis(50), &mut capture)
3925 .await
3926 .is_err()
3927 );
3928 drop(authority_mutation);
3929 capture.await.unwrap().unwrap();
3930
3931 let authority_mutation = device.inner.control_gate.write().await;
3932 let install_domain = domain.clone();
3933 let mut install =
3934 tokio::spawn(async move { install_domain.install_snapshot(snapshot).await });
3935 assert!(
3936 tokio::time::timeout(std::time::Duration::from_millis(50), &mut install)
3937 .await
3938 .is_err()
3939 );
3940 drop(authority_mutation);
3941 install.await.unwrap().unwrap();
3942
3943 drop(domain);
3944 device.close().await.unwrap();
3945 }
3946
3947 #[tokio::test]
3948 async fn rotation_updates_every_live_handle_for_the_domain() {
3949 let directory = tempfile::tempdir().unwrap();
3950 let device = IrohDb::open(directory.path()).await.unwrap();
3951 let domain = device.create_domain(ConsistencyMode::MultiWriter).unwrap();
3952 let clone = domain.clone();
3953 let (rotated, _) = domain.rotate_epoch(&[]).unwrap();
3954
3955 assert_eq!(clone.epoch(), 1);
3956 assert_eq!(clone.domain_seed().epoch(), 1);
3957 assert_eq!(rotated.epoch(), 1);
3958
3959 drop(domain);
3960 drop(clone);
3961 drop(rotated);
3962 device.close().await.unwrap();
3963 }
3964}