lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 1e578cacad013e6275ab1537696a29e37577de2f
parent 1c585556179cc36df79ff5c289ef097d1029429b
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 04:46:33 +0000

storage-sqlite: persist resumable import journals

- add the governed forward runtime migration for import recovery
- bind one retained journal to exact target evidence and classification
- enforce immutable identities monotonic states counts and timestamps
- prove idempotent reopen resume conflict and database guard behavior

Diffstat:
Acontracts/storage/legacy_import_journal_policy_v1.toml | 15+++++++++++++++
Mcontracts/storage/runtime_schema_v1.toml | 53++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/storage_sqlite/README.md | 8++++++++
Mcrates/storage_sqlite/src/legacy.rs | 746+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/storage_sqlite/src/migration.rs | 17+++++++++--------
Acrates/storage_sqlite/src/migration/runtime/0006_legacy_import_journal.up.sql | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage_sqlite/src/migration/runtime/mod.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/storage_sqlite/src/open.rs | 12++++++++++++
8 files changed, 976 insertions(+), 28 deletions(-)

diff --git a/contracts/storage/legacy_import_journal_policy_v1.toml b/contracts/storage/legacy_import_journal_policy_v1.toml @@ -0,0 +1,15 @@ +schema_version = 1 +authority = "runtime_sqlite_owned_forward_migration_v6" +identity = ["import_id", "target_generation", "manifest_sha256", "classification_sha256"] +import_states = ["classified", "staging", "ready", "committing", "complete"] +member_states = ["pending", "staging", "ready", "complete"] +imports_per_target_generation = 1 +begin = "atomic_exact_idempotent_or_conflict" +resume = "read_exact_durable_state" +source_members = "one_exact_row_per_classified_source" +resume_cursor = "opaque_nullable_bytes" +staged_row_count = "non_negative" +host_timestamp = "positive_monotonic_per_import" +hidden_clock_or_entropy = false +legacy_row_conversion = false +live_product_row_mutation = false diff --git a/contracts/storage/runtime_schema_v1.toml b/contracts/storage/runtime_schema_v1.toml @@ -2,9 +2,9 @@ schema_version = 1 database = "runtime.sqlite" application_id = 1380209236 # ASCII "RDRT" minimum_version = 1 -current_version = 5 -migration_name = "projection_metadata" -migration_sha256 = "ef162e3591057414b5704ff07fc93e57127ad7996ab3d431541418fd9c285e51" +current_version = 6 +migration_name = "legacy_import_journal" +migration_sha256 = "5858336c25b048d6cdae1beedc8bf0bdfb530b858c3c50f501621c3314d23d4a" forward_only = true raw_sql_public = false @@ -17,6 +17,7 @@ authorities = [ "projection_coordination", "event_index_metadata", "atomic_commit_receipts", + "legacy_import_recovery", ] source_invariants = [ @@ -206,3 +207,49 @@ owned_objects = [ "radroots_runtime_source_generations_identity_guard", "radroots_runtime_source_generations_sequence_guard", ] + +[[migrations]] +version = 6 +name = "legacy_import_journal" +sha256 = "5858336c25b048d6cdae1beedc8bf0bdfb530b858c3c50f501621c3314d23d4a" + +owned_objects = [ + "radroots_runtime_atomic_commits", + "radroots_runtime_delivery_evidence", + "radroots_runtime_delivery_evidence_item_idx", + "radroots_runtime_event_index_checkpoints", + "radroots_runtime_event_index_manifests", + "radroots_runtime_event_index_shards", + "radroots_runtime_event_provenance", + "radroots_runtime_event_provenance_observed_idx", + "radroots_runtime_events", + "radroots_runtime_events_admission_idx", + "radroots_runtime_events_delete_guard", + "radroots_runtime_events_event_id_idx", + "radroots_runtime_events_raw_update_guard", + "radroots_runtime_journal_idempotency_idx", + "radroots_runtime_journal_operations", + "radroots_runtime_journal_recovery_idx", + "radroots_runtime_legacy_import_delete_guard", + "radroots_runtime_legacy_import_identity_guard", + "radroots_runtime_legacy_import_member_delete_guard", + "radroots_runtime_legacy_import_member_identity_guard", + "radroots_runtime_legacy_import_member_state_guard", + "radroots_runtime_legacy_import_members", + "radroots_runtime_legacy_import_state_guard", + "radroots_runtime_legacy_import_state_idx", + "radroots_runtime_legacy_imports", + "radroots_runtime_outbox_items", + "radroots_runtime_outbox_operation_idx", + "radroots_runtime_outbox_ready_idx", + "radroots_runtime_outbox_targets", + "radroots_runtime_projection_checkpoints", + "radroots_runtime_projection_invalidations", + "radroots_runtime_projection_rebuilds", + "radroots_runtime_projection_rebuilds_stage_idx", + "radroots_runtime_source_generations", + "radroots_runtime_source_generations_active_idx", + "radroots_runtime_source_generations_delete_guard", + "radroots_runtime_source_generations_identity_guard", + "radroots_runtime_source_generations_sequence_guard", +] diff --git a/crates/storage_sqlite/README.md b/crates/storage_sqlite/README.md @@ -48,6 +48,14 @@ source families, checksum drift, and newer schemas fail closed. Studio records are explicitly classified for host handoff and are never imported into SDK storage. +Beginning an import writes only governed recovery metadata. Runtime schema v6 +binds one retained import journal to the target generation, finalized manifest, +and ordered classification digest, plus one exact member row per predecessor +source. SQLite guards immutable identity, one-shot conflicts, legal monotonic +state transitions, timestamps, staged counts, and retained audit history. +Repeated begin calls resume the exact journal; conflicting attempts fail +closed. No legacy or live product row is converted by this checkpoint. + ```rust,no_run use radroots_storage::event::SourceGeneration; use radroots_storage_sqlite::{OpenMode, OpenOptions, Paths, SqliteStorage}; diff --git a/crates/storage_sqlite/src/legacy.rs b/crates/storage_sqlite/src/legacy.rs @@ -170,6 +170,16 @@ pub enum LegacyImportDisposition { HostHandoff, } +impl LegacyImportDisposition { + /// Returns the stable durable-journal value. + pub const fn as_str(self) -> &'static str { + match self { + Self::Import => "import", + Self::HostHandoff => "host_handoff", + } + } +} + /// Exact schema evidence for one classified predecessor snapshot. #[derive(Clone, Debug, Eq, PartialEq)] pub struct LegacySourceClassification { @@ -204,26 +214,24 @@ impl LegacySourceClassification { /// Fully reverified classification of a prepared import evidence bundle. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClassifiedLegacyImport { - import_id: LegacyImportId, - target_generation: SourceGeneration, - bundle_path: PathBuf, + prepared: PreparedLegacyImport, sources: Vec<LegacySourceClassification>, } impl ClassifiedLegacyImport { /// Returns the stable import-attempt identity. pub const fn import_id(&self) -> LegacyImportId { - self.import_id + self.prepared.import_id() } /// Returns the exact destination storage generation. pub const fn target_generation(&self) -> SourceGeneration { - self.target_generation + self.prepared.target_generation() } /// Returns the reverified finalized evidence bundle. pub fn bundle_path(&self) -> &Path { - &self.bundle_path + self.prepared.bundle_path() } /// Returns exact classifications in stable source-family order. @@ -232,6 +240,150 @@ impl ClassifiedLegacyImport { } } +/// Durable whole-import lifecycle state. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum LegacyImportState { + Classified, + Staging, + Ready, + Committing, + Complete, +} + +impl LegacyImportState { + /// Returns the stable SQLite journal value. + pub const fn as_str(self) -> &'static str { + match self { + Self::Classified => "classified", + Self::Staging => "staging", + Self::Ready => "ready", + Self::Committing => "committing", + Self::Complete => "complete", + } + } +} + +/// Durable per-source staging lifecycle state. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum LegacyImportMemberState { + Pending, + Staging, + Ready, + Complete, +} + +impl LegacyImportMemberState { + /// Returns the stable SQLite journal value. + pub const fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Staging => "staging", + Self::Ready => "ready", + Self::Complete => "complete", + } + } +} + +/// Durable recovery state for one classified predecessor source. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LegacyImportMemberJournal { + classification: LegacySourceClassification, + state: LegacyImportMemberState, + resume_cursor: Option<Vec<u8>>, + staged_row_count: u64, + updated_at_unix_ms: u64, +} + +impl LegacyImportMemberJournal { + /// Returns the exact source classification bound to this member. + pub const fn classification(&self) -> &LegacySourceClassification { + &self.classification + } + + /// Returns the durable staging state. + pub const fn state(&self) -> LegacyImportMemberState { + self.state + } + + /// Returns the opaque source-specific resume cursor. + pub fn resume_cursor(&self) -> Option<&[u8]> { + self.resume_cursor.as_deref() + } + + /// Returns the number of rows durably staged so far. + pub const fn staged_row_count(&self) -> u64 { + self.staged_row_count + } + + /// Returns the positive last-update timestamp supplied by the host. + pub const fn updated_at_unix_ms(&self) -> u64 { + self.updated_at_unix_ms + } +} + +/// Exact durable recovery journal for one target-bound import. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LegacyImportJournal { + import_id: LegacyImportId, + target_generation: SourceGeneration, + manifest_sha256: MemberDigest, + classification_sha256: MemberDigest, + state: LegacyImportState, + started_at_unix_ms: u64, + updated_at_unix_ms: u64, + completed_at_unix_ms: Option<u64>, + members: Vec<LegacyImportMemberJournal>, +} + +impl LegacyImportJournal { + /// Returns the stable import identity. + pub const fn import_id(&self) -> LegacyImportId { + self.import_id + } + + /// Returns the exact destination storage generation. + pub const fn target_generation(&self) -> SourceGeneration { + self.target_generation + } + + /// Returns the exact finalized evidence-manifest digest. + pub const fn manifest_sha256(&self) -> MemberDigest { + self.manifest_sha256 + } + + /// Returns the exact ordered-classification digest. + pub const fn classification_sha256(&self) -> MemberDigest { + self.classification_sha256 + } + + /// Returns the durable whole-import state. + pub const fn state(&self) -> LegacyImportState { + self.state + } + + /// Returns the positive host-supplied start timestamp. + pub const fn started_at_unix_ms(&self) -> u64 { + self.started_at_unix_ms + } + + /// Returns the last positive host-supplied update timestamp. + pub const fn updated_at_unix_ms(&self) -> u64 { + self.updated_at_unix_ms + } + + /// Returns the host-supplied completion timestamp once terminal. + pub const fn completed_at_unix_ms(&self) -> Option<u64> { + self.completed_at_unix_ms + } + + /// Returns one exact durable row per classified source. + pub fn members(&self) -> &[LegacyImportMemberJournal] { + &self.members + } +} + /// One explicitly typed existing predecessor database. #[derive(Clone, Debug, Eq, PartialEq)] pub struct LegacySource { @@ -470,12 +622,272 @@ impl SqliteStorage { ); } Ok(ClassifiedLegacyImport { - import_id: prepared.import_id(), - target_generation: prepared.target_generation(), - bundle_path: prepared.bundle_path().to_path_buf(), + prepared: prepared.clone(), sources, }) } + + /// Atomically creates or resumes the exact durable journal for a classification. + pub async fn begin_legacy_import( + &self, + classified: &ClassifiedLegacyImport, + started_at_unix_ms: u64, + ) -> Result<LegacyImportJournal, Error> { + self.require_legacy_import_writer(classified.target_generation())?; + if started_at_unix_ms == 0 || classified.sources().is_empty() { + return Err(Error::InvalidLegacyImportJournal); + } + verify_prepared_evidence(&classified.prepared).await?; + let started_at = + i64::try_from(started_at_unix_ms).map_err(|_| Error::InvalidLegacyImportJournal)?; + let classification_sha256 = classification_digest(classified); + let mut transaction = self + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + let existing = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM radroots_runtime_legacy_imports WHERE import_id = ?", + ) + .bind(classified.import_id().as_bytes().as_slice()) + .fetch_one(&mut *transaction) + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + if existing == 0 { + let active = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM radroots_runtime_legacy_imports WHERE target_generation = ?", + ) + .bind(classified.target_generation().as_bytes().as_slice()) + .fetch_one(&mut *transaction) + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + if active != 0 { + transaction + .rollback() + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + return Err(Error::LegacyImportConflict); + } + sqlx::query( + "INSERT INTO radroots_runtime_legacy_imports( + import_id, target_generation, manifest_sha256, + classification_sha256, state, started_at_ms, + updated_at_ms, completed_at_ms + ) VALUES (?, ?, ?, ?, 'classified', ?, ?, NULL)", + ) + .bind(classified.import_id().as_bytes().as_slice()) + .bind(classified.target_generation().as_bytes().as_slice()) + .bind(classified.prepared.manifest_sha256().as_bytes().as_slice()) + .bind(classification_sha256.as_bytes().as_slice()) + .bind(started_at) + .bind(started_at) + .execute(&mut *transaction) + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + for source in classified.sources() { + sqlx::query( + "INSERT INTO radroots_runtime_legacy_import_members( + import_id, source_kind, legacy_schema, disposition, + catalog_sha256, state, resume_cursor, staged_row_count, + updated_at_ms + ) VALUES (?, ?, ?, ?, ?, 'pending', NULL, 0, ?)", + ) + .bind(classified.import_id().as_bytes().as_slice()) + .bind(source.kind().as_str()) + .bind(source.schema().as_str()) + .bind(source.schema().disposition().as_str()) + .bind(source.catalog_sha256().as_bytes().as_slice()) + .bind(started_at) + .execute(&mut *transaction) + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + } + } + transaction + .commit() + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + let journal = self + .legacy_import_journal(classified.import_id()) + .await? + .ok_or(Error::InvalidLegacyImportJournal)?; + if journal_matches_classified(&journal, classified, classification_sha256) { + Ok(journal) + } else { + Err(Error::LegacyImportConflict) + } + } + + /// Reads exact durable recovery state without advancing the importer. + pub async fn legacy_import_journal( + &self, + import_id: LegacyImportId, + ) -> Result<Option<LegacyImportJournal>, Error> { + self.lifecycle + .require_open() + .map_err(|_| Error::BackupBackendUnavailable)?; + let mut transaction = self + .pool + .begin_with("BEGIN") + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + let row = sqlx::query( + "SELECT import_id, target_generation, manifest_sha256, + classification_sha256, state, started_at_ms, updated_at_ms, + completed_at_ms + FROM radroots_runtime_legacy_imports WHERE import_id = ?", + ) + .bind(import_id.as_bytes().as_slice()) + .fetch_optional(&mut *transaction) + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + let Some(row) = row else { + transaction + .commit() + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + return Ok(None); + }; + let durable_import_id = decode_import_id( + row.try_get("import_id") + .map_err(|_| Error::InvalidLegacyImportJournal)?, + )?; + if durable_import_id != import_id { + return Err(Error::InvalidLegacyImportJournal); + } + let target_generation = decode_generation( + row.try_get("target_generation") + .map_err(|_| Error::InvalidLegacyImportJournal)?, + )?; + if target_generation != self.generation { + return Err(Error::InvalidLegacyImportJournal); + } + let manifest_sha256 = decode_digest( + row.try_get("manifest_sha256") + .map_err(|_| Error::InvalidLegacyImportJournal)?, + )?; + let classification_sha256 = decode_digest( + row.try_get("classification_sha256") + .map_err(|_| Error::InvalidLegacyImportJournal)?, + )?; + let state = parse_import_state( + row.try_get::<String, _>("state") + .map_err(|_| Error::InvalidLegacyImportJournal)? + .as_str(), + )?; + let started_at_unix_ms = decode_positive_time( + row.try_get("started_at_ms") + .map_err(|_| Error::InvalidLegacyImportJournal)?, + )?; + let updated_at_unix_ms = decode_positive_time( + row.try_get("updated_at_ms") + .map_err(|_| Error::InvalidLegacyImportJournal)?, + )?; + let completed_at_unix_ms = row + .try_get::<Option<i64>, _>("completed_at_ms") + .map_err(|_| Error::InvalidLegacyImportJournal)? + .map(decode_positive_time) + .transpose()?; + let member_rows = sqlx::query( + "SELECT source_kind, legacy_schema, disposition, catalog_sha256, + state, resume_cursor, staged_row_count, updated_at_ms + FROM radroots_runtime_legacy_import_members + WHERE import_id = ? ORDER BY source_kind", + ) + .bind(import_id.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + if member_rows.is_empty() || member_rows.len() > LEGACY_SOURCE_MAX { + return Err(Error::InvalidLegacyImportJournal); + } + let mut members = Vec::with_capacity(member_rows.len()); + for row in member_rows { + let kind = parse_source_kind( + row.try_get::<String, _>("source_kind") + .map_err(|_| Error::InvalidLegacyImportJournal)? + .as_str(), + )?; + let schema = parse_legacy_schema( + row.try_get::<String, _>("legacy_schema") + .map_err(|_| Error::InvalidLegacyImportJournal)? + .as_str(), + )?; + let disposition = row + .try_get::<String, _>("disposition") + .map_err(|_| Error::InvalidLegacyImportJournal)?; + if disposition != schema.disposition().as_str() || schema_source_kind(schema) != kind { + return Err(Error::InvalidLegacyImportJournal); + } + members.push(LegacyImportMemberJournal { + classification: LegacySourceClassification { + kind, + schema, + user_version: expected_user_version(schema), + catalog_sha256: decode_digest( + row.try_get("catalog_sha256") + .map_err(|_| Error::InvalidLegacyImportJournal)?, + )?, + }, + state: parse_member_state( + row.try_get::<String, _>("state") + .map_err(|_| Error::InvalidLegacyImportJournal)? + .as_str(), + )?, + resume_cursor: row + .try_get("resume_cursor") + .map_err(|_| Error::InvalidLegacyImportJournal)?, + staged_row_count: u64::try_from( + row.try_get::<i64, _>("staged_row_count") + .map_err(|_| Error::InvalidLegacyImportJournal)?, + ) + .map_err(|_| Error::InvalidLegacyImportJournal)?, + updated_at_unix_ms: decode_positive_time( + row.try_get("updated_at_ms") + .map_err(|_| Error::InvalidLegacyImportJournal)?, + )?, + }); + } + if updated_at_unix_ms < started_at_unix_ms + || members + .iter() + .any(|member| member.updated_at_unix_ms() < started_at_unix_ms) + || !journal_member_states_are_consistent(state, &members) + { + return Err(Error::InvalidLegacyImportJournal); + } + transaction + .commit() + .await + .map_err(|_| Error::LegacyImportJournalFailed)?; + Ok(Some(LegacyImportJournal { + import_id, + target_generation, + manifest_sha256, + classification_sha256, + state, + started_at_unix_ms, + updated_at_unix_ms, + completed_at_unix_ms, + members, + })) + } + + fn require_legacy_import_writer( + &self, + target_generation: SourceGeneration, + ) -> Result<(), Error> { + self.lifecycle + .require_open() + .map_err(|_| Error::BackupBackendUnavailable)?; + if self.mode != EventStoreMode::ReadWrite { + return Err(Error::RestoreRequiresWritableStorage); + } + if target_generation != self.generation { + return Err(Error::LegacyImportTargetMismatch); + } + Ok(()) + } } struct LegacyBackupLayout { @@ -965,6 +1377,163 @@ fn unsupported_schema( } } +fn classification_digest(classified: &ClassifiedLegacyImport) -> MemberDigest { + let mut digest = Sha256::new(); + for field in [ + classified.import_id().as_bytes().as_slice(), + classified.target_generation().as_bytes().as_slice(), + classified.prepared.manifest_sha256().as_bytes().as_slice(), + ] { + digest.update(field); + digest.update([0]); + } + for source in classified.sources() { + for field in [ + source.kind().as_str().as_bytes(), + source.schema().as_str().as_bytes(), + source.schema().disposition().as_str().as_bytes(), + source.catalog_sha256().as_bytes().as_slice(), + ] { + digest.update(field); + digest.update([0]); + } + digest.update(source.user_version().to_be_bytes()); + digest.update([0]); + } + MemberDigest::new(digest.finalize().into()) +} + +fn journal_matches_classified( + journal: &LegacyImportJournal, + classified: &ClassifiedLegacyImport, + classification_sha256: MemberDigest, +) -> bool { + journal.import_id() == classified.import_id() + && journal.target_generation() == classified.target_generation() + && journal.manifest_sha256() == classified.prepared.manifest_sha256() + && journal.classification_sha256() == classification_sha256 + && journal.members().len() == classified.sources().len() + && journal + .members() + .iter() + .zip(classified.sources()) + .all(|(durable, expected)| durable.classification() == expected) +} + +fn decode_import_id(bytes: Vec<u8>) -> Result<LegacyImportId, Error> { + LegacyImportId::new(decode_array(bytes)?) +} + +fn decode_generation(bytes: Vec<u8>) -> Result<SourceGeneration, Error> { + SourceGeneration::new(decode_array(bytes)?).map_err(|_| Error::InvalidLegacyImportJournal) +} + +fn decode_digest(bytes: Vec<u8>) -> Result<MemberDigest, Error> { + Ok(MemberDigest::new(decode_array(bytes)?)) +} + +fn decode_array<const N: usize>(bytes: Vec<u8>) -> Result<[u8; N], Error> { + bytes + .try_into() + .map_err(|_| Error::InvalidLegacyImportJournal) +} + +fn decode_positive_time(value: i64) -> Result<u64, Error> { + let value = u64::try_from(value).map_err(|_| Error::InvalidLegacyImportJournal)?; + if value == 0 { + Err(Error::InvalidLegacyImportJournal) + } else { + Ok(value) + } +} + +fn parse_source_kind(value: &str) -> Result<LegacySourceKind, Error> { + match value { + "event_store" => Ok(LegacySourceKind::EventStore), + "outbox" => Ok(LegacySourceKind::Outbox), + "private" => Ok(LegacySourceKind::Private), + "studio" => Ok(LegacySourceKind::Studio), + _ => Err(Error::InvalidLegacyImportJournal), + } +} + +fn parse_legacy_schema(value: &str) -> Result<LegacySchema, Error> { + match value { + "event_store_v1" => Ok(LegacySchema::EventStoreV1), + "event_store_v2" => Ok(LegacySchema::EventStoreV2), + "event_store_v3" => Ok(LegacySchema::EventStoreV3), + "event_store_v4" => Ok(LegacySchema::EventStoreV4), + "outbox_v1" => Ok(LegacySchema::OutboxV1), + "private_v1" => Ok(LegacySchema::PrivateV1), + "studio_v1_host_handoff" => Ok(LegacySchema::StudioV1HostHandoff), + _ => Err(Error::InvalidLegacyImportJournal), + } +} + +const fn expected_user_version(schema: LegacySchema) -> u32 { + match schema { + LegacySchema::PrivateV1 => 1, + LegacySchema::EventStoreV1 + | LegacySchema::EventStoreV2 + | LegacySchema::EventStoreV3 + | LegacySchema::EventStoreV4 + | LegacySchema::OutboxV1 + | LegacySchema::StudioV1HostHandoff => 0, + } +} + +const fn schema_source_kind(schema: LegacySchema) -> LegacySourceKind { + match schema { + LegacySchema::EventStoreV1 + | LegacySchema::EventStoreV2 + | LegacySchema::EventStoreV3 + | LegacySchema::EventStoreV4 => LegacySourceKind::EventStore, + LegacySchema::OutboxV1 => LegacySourceKind::Outbox, + LegacySchema::PrivateV1 => LegacySourceKind::Private, + LegacySchema::StudioV1HostHandoff => LegacySourceKind::Studio, + } +} + +fn journal_member_states_are_consistent( + state: LegacyImportState, + members: &[LegacyImportMemberJournal], +) -> bool { + members.iter().all(|member| match state { + LegacyImportState::Classified => member.state() == LegacyImportMemberState::Pending, + LegacyImportState::Staging => true, + LegacyImportState::Ready => matches!( + member.state(), + LegacyImportMemberState::Ready | LegacyImportMemberState::Complete + ), + LegacyImportState::Committing => matches!( + member.state(), + LegacyImportMemberState::Ready | LegacyImportMemberState::Complete + ), + LegacyImportState::Complete => member.state() == LegacyImportMemberState::Complete, + }) +} + +fn parse_import_state(value: &str) -> Result<LegacyImportState, Error> { + match value { + "classified" => Ok(LegacyImportState::Classified), + "staging" => Ok(LegacyImportState::Staging), + "ready" => Ok(LegacyImportState::Ready), + "committing" => Ok(LegacyImportState::Committing), + "complete" => Ok(LegacyImportState::Complete), + _ => Err(Error::InvalidLegacyImportJournal), + } +} + +fn parse_member_state(value: &str) -> Result<LegacyImportMemberState, Error> { + match value { + "pending" => Ok(LegacyImportMemberState::Pending), + "staging" => Ok(LegacyImportMemberState::Staging), + "ready" => Ok(LegacyImportMemberState::Ready), + "complete" => Ok(LegacyImportMemberState::Complete), + _ => Err(Error::InvalidLegacyImportJournal), + } +} + fn write_manifest( plan: &LegacyImportPlan, target_generation: SourceGeneration, @@ -1109,6 +1678,8 @@ mod tests { include_str!("../../../contracts/storage/legacy_import_backup_policy_v1.toml"); const CLASSIFICATION_POLICY: &str = include_str!("../../../contracts/storage/legacy_schema_classification_v1.toml"); + const JOURNAL_POLICY: &str = + include_str!("../../../contracts/storage/legacy_import_journal_policy_v1.toml"); #[derive(Deserialize)] struct Policy { @@ -1175,6 +1746,25 @@ mod tests { disposition: String, } + #[derive(Deserialize)] + struct JournalPolicy { + schema_version: u32, + authority: String, + identity: Vec<String>, + import_states: Vec<String>, + member_states: Vec<String>, + imports_per_target_generation: u32, + begin: String, + resume: String, + source_members: String, + resume_cursor: String, + staged_row_count: String, + host_timestamp: String, + hidden_clock_or_entropy: bool, + legacy_row_conversion: bool, + live_product_row_mutation: bool, + } + fn generation(byte: u8) -> SourceGeneration { SourceGeneration::new([byte; 32]).expect("source generation") } @@ -1375,6 +1965,44 @@ mod tests { assert_eq!(policy.studio.disposition, "host_handoff_not_sdk_import"); } + #[test] + fn implementation_matches_the_governed_import_journal_policy() { + let policy = + toml::from_str::<JournalPolicy>(JOURNAL_POLICY).expect("legacy journal policy"); + assert_eq!(policy.schema_version, 1); + assert_eq!( + policy.authority, + "runtime_sqlite_owned_forward_migration_v6" + ); + assert_eq!( + policy.identity, + [ + "import_id", + "target_generation", + "manifest_sha256", + "classification_sha256" + ] + ); + assert_eq!( + policy.import_states, + ["classified", "staging", "ready", "committing", "complete"] + ); + assert_eq!( + policy.member_states, + ["pending", "staging", "ready", "complete"] + ); + assert_eq!(policy.imports_per_target_generation, 1); + assert_eq!(policy.begin, "atomic_exact_idempotent_or_conflict"); + assert_eq!(policy.resume, "read_exact_durable_state"); + assert_eq!(policy.source_members, "one_exact_row_per_classified_source"); + assert_eq!(policy.resume_cursor, "opaque_nullable_bytes"); + assert_eq!(policy.staged_row_count, "non_negative"); + assert_eq!(policy.host_timestamp, "positive_monotonic_per_import"); + assert!(!policy.hidden_clock_or_entropy); + assert!(!policy.legacy_row_conversion); + assert!(!policy.live_product_row_mutation); + } + fn assert_fixed_schema_policy( policy: &FixedSchemaPolicy, user_version: i64, @@ -1528,12 +2156,12 @@ mod tests { let studio_connection = supported_studio_database(&studio_path).await; let plan = LegacyImportPlan::new( LegacyImportId::new([125; 16]).expect("import id"), - vec![LegacySource::new(LegacySourceKind::Studio, studio_path).expect("Studio source")], + vec![LegacySource::new(LegacySourceKind::Studio, &studio_path).expect("Studio source")], backup_root.path(), 12_500, ) .expect("Studio import plan"); - let (_, store) = target(target_root.path()).await; + let (target_paths, store) = target(target_root.path()).await; let prepared = store .prepare_legacy_import(&plan) .await @@ -1558,6 +2186,86 @@ mod tests { encode_digest(source.catalog_sha256().as_bytes()), STUDIO_CATALOG_SHA256 ); + let journal = store + .begin_legacy_import(&classified, 12_501) + .await + .expect("begin durable import"); + assert_eq!(journal.import_id(), plan.import_id()); + assert_eq!(journal.target_generation(), generation(121)); + assert_eq!(journal.manifest_sha256(), prepared.manifest_sha256()); + assert_eq!( + journal.classification_sha256(), + classification_digest(&classified) + ); + assert_eq!(journal.state(), LegacyImportState::Classified); + assert_eq!(journal.started_at_unix_ms(), 12_501); + assert_eq!(journal.updated_at_unix_ms(), 12_501); + assert_eq!(journal.completed_at_unix_ms(), None); + assert_eq!(journal.members().len(), 1); + assert_eq!( + journal.members()[0].classification(), + &classified.sources()[0] + ); + assert_eq!( + journal.members()[0].state(), + LegacyImportMemberState::Pending + ); + assert_eq!(journal.members()[0].resume_cursor(), None); + assert_eq!(journal.members()[0].staged_row_count(), 0); + assert_eq!(journal.members()[0].updated_at_unix_ms(), 12_501); + assert_eq!( + store + .begin_legacy_import(&classified, 12_599) + .await + .expect("idempotent begin"), + journal + ); + assert_eq!( + store + .legacy_import_journal(plan.import_id()) + .await + .expect("read journal"), + Some(journal.clone()) + ); + for statement in [ + "UPDATE radroots_runtime_legacy_imports SET state = 'ready'", + "UPDATE radroots_runtime_legacy_imports SET import_id = zeroblob(16)", + "DELETE FROM radroots_runtime_legacy_imports", + "UPDATE radroots_runtime_legacy_import_members SET state = 'ready'", + "UPDATE radroots_runtime_legacy_import_members SET source_kind = 'outbox'", + "DELETE FROM radroots_runtime_legacy_import_members", + ] { + assert!( + sqlx::query(statement).execute(&store.pool).await.is_err(), + "journal guard accepted `{statement}`" + ); + } + + let conflicting_backup_root = tempfile::tempdir().expect("conflicting backup root"); + let conflicting_plan = LegacyImportPlan::new( + LegacyImportId::new([127; 16]).expect("conflicting import id"), + vec![ + LegacySource::new(LegacySourceKind::Studio, &studio_path) + .expect("conflicting Studio source"), + ], + conflicting_backup_root.path(), + 12_700, + ) + .expect("conflicting import plan"); + let conflicting_prepared = store + .prepare_legacy_import(&conflicting_plan) + .await + .expect("conflicting prepared import"); + let conflicting_classified = store + .classify_legacy_import(&conflicting_prepared) + .await + .expect("conflicting classified import"); + assert!(matches!( + store + .begin_legacy_import(&conflicting_classified, 12_701) + .await, + Err(Error::LegacyImportConflict) + )); let other_root = tempfile::tempdir().expect("other target root"); let other_paths = Paths::from_directory(other_root.path()).expect("other target paths"); @@ -1572,6 +2280,10 @@ mod tests { other_store.classify_legacy_import(&prepared).await, Err(Error::LegacyImportTargetMismatch) )); + assert!(matches!( + other_store.begin_legacy_import(&classified, 12_601).await, + Err(Error::LegacyImportTargetMismatch) + )); other_store.close().await.expect("close other target"); fs::write(prepared.bundle_path().join("unexpected"), b"unsupported") @@ -1585,6 +2297,18 @@ mod tests { .await .expect("close Studio source"); store.close().await.expect("close target"); + let reopened = + SqliteStorage::open(OpenOptions::new(target_paths, OpenMode::ReadWriteExisting)) + .await + .expect("reopen target"); + assert_eq!( + reopened + .legacy_import_journal(plan.import_id()) + .await + .expect("read journal after reopen"), + Some(journal) + ); + reopened.close().await.expect("close reopened target"); } #[test] diff --git a/crates/storage_sqlite/src/migration.rs b/crates/storage_sqlite/src/migration.rs @@ -280,7 +280,7 @@ async fn metadata( fn validate_plan(plan: &MigrationPlan) -> Result<(), Error> { let valid = plan.minimum_version > 0 && plan.minimum_version <= plan.current_version - && plan.current_version <= 5 + && plan.current_version <= 6 && plan.steps.len() == usize::try_from(plan.current_version).unwrap_or(usize::MAX) && plan .steps @@ -388,6 +388,7 @@ const fn set_user_version_sql(version: u32) -> Option<&'static str> { 3 => Some("PRAGMA user_version = 3"), 4 => Some("PRAGMA user_version = 4"), 5 => Some("PRAGMA user_version = 5"), + 6 => Some("PRAGMA user_version = 6"), _ => None, } } @@ -501,8 +502,8 @@ mod tests { .await .expect("forward migration"); assert_eq!(report.initial_version(), 1); - assert_eq!(report.final_version(), 5); - assert_eq!(report.applied(), 4); + assert_eq!(report.final_version(), runtime::CURRENT_VERSION); + assert_eq!(report.applied(), runtime::CURRENT_VERSION - 1); assert_eq!( sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM radroots_runtime_source_generations", @@ -566,7 +567,7 @@ mod tests { migrate_runtime(&mut connection, OpenMode::ReadOnly).await, Err(Error::SchemaMigrationRequired { database: RUNTIME_DATABASE, - current: 5, + current: runtime::CURRENT_VERSION, actual: 1, }) )); @@ -580,7 +581,7 @@ mod tests { .execute(&mut newer) .await .expect("application id"); - sqlx::raw_sql("PRAGMA user_version = 6") + sqlx::raw_sql("PRAGMA user_version = 7") .execute(&mut newer) .await .expect("newer version"); @@ -588,11 +589,11 @@ mod tests { migrate_runtime(&mut newer, OpenMode::ReadWriteExisting).await, Err(Error::SchemaTooNew { database: RUNTIME_DATABASE, - supported: 5, - actual: 6, + supported: runtime::CURRENT_VERSION, + actual: 7, }) )); - assert_eq!(pragma(&mut newer, "user_version").await, 6); + assert_eq!(pragma(&mut newer, "user_version").await, 7); let mut wrong_identity = connection().await; establish_runtime_version(&mut wrong_identity, 1).await; diff --git a/crates/storage_sqlite/src/migration/runtime/0006_legacy_import_journal.up.sql b/crates/storage_sqlite/src/migration/runtime/0006_legacy_import_journal.up.sql @@ -0,0 +1,92 @@ +CREATE TABLE radroots_runtime_legacy_imports ( + import_id BLOB PRIMARY KEY NOT NULL CHECK(length(import_id) = 16), + target_generation BLOB NOT NULL UNIQUE CHECK(length(target_generation) = 32) + REFERENCES radroots_runtime_source_generations(generation) ON DELETE RESTRICT, + manifest_sha256 BLOB NOT NULL CHECK(length(manifest_sha256) = 32), + classification_sha256 BLOB NOT NULL CHECK(length(classification_sha256) = 32), + state TEXT NOT NULL CHECK(state IN ('classified', 'staging', 'ready', 'committing', 'complete')), + started_at_ms INTEGER NOT NULL CHECK(started_at_ms > 0), + updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= started_at_ms), + completed_at_ms INTEGER CHECK(completed_at_ms IS NULL OR completed_at_ms >= started_at_ms), + CHECK((state = 'complete') = (completed_at_ms IS NOT NULL)) +) STRICT, WITHOUT ROWID; + +CREATE TABLE radroots_runtime_legacy_import_members ( + import_id BLOB NOT NULL REFERENCES radroots_runtime_legacy_imports(import_id) ON DELETE CASCADE, + source_kind TEXT NOT NULL CHECK(source_kind IN ('event_store', 'outbox', 'private', 'studio')), + legacy_schema TEXT NOT NULL CHECK(legacy_schema IN ( + 'event_store_v1', + 'event_store_v2', + 'event_store_v3', + 'event_store_v4', + 'outbox_v1', + 'private_v1', + 'studio_v1_host_handoff' + )), + disposition TEXT NOT NULL CHECK(disposition IN ('import', 'host_handoff')), + catalog_sha256 BLOB NOT NULL CHECK(length(catalog_sha256) = 32), + state TEXT NOT NULL CHECK(state IN ('pending', 'staging', 'ready', 'complete')), + resume_cursor BLOB, + staged_row_count INTEGER NOT NULL DEFAULT 0 CHECK(staged_row_count >= 0), + updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms > 0), + PRIMARY KEY(import_id, source_kind) +) STRICT, WITHOUT ROWID; + +CREATE INDEX radroots_runtime_legacy_import_state_idx +ON radroots_runtime_legacy_imports(state, updated_at_ms, import_id); + +CREATE TRIGGER radroots_runtime_legacy_import_identity_guard +BEFORE UPDATE OF import_id, target_generation, manifest_sha256, classification_sha256, started_at_ms +ON radroots_runtime_legacy_imports +BEGIN + SELECT RAISE(ABORT, 'legacy import identity is immutable'); +END; + +CREATE TRIGGER radroots_runtime_legacy_import_delete_guard +BEFORE DELETE ON radroots_runtime_legacy_imports +BEGIN + SELECT RAISE(ABORT, 'legacy import history is retained'); +END; + +CREATE TRIGGER radroots_runtime_legacy_import_state_guard +BEFORE UPDATE OF state, updated_at_ms, completed_at_ms +ON radroots_runtime_legacy_imports +WHEN NEW.updated_at_ms < OLD.updated_at_ms + OR NOT ( + NEW.state = OLD.state + OR (OLD.state = 'classified' AND NEW.state = 'staging') + OR (OLD.state = 'staging' AND NEW.state = 'ready') + OR (OLD.state = 'ready' AND NEW.state = 'committing') + OR (OLD.state = 'committing' AND NEW.state = 'complete') + ) +BEGIN + SELECT RAISE(ABORT, 'legacy import state must advance monotonically'); +END; + +CREATE TRIGGER radroots_runtime_legacy_import_member_identity_guard +BEFORE UPDATE OF import_id, source_kind, legacy_schema, disposition, catalog_sha256 +ON radroots_runtime_legacy_import_members +BEGIN + SELECT RAISE(ABORT, 'legacy import member identity is immutable'); +END; + +CREATE TRIGGER radroots_runtime_legacy_import_member_delete_guard +BEFORE DELETE ON radroots_runtime_legacy_import_members +BEGIN + SELECT RAISE(ABORT, 'legacy import member history is retained'); +END; + +CREATE TRIGGER radroots_runtime_legacy_import_member_state_guard +BEFORE UPDATE OF state, resume_cursor, staged_row_count, updated_at_ms +ON radroots_runtime_legacy_import_members +WHEN NEW.updated_at_ms < OLD.updated_at_ms + OR NEW.staged_row_count < OLD.staged_row_count + OR NOT ( + NEW.state = OLD.state + OR (OLD.state = 'pending' AND NEW.state = 'staging') + OR (OLD.state = 'staging' AND NEW.state = 'ready') + OR (OLD.state = 'ready' AND NEW.state = 'complete') + ) +BEGIN + SELECT RAISE(ABORT, 'legacy import member state must advance monotonically'); +END; diff --git a/crates/storage_sqlite/src/migration/runtime/mod.rs b/crates/storage_sqlite/src/migration/runtime/mod.rs @@ -6,13 +6,14 @@ /// Lowest runtime schema version this package can recognize. pub const MINIMUM_VERSION: u32 = 1; /// Current runtime schema version created by this package. -pub const CURRENT_VERSION: u32 = 5; +pub const CURRENT_VERSION: u32 = 6; const RUNTIME_V1_SQL: &str = include_str!("0001_runtime.up.sql"); const CANONICAL_EVENT_STORAGE_V2_SQL: &str = include_str!("0002_canonical_event_storage.up.sql"); const OPERATION_JOURNAL_V3_SQL: &str = include_str!("0003_operation_journal.up.sql"); const OUTBOX_DELIVERY_EVIDENCE_V4_SQL: &str = include_str!("0004_outbox_delivery_evidence.up.sql"); const PROJECTION_METADATA_V5_SQL: &str = include_str!("0005_projection_metadata.up.sql"); +const LEGACY_IMPORT_JOURNAL_V6_SQL: &str = include_str!("0006_legacy_import_journal.up.sql"); /// Stable, non-SQL description of one forward runtime migration. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -137,6 +138,47 @@ const RUNTIME_V4_OBJECTS: &[&str] = &[ "radroots_runtime_source_generations_sequence_guard", ]; +const RUNTIME_V6_OBJECTS: &[&str] = &[ + "radroots_runtime_atomic_commits", + "radroots_runtime_delivery_evidence", + "radroots_runtime_delivery_evidence_item_idx", + "radroots_runtime_event_index_checkpoints", + "radroots_runtime_event_index_manifests", + "radroots_runtime_event_index_shards", + "radroots_runtime_event_provenance", + "radroots_runtime_event_provenance_observed_idx", + "radroots_runtime_events", + "radroots_runtime_events_admission_idx", + "radroots_runtime_events_delete_guard", + "radroots_runtime_events_event_id_idx", + "radroots_runtime_events_raw_update_guard", + "radroots_runtime_journal_idempotency_idx", + "radroots_runtime_journal_operations", + "radroots_runtime_journal_recovery_idx", + "radroots_runtime_legacy_import_delete_guard", + "radroots_runtime_legacy_import_identity_guard", + "radroots_runtime_legacy_import_member_delete_guard", + "radroots_runtime_legacy_import_member_identity_guard", + "radroots_runtime_legacy_import_member_state_guard", + "radroots_runtime_legacy_import_members", + "radroots_runtime_legacy_import_state_guard", + "radroots_runtime_legacy_import_state_idx", + "radroots_runtime_legacy_imports", + "radroots_runtime_outbox_items", + "radroots_runtime_outbox_operation_idx", + "radroots_runtime_outbox_ready_idx", + "radroots_runtime_outbox_targets", + "radroots_runtime_projection_checkpoints", + "radroots_runtime_projection_invalidations", + "radroots_runtime_projection_rebuilds", + "radroots_runtime_projection_rebuilds_stage_idx", + "radroots_runtime_source_generations", + "radroots_runtime_source_generations_active_idx", + "radroots_runtime_source_generations_delete_guard", + "radroots_runtime_source_generations_identity_guard", + "radroots_runtime_source_generations_sequence_guard", +]; + /// Ordered, immutable runtime migration plan. pub const MIGRATIONS: &[MigrationDescriptor] = &[ MigrationDescriptor { @@ -169,6 +211,12 @@ pub const MIGRATIONS: &[MigrationDescriptor] = &[ up_sha256: "ef162e3591057414b5704ff07fc93e57127ad7996ab3d431541418fd9c285e51", owned_objects: RUNTIME_V4_OBJECTS, }, + MigrationDescriptor { + version: 6, + name: "legacy_import_journal", + up_sha256: "5858336c25b048d6cdae1beedc8bf0bdfb530b858c3c50f501621c3314d23d4a", + owned_objects: RUNTIME_V6_OBJECTS, + }, ]; pub(crate) const fn migration_sql(version: u32) -> Option<&'static str> { @@ -178,6 +226,7 @@ pub(crate) const fn migration_sql(version: u32) -> Option<&'static str> { 3 => Some(OPERATION_JOURNAL_V3_SQL), 4 => Some(OUTBOX_DELIVERY_EVIDENCE_V4_SQL), 5 => Some(PROJECTION_METADATA_V5_SQL), + 6 => Some(LEGACY_IMPORT_JOURNAL_V6_SQL), _ => None, } } @@ -220,9 +269,9 @@ mod tests { fn migration_plan_matches_governed_snapshot() { let snapshot = toml::from_str::<PlanSnapshot>(PLAN_SNAPSHOT).expect("valid snapshot"); assert_eq!(MINIMUM_VERSION, 1); - assert_eq!(CURRENT_VERSION, 5); - assert_eq!(MIGRATIONS.len(), 5); - let migration = MIGRATIONS[4]; + assert_eq!(CURRENT_VERSION, 6); + assert_eq!(MIGRATIONS.len(), 6); + let migration = MIGRATIONS[5]; assert_eq!(snapshot.schema_version, 1); assert_eq!(snapshot.database, "runtime.sqlite"); assert_eq!(snapshot.application_id, 1_380_209_236); @@ -232,7 +281,7 @@ mod tests { assert_eq!(snapshot.migration_sha256, migration.up_sha256()); assert!(snapshot.forward_only); assert!(!snapshot.raw_sql_public); - assert_eq!(snapshot.authorities.len(), 8); + assert_eq!(snapshot.authorities.len(), 9); assert_eq!(snapshot.source_invariants.len(), 5); assert_eq!(snapshot.migrations.len(), MIGRATIONS.len()); for (expected, actual) in snapshot.migrations.iter().zip(MIGRATIONS) { @@ -249,7 +298,7 @@ mod tests { let sql = migration_sql(migration.version()).expect("registered SQL"); assert_eq!(format!("{:x}", Sha256::digest(sql)), migration.up_sha256()); } - assert_eq!(migration_sql(6), None); + assert_eq!(migration_sql(7), None); } #[tokio::test] diff --git a/crates/storage_sqlite/src/open.rs b/crates/storage_sqlite/src/open.rs @@ -278,6 +278,9 @@ pub enum Error { LegacyImportEvidenceInvalid, LegacyImportTargetMismatch, LegacyImportMigrationHistoryInvalid, + InvalidLegacyImportJournal, + LegacyImportConflict, + LegacyImportJournalFailed, UnsupportedLegacySchema { source_kind: &'static str, user_version: i64, @@ -547,6 +550,15 @@ impl fmt::Display for Error { Self::LegacyImportMigrationHistoryInvalid => { formatter.write_str("SQLite legacy event-store migration history is invalid") } + Self::InvalidLegacyImportJournal => { + formatter.write_str("SQLite legacy import journal is invalid") + } + Self::LegacyImportConflict => { + formatter.write_str("SQLite legacy import conflicts with durable state") + } + Self::LegacyImportJournalFailed => { + formatter.write_str("SQLite legacy import journal operation failed") + } Self::UnsupportedLegacySchema { source_kind, user_version,