use icydb_schema::{
EntitySourceKey, ExpectedAcceptedHead, SchemaMigrationPlanDigest, TargetDatabaseIdentity,
};
use serde::Deserialize;
const MAX_SCHEMA_MIGRATION_STATUS_CURSOR_BYTES: usize = 256;
pub(in crate::db::schema) const MAX_SCHEMA_MIGRATION_FINDINGS_PER_PAGE: usize = 64;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
#[cfg_attr(not(target_family = "wasm"), derive(ic_wire::Wire))]
pub enum SchemaMigrationCommand {
Adopt {
expected_database: TargetDatabaseIdentity,
expected_head: ExpectedAcceptedHead,
},
Advance {
expected_database: TargetDatabaseIdentity,
expected_head: ExpectedAcceptedHead,
expected_plan: SchemaMigrationPlanDigest,
acknowledged_finding_page: Option<u64>,
},
Abort {
expected_database: TargetDatabaseIdentity,
expected_head: ExpectedAcceptedHead,
expected_plan: SchemaMigrationPlanDigest,
},
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, serde::Serialize)]
#[cfg_attr(not(target_family = "wasm"), derive(ic_wire::Wire))]
pub struct SchemaMigrationStatusRequest {
#[serde(with = "serde_bytes")]
cursor: Option<Vec<u8>>,
}
impl SchemaMigrationStatusRequest {
#[must_use]
pub const fn new(cursor: Option<Vec<u8>>) -> Self {
Self { cursor }
}
#[must_use]
pub fn cursor(&self) -> Option<&[u8]> {
self.cursor.as_deref()
}
pub(in crate::db::schema) fn validate(&self) -> bool {
self.cursor
.as_ref()
.is_none_or(|cursor| cursor.len() <= MAX_SCHEMA_MIGRATION_STATUS_CURSOR_BYTES)
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
#[cfg_attr(not(target_family = "wasm"), derive(ic_wire::Wire))]
pub enum SchemaMigrationPhase {
Unadopted,
Adopted,
Idle,
Prepared,
Validating,
ReadyToRewrite,
RewritingRows,
RebuildingIndexes,
FinalValidation,
Publishing,
Applied,
Rejected,
Aborted,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
#[cfg_attr(not(target_family = "wasm"), derive(ic_wire::Wire))]
pub struct SchemaMigrationEntityTransition {
entity: EntitySourceKey,
from_version: Option<u32>,
to_version: u32,
}
impl SchemaMigrationEntityTransition {
pub(in crate::db::schema) const fn new(
entity: EntitySourceKey,
from_version: Option<u32>,
to_version: u32,
) -> Self {
Self {
entity,
from_version,
to_version,
}
}
#[must_use]
pub const fn entity(&self) -> &EntitySourceKey {
&self.entity
}
#[must_use]
pub const fn from_version(&self) -> Option<u32> {
self.from_version
}
#[must_use]
pub const fn to_version(&self) -> u32 {
self.to_version
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
#[cfg_attr(not(target_family = "wasm"), derive(ic_wire::Wire))]
pub enum SchemaMigrationFindingKind {
Transform,
UniqueIndex,
Relation,
Constraint,
ResourceLimit,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
#[cfg_attr(not(target_family = "wasm"), derive(ic_wire::Wire))]
pub struct SchemaMigrationFinding {
kind: SchemaMigrationFindingKind,
entity_tag: u64,
#[serde(with = "serde_bytes")]
primary_key: Vec<u8>,
}
impl SchemaMigrationFinding {
pub(in crate::db::schema) const fn new(
kind: SchemaMigrationFindingKind,
entity_tag: u64,
primary_key: Vec<u8>,
) -> Self {
Self {
kind,
entity_tag,
primary_key,
}
}
#[must_use]
pub const fn kind(&self) -> SchemaMigrationFindingKind {
self.kind
}
#[must_use]
pub const fn entity_tag(&self) -> u64 {
self.entity_tag
}
#[must_use]
pub const fn primary_key(&self) -> &[u8] {
self.primary_key.as_slice()
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
#[cfg_attr(not(target_family = "wasm"), derive(ic_wire::Wire))]
pub struct SchemaMigrationReceipt {
database_identity: TargetDatabaseIdentity,
plan_digest: Option<SchemaMigrationPlanDigest>,
prior_head: ExpectedAcceptedHead,
accepted_head: ExpectedAcceptedHead,
}
impl SchemaMigrationReceipt {
pub(in crate::db::schema) const fn new(
database_identity: TargetDatabaseIdentity,
plan_digest: Option<SchemaMigrationPlanDigest>,
prior_head: ExpectedAcceptedHead,
accepted_head: ExpectedAcceptedHead,
) -> Self {
Self {
database_identity,
plan_digest,
prior_head,
accepted_head,
}
}
#[must_use]
pub const fn database_identity(&self) -> TargetDatabaseIdentity {
self.database_identity
}
#[must_use]
pub const fn plan_digest(&self) -> Option<SchemaMigrationPlanDigest> {
self.plan_digest
}
#[must_use]
pub const fn prior_head(&self) -> &ExpectedAcceptedHead {
&self.prior_head
}
#[must_use]
pub const fn accepted_head(&self) -> &ExpectedAcceptedHead {
&self.accepted_head
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, serde::Serialize)]
#[cfg_attr(not(target_family = "wasm"), derive(ic_wire::Wire))]
pub struct SchemaMigrationStatusPage {
database_identity: TargetDatabaseIdentity,
accepted_head: ExpectedAcceptedHead,
plan_digest: Option<SchemaMigrationPlanDigest>,
phase: SchemaMigrationPhase,
transitions: Vec<SchemaMigrationEntityTransition>,
rows_validated: u64,
rows_rewritten: u64,
indexes_rebuilt: u32,
findings: Vec<SchemaMigrationFinding>,
#[serde(with = "serde_bytes")]
next_cursor: Option<Vec<u8>>,
terminal_receipt: Option<SchemaMigrationReceipt>,
}
impl SchemaMigrationStatusPage {
#[expect(
clippy::too_many_arguments,
reason = "the constructor mirrors the frozen bounded status-page contract"
)]
pub(in crate::db::schema) fn new(
database_identity: TargetDatabaseIdentity,
accepted_head: ExpectedAcceptedHead,
plan_digest: Option<SchemaMigrationPlanDigest>,
phase: SchemaMigrationPhase,
transitions: Vec<SchemaMigrationEntityTransition>,
rows_validated: u64,
rows_rewritten: u64,
indexes_rebuilt: u32,
findings: Vec<SchemaMigrationFinding>,
next_cursor: Option<Vec<u8>>,
terminal_receipt: Option<SchemaMigrationReceipt>,
) -> Self {
debug_assert!(findings.len() <= MAX_SCHEMA_MIGRATION_FINDINGS_PER_PAGE);
Self {
database_identity,
accepted_head,
plan_digest,
phase,
transitions,
rows_validated,
rows_rewritten,
indexes_rebuilt,
findings,
next_cursor,
terminal_receipt,
}
}
#[must_use]
pub const fn database_identity(&self) -> TargetDatabaseIdentity {
self.database_identity
}
#[must_use]
pub const fn accepted_head(&self) -> &ExpectedAcceptedHead {
&self.accepted_head
}
#[must_use]
pub const fn plan_digest(&self) -> Option<SchemaMigrationPlanDigest> {
self.plan_digest
}
#[must_use]
pub const fn phase(&self) -> SchemaMigrationPhase {
self.phase
}
#[must_use]
pub const fn transitions(&self) -> &[SchemaMigrationEntityTransition] {
self.transitions.as_slice()
}
#[must_use]
pub const fn rows_validated(&self) -> u64 {
self.rows_validated
}
#[must_use]
pub const fn rows_rewritten(&self) -> u64 {
self.rows_rewritten
}
#[must_use]
pub const fn indexes_rebuilt(&self) -> u32 {
self.indexes_rebuilt
}
#[must_use]
pub const fn findings(&self) -> &[SchemaMigrationFinding] {
self.findings.as_slice()
}
#[must_use]
pub fn next_cursor(&self) -> Option<&[u8]> {
self.next_cursor.as_deref()
}
#[must_use]
pub const fn terminal_receipt(&self) -> Option<&SchemaMigrationReceipt> {
self.terminal_receipt.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::{MAX_SCHEMA_MIGRATION_STATUS_CURSOR_BYTES, SchemaMigrationStatusRequest};
use icydb_schema::wire::{decode_one, encode_one};
#[test]
fn cursor_byte_buffers_preserve_payload_and_application_bounds() {
for length in [0, 1, 127, 128, 256, 257] {
let cursor = vec![255; length];
let request = SchemaMigrationStatusRequest::new(Some(cursor.clone()));
let bytes = encode_one(&request).expect("encode byte cursor");
assert_eq!(bytes, encode_one(Some(cursor)).expect("encode control"));
let decoded: SchemaMigrationStatusRequest = decode_one(&bytes).expect("decode cursor");
assert_eq!(decoded, request);
assert_eq!(
decoded.validate(),
length <= MAX_SCHEMA_MIGRATION_STATUS_CURSOR_BYTES
);
assert!(decode_one::<SchemaMigrationStatusRequest>(&bytes[..bytes.len() - 1]).is_err());
}
let absent = SchemaMigrationStatusRequest::default();
assert_eq!(encode_one(&absent).expect("encode absent cursor"), [0]);
assert_eq!(
decode_one::<SchemaMigrationStatusRequest>(&[0]).expect("decode absent cursor"),
absent
);
}
}