diff --git a/src/db/counts.rs b/src/db/counts.rs index d1ab0dc..c8f4597 100644 --- a/src/db/counts.rs +++ b/src/db/counts.rs @@ -166,6 +166,19 @@ pub(crate) fn apply_count_delta(current: u64, delta: i64) -> u64 { } } +/// unix secs of the first open at v15. rows from before it carry no resolve time, see +/// [`crate::types::RepoState::identity_resolved_or_spread`] +pub(crate) const IDENTITY_TTL_EPOCH: &str = "identity_ttl_epoch"; + +#[allow(dead_code)] +pub(crate) fn identity_ttl_epoch(db: &Db) -> Result { + db.counts + .get(keys::count_keyspace_key(IDENTITY_TTL_EPOCH)) + .into_diagnostic()? + .map(|value| read_u64_counter(&value).map(|epoch| epoch as i64)) + .unwrap_or_else(|| Ok(chrono::Utc::now().timestamp())) +} + pub fn set_ks_count(batch: &mut OwnedWriteBatch, db: &Db, name: &str, count: u64) { let key = keys::count_keyspace_key(name); batch.insert(&db.counts, key, count.to_be_bytes()); diff --git a/src/db/migration/mod.rs b/src/db/migration/mod.rs index 03eaddd..cff1b76 100644 --- a/src/db/migration/mod.rs +++ b/src/db/migration/mod.rs @@ -68,6 +68,7 @@ mod v11; mod v12; mod v13; mod v14; +mod v15; mod v2; mod v3; mod v4; @@ -260,8 +261,31 @@ const MIGRATIONS: &[(&str, Migration)] = &[ "repo_metadata_v14", Migration::Atomic(v14::repo_metadata_v14), ), + ( + FENCE, + Migration::Atomic(v15::repo_state_identity_resolved_at), + ), ]; +/// from this migration on, the applied markers are the whole truth. a relay-only build skips v8 +/// and the indexer passes for good, so a contiguous version stays at 7 there and would never +/// keep an older build out of rows it can't read +const FENCE: &str = "repo_state_identity_resolved_at"; + +/// the version to store, from which migrations are applied in order. before the fence it's the +/// end of the unbroken run from the start, like it always was. once fenced it's the newest +/// applied one, holes or not, so every migration a build applies keeps the builds before it out +fn schema_version(applied: &[bool], fenced: bool) -> u64 { + let version = match fenced { + true => applied + .iter() + .rposition(|applied| *applied) + .map_or(0, |i| i + 1), + false => applied.iter().take_while(|applied| **applied).count(), + }; + version as u64 +} + pub(crate) const LATEST_VERSION: u64 = MIGRATIONS.len() as u64; /// resume cursors for chunked passes, in the `counts` keyspace: @@ -306,7 +330,13 @@ pub(crate) fn rewind_version_for_test(db: &Db, version: u64) -> Result<()> { batch.commit().into_diagnostic() } -fn is_migration_applied(db: &Db, index: usize, name: &str, stored_version: u64) -> Result { +fn is_migration_applied( + db: &Db, + index: usize, + name: &str, + stored_version: u64, + fenced: bool, +) -> Result { let key = migration_applied_key(name); if db.counts.contains_key(&key).into_diagnostic()? { return Ok(true); @@ -315,8 +345,9 @@ fn is_migration_applied(db: &Db, index: usize, name: &str, stored_version: u64) // Legacy versions predate per-migration applied keys. Their global version // proves ordinary migrations ran, while feature-gated migrations need a // cheap migration-specific proof: reduced-feature builds advanced the old - // version even when they skipped the work. - if (index as u64) < stored_version { + // version even when they skipped the work. a fenced database had markers + // for everything already, and its version may be past what it applied. + if !fenced && (index as u64) < stored_version { return match name { "rebuild_lifecycle_counts" => v8::legacy_migration_applied(db), // The old v9 marker is trusted; v10 safely and boundedly finishes @@ -485,8 +516,12 @@ pub(super) fn run(db: &Db) -> Result<()> { "database schema version {stored_version} is newer than supported version {LATEST_VERSION}" ); } + let fenced = db + .counts + .contains_key(migration_applied_key(FENCE)) + .into_diagnostic()?; for (i, (name, migration)) in MIGRATIONS.iter().enumerate() { - if is_migration_applied(db, i, name, stored_version)? { + if is_migration_applied(db, i, name, stored_version, fenced)? { let key = migration_applied_key(name); if !db.counts.contains_key(&key).into_diagnostic()? { db.counts.insert(key, b"1").into_diagnostic()?; @@ -541,22 +576,19 @@ pub(super) fn run(db: &Db) -> Result<()> { } } - // Advance VERSIONING_KEY to highest contiguous applied migration version - let mut max_contiguous_version = 0u64; - for (i, (name, _)) in MIGRATIONS.iter().enumerate() { - if is_migration_applied(db, i, name, stored_version)? { - max_contiguous_version = (i + 1) as u64; - } else { - break; - } - } - if max_contiguous_version != stored_version { + let applied = MIGRATIONS + .iter() + .enumerate() + .map(|(i, (name, _))| is_migration_applied(db, i, name, stored_version, fenced)) + .collect::>>()?; + let fenced = db + .counts + .contains_key(migration_applied_key(FENCE)) + .into_diagnostic()?; + let version = schema_version(&applied, fenced); + if version != stored_version { let mut batch = db.inner.batch(); - batch.insert( - &db.counts, - VERSIONING_KEY, - max_contiguous_version.to_be_bytes(), - ); + batch.insert(&db.counts, VERSIONING_KEY, version.to_be_bytes()); batch.commit().into_diagnostic()?; } @@ -836,6 +868,29 @@ mod tests { Ok(()) } + #[test] + fn the_stored_version_follows_the_markers_once_fenced() { + // a legacy database, everything applied in order up to v12 + let legacy = [[true; 12].as_slice(), &[false; 3]].concat(); + assert_eq!(schema_version(&legacy, false), 12); + // unfenced holes still stop the version, so a skipped migration runs before anything + // treats the database as past it + assert_eq!(schema_version(&[true, true, false, true], false), 2); + + // a relay-only build: v8 and the indexer passes never apply, the fence (v15) does + let mut relay = [true; 15]; + for skipped in [7, 9, 10, 11] { + relay[skipped] = false; + } + assert_eq!(schema_version(&relay, false), 7); + assert_eq!(schema_version(&relay, true), 15); + // and a v16 every build runs moves it on, so a v15 build is kept out of its changes + let after = [relay.as_slice(), &[true]].concat(); + assert_eq!(schema_version(&after, true), 16); + let not_yet = [relay.as_slice(), &[false]].concat(); + assert_eq!(schema_version(¬_yet, true), 15); + } + #[test] fn chunked_pass_rewrites_every_entry_across_many_chunks() -> Result<()> { let (_tmp, db) = open_db()?; diff --git a/src/db/migration/v13.rs b/src/db/migration/v13.rs index 619f85b..a9a4b38 100644 --- a/src/db/migration/v13.rs +++ b/src/db/migration/v13.rs @@ -59,13 +59,20 @@ mod tests { for (did, pds, active) in rows { let row = v7::RepoState { active, + status: v7::RepoStatus::Desynchronized, + root: None, + last_message_time: None, + last_identity_time: None, + last_account_time: None, + last_updated_at: 0, + signing_key: None, pds: Some(CowStr::Borrowed(pds)), - ..v7::RepoState::backfilling() + handle: None, }; batch.insert( &db.repos, keys::repo_key(&Did::new_static(did).into_diagnostic()?), - crate::db::ser_repo_state(&row)?, + rmp_serde::to_vec(&row).into_diagnostic()?, ); } // what the writers that never counted left behind: one short, one host whose diff --git a/src/db/migration/v14.rs b/src/db/migration/v14.rs index d00200d..c878008 100644 --- a/src/db/migration/v14.rs +++ b/src/db/migration/v14.rs @@ -52,7 +52,6 @@ mod tests { .get(VERSIONING_KEY) .into_diagnostic()? .expect("schema version"); - assert_eq!(LATEST_VERSION, 14); assert_eq!( u64::from_be_bytes(version.as_ref().try_into().into_diagnostic()?), LATEST_VERSION diff --git a/src/db/migration/v15.rs b/src/db/migration/v15.rs new file mode 100644 index 0000000..74ff32c --- /dev/null +++ b/src/db/migration/v15.rs @@ -0,0 +1,105 @@ +//! v15 is a fence for the `types::v15::RepoState` shape. old rows decode with no resolve time +//! through the serde default, but an older build can't read a stamped row, so it has to refuse +//! the database at open. it also notes when the ttl epoch started. don't drop it as a no-op + +use fjall::OwnedWriteBatch; +use miette::Result; + +use crate::db::Db; +use crate::db::counts::{IDENTITY_TTL_EPOCH, set_ks_count}; + +pub(super) fn repo_state_identity_resolved_at( + db: &Db, + batch: &mut OwnedWriteBatch, +) -> Result { + set_ks_count( + batch, + db, + IDENTITY_TTL_EPOCH, + chrono::Utc::now().timestamp() as u64, + ); + Ok(true) +} + +#[cfg(test)] +mod tests { + use crate::config::Config; + use crate::db::keys::{self, VERSIONING_KEY}; + use crate::db::migration::{LATEST_VERSION, rewind_version_for_test}; + use crate::db::{Db, deser_repo_state}; + use crate::types::v7; + use jacquard_common::types::did::Did; + use miette::{IntoDiagnostic, Result}; + use tempfile::tempdir; + + fn version(db: &Db) -> Result { + let bytes = db + .counts + .get(VERSIONING_KEY) + .into_diagnostic()? + .expect("schema version"); + Ok(u64::from_be_bytes( + bytes.as_ref().try_into().into_diagnostic()?, + )) + } + + fn cfg(path: &std::path::Path) -> Config { + Config { + database_path: path.to_path_buf(), + ..Default::default() + } + } + + /// relay-only builds never apply v8, so without the fence their version would stay at 7, + /// and a build from before v15 would open the db and fail on every stamped row + #[test] + fn every_build_fences_older_ones_out() -> Result<()> { + let tmp = tempdir().into_diagnostic()?; + let did = Did::new_static("did:plc:ewvi7nxzyoun6zhxrhs64oiz").into_diagnostic()?; + let old_row = rmp_serde::to_vec(&v7::RepoState { + active: true, + status: v7::RepoStatus::Synced, + root: None, + last_message_time: Some(1), + last_identity_time: None, + last_account_time: None, + last_updated_at: 2, + signing_key: None, + pds: Some("https://pds.example/".into()), + handle: None, + }) + .into_diagnostic()?; + { + let db = Db::open(&cfg(tmp.path()))?; + db.repos + .insert(keys::repo_key(&did), &old_row) + .into_diagnostic()?; + rewind_version_for_test(&db, 7)?; + db.persist()?; + } + + let db = Db::open(&cfg(tmp.path()))?; + assert_eq!(LATEST_VERSION, 15); + assert_eq!(version(&db)?, LATEST_VERSION); + let epoch = crate::db::counts::identity_ttl_epoch(&db)?; + assert!((chrono::Utc::now().timestamp() - epoch).abs() < 60); + let row = db + .repos + .get(keys::repo_key(&did)) + .into_diagnostic()? + .expect("repo row"); + assert_eq!( + row.as_ref(), + old_row.as_slice(), + "the fence rewrites nothing" + ); + assert_eq!(deser_repo_state(&row)?.identity_resolved_at, None); + drop(db); + + // and opening it again doesn't move anything, nor trip over what this build skipped + let db = Db::open(&cfg(tmp.path()))?; + assert_eq!(version(&db)?, LATEST_VERSION); + assert_eq!(crate::db::counts::identity_ttl_epoch(&db)?, epoch); + Ok(()) + } +} diff --git a/src/db/migration/v7.rs b/src/db/migration/v7.rs index 1ed45b3..b39448c 100644 --- a/src/db/migration/v7.rs +++ b/src/db/migration/v7.rs @@ -2,7 +2,8 @@ use fjall::OwnedWriteBatch; use miette::{Context, IntoDiagnostic, Result}; use crate::db::Db; -use crate::types::{RepoState, v4}; +use crate::types::v4; +use crate::types::v7::RepoState; pub(crate) fn repo_state_event_clocks(db: &Db, batch: &mut OwnedWriteBatch) -> Result { for guard in db.repos.iter() { @@ -101,11 +102,8 @@ mod tests { .get(keys::VERSIONING_KEY) .into_diagnostic()? .expect("db version should be set"); - let expected_version = if cfg!(feature = "indexer") { - crate::db::migration::LATEST_VERSION - } else { - 7 - }; + // relay-only builds skip v8 for good, but v15's fence still lifts them to the latest + let expected_version = crate::db::migration::LATEST_VERSION; assert_eq!( u64::from_be_bytes(version_bytes.as_ref().try_into().into_diagnostic()?), expected_version diff --git a/src/types.rs b/src/types.rs index 72eabbc..4b8d2b2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -13,6 +13,7 @@ use crate::resolver::MiniDoc; #[cfg(any(feature = "indexer_stream", feature = "relay", feature = "jetstream"))] pub(crate) mod event; pub(crate) mod v14; +pub(crate) mod v15; pub(crate) mod v2; pub(crate) mod v4; pub(crate) mod v7; @@ -20,6 +21,7 @@ pub(crate) mod v7; #[cfg(any(feature = "indexer_stream", feature = "relay", feature = "jetstream"))] pub(crate) use event::*; pub(crate) use v14::*; +pub(crate) use v15::RepoState; /// which record-scoped body copies an operator redaction removes. #[cfg(feature = "indexer")] @@ -214,6 +216,7 @@ impl<'i> RepoState<'i> { last_message_time: None, last_identity_time: None, last_account_time: None, + identity_resolved_at: None, } } @@ -328,10 +331,20 @@ impl<'i> RepoState<'i> { } else { (current.active, current.status) }; - let (handle, pds, signing_key) = if identity_changed { - (self.handle, self.pds, self.signing_key) + let (handle, pds, signing_key, identity_resolved_at) = if identity_changed { + ( + self.handle, + self.pds, + self.signing_key, + self.identity_resolved_at, + ) } else { - (current.handle, current.pds, current.signing_key) + ( + current.handle, + current.pds, + current.signing_key, + current.identity_resolved_at, + ) }; let root = match (self.root, current.root) { (_, theirs) if !root_changed => theirs, @@ -349,8 +362,20 @@ impl<'i> RepoState<'i> { signing_key, pds, handle, + identity_resolved_at, } } + + /// when the identity was last resolved. a row from before v15 has no time, and reads as + /// resolved at a point in the ttl before `epoch` picked by its did, so the rows that were + /// there at the upgrade come due for a refresh spread over one ttl instead of all at once + #[allow(dead_code)] + pub(crate) fn identity_resolved_or_spread(&self, did: &Did, epoch: i64, ttl: i64) -> i64 { + self.identity_resolved_at.unwrap_or_else(|| { + let spread = (crate::util::hash(&did.as_str()) % ttl.max(1) as u64) as i64; + epoch - spread + }) + } } impl<'i> IntoStatic for RepoState<'i> { @@ -368,6 +393,7 @@ impl<'i> IntoStatic for RepoState<'i> { last_message_time: self.last_message_time, last_identity_time: self.last_identity_time, last_account_time: self.last_account_time, + identity_resolved_at: self.identity_resolved_at, } } } @@ -610,6 +636,33 @@ mod tests { assert_eq!(merged.last_identity_time, Some(20)); } + #[test] + fn rows_without_a_resolve_time_spread_over_one_ttl() -> miette::Result<()> { + let ttl = 86_400; + let epoch = 1_700_000_000; + let ages = (0..1000) + .map(|i| { + let did = Did::new_owned(format!("did:plc:{i:0>24}")).into_diagnostic()?; + let state = RepoState::backfilling(); + let at = state.identity_resolved_or_spread(&did, epoch, ttl); + assert_eq!(at, state.identity_resolved_or_spread(&did, epoch, ttl)); + Ok(epoch - at) + }) + .collect::>>()?; + assert!(ages.iter().all(|age| (0..ttl).contains(age))); + // a tenth of the ttl each, give or take + for slot in 0..10 { + let in_slot = ages.iter().filter(|age| **age * 10 / ttl == slot).count(); + assert!((50..150).contains(&in_slot), "slot {slot} has {in_slot}"); + } + + let mut stamped = RepoState::backfilling(); + stamped.identity_resolved_at = Some(42); + let did = Did::new_static("did:plc:ewvi7nxzyoun6zhxrhs64oiz").into_diagnostic()?; + assert_eq!(stamped.identity_resolved_or_spread(&did, epoch, ttl), 42); + Ok(()) + } + #[test] fn commit_and_repo_status_conversion_contracts() { let did: Did = Did::new_static("did:plc:ewvi7nxzyoun6zhxrhs64oiz").unwrap(); diff --git a/src/types/v14.rs b/src/types/v14.rs index 07be4f1..715ed75 100644 --- a/src/types/v14.rs +++ b/src/types/v14.rs @@ -1,4 +1,4 @@ -pub(crate) use super::v7::{Commit, RepoState, RepoStatus}; +pub(crate) use super::v7::{Commit, RepoStatus}; use crate::db::types::DbTid; use serde::{Deserialize, Serialize}; diff --git a/src/types/v15.rs b/src/types/v15.rs new file mode 100644 index 0000000..48d4625 --- /dev/null +++ b/src/types/v15.rs @@ -0,0 +1,119 @@ +pub(crate) use super::v7::{Commit, RepoStatus}; +use crate::db::types::DidKey; +use jacquard_common::CowStr; +use jacquard_common::types::string::Handle; +use serde::{Deserialize, Serialize}; + +/// v7's row plus when its identity was last resolved. rows are msgpack arrays, so a v7 row +/// decodes as this one with the new field `None`, and nothing gets rewritten +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound(deserialize = "'i: 'de"))] +pub(crate) struct RepoState<'i> { + pub active: bool, + pub status: RepoStatus, + pub root: Option, + pub last_message_time: Option, + pub last_identity_time: Option, + pub last_account_time: Option, + pub last_updated_at: i64, // unix timestamp + #[serde(borrow)] + pub signing_key: Option>, + #[serde(borrow)] + pub pds: Option>, + pub handle: Option, + /// unix secs of the doc fetch that set handle, pds and key. `None` on rows from before v15, + /// which read as resolved somewhere in the ttl before the db first opened at v15. it's the + /// last field and left out when `None`, so a row nobody stamped yet stays a v7 row + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity_resolved_at: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v7_row() -> super::super::v7::RepoState<'static> { + super::super::v7::RepoState { + active: true, + status: RepoStatus::Synced, + root: None, + last_message_time: Some(7), + last_identity_time: Some(8), + last_account_time: Some(9), + last_updated_at: 42, + signing_key: Some(DidKey(std::borrow::Cow::Borrowed(&[0xed, 1, 2, 3]))), + pds: Some("https://pds.example".into()), + handle: Some(Handle::new_static("alice.test").unwrap()), + } + } + + #[test] + fn v15_persisted_schema_golden_fixture() { + let old = v7_row(); + let state = RepoState { + active: old.active, + status: old.status, + root: old.root, + last_message_time: old.last_message_time, + last_identity_time: old.last_identity_time, + last_account_time: old.last_account_time, + last_updated_at: old.last_updated_at, + signing_key: old.signing_key, + pds: old.pds, + handle: old.handle, + identity_resolved_at: Some(1_700_000_000), + }; + let encoded = rmp_serde::to_vec(&state).unwrap(); + assert_eq!( + hex::encode(&encoded), + "9bc3a653796e636564c00708092ac404ed010203b368747470733a2f2f7064732e6578616d706c65aa616c6963652e74657374ce6553f100" + ); + let decoded: RepoState<'_> = rmp_serde::from_slice(&encoded).unwrap(); + assert_eq!(decoded.identity_resolved_at, Some(1_700_000_000)); + assert_eq!(decoded.pds.as_deref(), Some("https://pds.example")); + } + + #[test] + fn an_unstamped_row_is_written_as_a_v7_row() { + let old = v7_row(); + let state = RepoState { + active: old.active, + status: old.status.clone(), + root: old.root.clone(), + last_message_time: old.last_message_time, + last_identity_time: old.last_identity_time, + last_account_time: old.last_account_time, + last_updated_at: old.last_updated_at, + signing_key: old.signing_key.clone(), + pds: old.pds.clone(), + handle: old.handle.clone(), + identity_resolved_at: None, + }; + assert_eq!( + rmp_serde::to_vec(&state).unwrap(), + rmp_serde::to_vec(&old).unwrap() + ); + } + + #[test] + fn a_v7_row_decodes_with_no_resolve_time() { + let encoded = rmp_serde::to_vec(&v7_row()).unwrap(); + let decoded: RepoState<'_> = rmp_serde::from_slice(&encoded).unwrap(); + assert_eq!(decoded.identity_resolved_at, None); + assert_eq!(decoded.last_account_time, Some(9)); + assert_eq!( + decoded.handle.as_ref().map(Handle::as_str), + Some("alice.test") + ); + } + + /// why v15 needs a fence: an older build can't read a row once it's stamped + #[test] + fn a_v7_reader_rejects_a_stamped_v15_row() { + let mut encoded = rmp_serde::to_vec(&v7_row()).unwrap(); + encoded[0] = 0x9b; + encoded.extend_from_slice(&[0xce, 0x65, 0x53, 0xf1, 0x00]); + assert!(rmp_serde::from_slice::>(&encoded).is_ok()); + assert!(rmp_serde::from_slice::>(&encoded).is_err()); + } +} diff --git a/tests/representation_inventory.tsv b/tests/representation_inventory.tsv index a3732c1..f6ba367 100644 --- a/tests/representation_inventory.tsv +++ b/tests/representation_inventory.tsv @@ -472,6 +472,7 @@ migration-transform:src/db/migration/mod.rs::read_version rust:src/db/migration/ migration-transform:src/db/migration/mod.rs::run rust:src/db/migration/mod.rs::a_chunk_that_fails_partway_commits_nothing migration-transform:src/db/migration/mod.rs::run_chunk rust:src/db/migration/mod.rs::a_chunk_that_fails_partway_commits_nothing migration-transform:src/db/migration/mod.rs::run_pass rust:src/db/migration/mod.rs::a_chunk_that_fails_partway_commits_nothing +migration-transform:src/db/migration/mod.rs::schema_version rust:src/db/migration/mod.rs::the_stored_version_follows_the_markers_once_fenced migration-transform:src/db/migration/v1.rs::stable_firehose_cursors rust:src/db/migration/mod.rs::v0_database_migrates_all_early_representation_boundaries migration-transform:src/db/migration/v10.rs::is_current_key rust:src/db/migration/v10.rs::v10_rewrites_records_and_retires_legacy_storage migration-transform:src/db/migration/v10.rs::parse_legacy_key rust:src/db/migration/v10.rs::v10_rewrites_records_and_retires_legacy_storage @@ -490,6 +491,7 @@ migration-transform:src/db/migration/v11.rs::status rust:src/db/migration/v11.rs migration-transform:src/db/migration/v12.rs::copy_pending_entry rust:src/db/migration/v12.rs::copies_a_queue_that_has_no_did_order_copy_yet migration-transform:src/db/migration/v13.rs::recount_pds_account_counts rust:src/db/migration/v13.rs::repairs_drifted_per_pds_counts_from_the_rows migration-transform:src/db/migration/v14.rs::repo_metadata_v14 rust:src/db/migration/v14.rs::old_database_opens_at_v14_with_its_metadata_rows_untouched +migration-transform:src/db/migration/v15.rs::repo_state_identity_resolved_at rust:src/db/migration/v15.rs::every_build_fences_older_ones_out migration-transform:src/db/migration/v2.rs::repo_state_root_commit rust:src/db/migration/mod.rs::v0_database_migrates_all_early_representation_boundaries migration-transform:src/db/migration/v3.rs::firehose_source_is_pds rust:src/db/migration/mod.rs::v0_database_migrates_all_early_representation_boundaries migration-transform:src/db/migration/v3.rs::is_known_relay rust:src/db/migration/mod.rs::v0_database_migrates_all_early_representation_boundaries @@ -695,6 +697,10 @@ serde-attribute:src/types/event/stream.rs::StoredEvent::serde(bound(deserialize= serde-attribute:src/types/v14.rs::RepoMetadata::backfilled_rev::serde(default) rust:src/types/v14.rs::v4_repo_metadata_rows_decode_with_no_retries serde-attribute:src/types/v14.rs::RepoMetadata::relay_inactive::serde(default) rust:src/types/v14.rs::v4_repo_metadata_rows_decode_with_no_retries serde-attribute:src/types/v14.rs::RepoMetadata::retry_count::serde(default) rust:src/types/v14.rs::v4_repo_metadata_rows_decode_with_no_retries +serde-attribute:src/types/v15.rs::RepoState::identity_resolved_at::serde(default,skip_serializing_if="Option::is_none") rust:src/types/v15.rs::an_unstamped_row_is_written_as_a_v7_row +serde-attribute:src/types/v15.rs::RepoState::pds::serde(borrow) rust:src/types/v15.rs::v15_persisted_schema_golden_fixture +serde-attribute:src/types/v15.rs::RepoState::serde(bound(deserialize="'i:'de")) rust:src/types/v15.rs::v15_persisted_schema_golden_fixture +serde-attribute:src/types/v15.rs::RepoState::signing_key::serde(borrow) rust:src/types/v15.rs::v15_persisted_schema_golden_fixture serde-attribute:src/types/v2.rs::Commit::sig::serde(with="jacquard_common::serde_bytes_helper") rust:src/types/v2.rs::v2_persisted_schema_golden_fixture serde-attribute:src/types/v2.rs::RepoState::pds::serde(borrow) rust:src/types/v2.rs::v2_persisted_schema_golden_fixture serde-attribute:src/types/v2.rs::RepoState::serde(bound(deserialize="'i:'de")) rust:src/types/v2.rs::v2_persisted_schema_golden_fixture @@ -838,6 +844,7 @@ serde-type:src/types/event/stream.rs::RecordEvt rust:src/types/event/stream.rs:: serde-type:src/types/event/stream.rs::StoredData rust:src/types/event/stream.rs::stored_data_persisted_golden_fixtures serde-type:src/types/event/stream.rs::StoredEvent rust:src/control/stream/indexer.rs::persisted_eight_byte_string_rkey_replays_verbatim serde-type:src/types/v14.rs::RepoMetadata rust:src/types/v14.rs::v14_repo_metadata_golden_fixtures +serde-type:src/types/v15.rs::RepoState rust:src/types/v15.rs::v15_persisted_schema_golden_fixture serde-type:src/types/v2.rs::Commit rust:src/types/v2.rs::v2_persisted_schema_golden_fixture serde-type:src/types/v2.rs::RepoState rust:src/types/v2.rs::v2_persisted_schema_golden_fixture serde-type:src/types/v2.rs::RepoStatus rust:src/types/v2.rs::v2_persisted_schema_golden_fixture