contract.rs (43680B)
1 //! Sealed HarvestCircle service-state identity and schema contract. 2 3 use core::{fmt, num::NonZeroU32}; 4 use std::error::Error; 5 6 use radroots_runtime_paths::RuntimeContext; 7 use radroots_service_sqlite::{ 8 MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, 9 SchemaCatalogContractError, SchemaDigest, SchemaObject, SchemaObjectKind, SchemaVersionCatalog, 10 ServiceSqliteApplicationId, ServiceSqlitePaths, 11 }; 12 13 pub const HARVESTCIRCLE_SERVICE_ID: &str = "harvestcircle"; 14 pub const HARVESTCIRCLE_INSTANCE_ID: &str = "desktop"; 15 pub const HARVESTCIRCLE_APPLICATION_ID: u32 = 0x4843_5231; 16 pub(crate) const HARVESTCIRCLE_INITIAL_STATE_SCHEMA_VERSION: u32 = 1; 17 pub const HARVESTCIRCLE_STATE_SCHEMA_VERSION: u32 = 3; 18 pub const HARVESTCIRCLE_IDENTITY_CAPACITY: usize = 256; 19 pub const HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY: usize = 1_024; 20 pub const HARVESTCIRCLE_DURABLE_OPERATION_CAPACITY: usize = 4_096; 21 pub const HARVESTCIRCLE_DURABLE_OPERATION_CLEANUP_BATCH: usize = 256; 22 pub const HARVESTCIRCLE_TERMINAL_RECEIPT_RETENTION_SECONDS: i64 = 7 * 24 * 60 * 60; 23 pub const HARVESTCIRCLE_PREFERENCE_VALUE_UTF8_BYTES: usize = 4_096; 24 pub const HARVESTCIRCLE_RELAY_ENDPOINT_CAPACITY: usize = 16; 25 pub const HARVESTCIRCLE_RELAY_URL_UTF8_BYTES: usize = 2_048; 26 pub const HARVESTCIRCLE_EVENTS_PER_RELAY_CAPACITY: usize = 64; 27 pub const HARVESTCIRCLE_EVENTS_TOTAL_CAPACITY: usize = 1_024; 28 pub const HARVESTCIRCLE_OBSERVER_CAPACITY: usize = 32; 29 pub const HARVESTCIRCLE_ACTOR_MAILBOX_CAPACITY: usize = 64; 30 pub const HARVESTCIRCLE_COMMAND_DEADLINE_MIN_MS: u64 = 1; 31 pub const HARVESTCIRCLE_COMMAND_DEADLINE_MAX_MS: u64 = 30_000; 32 33 pub(crate) const CREATE_ACCOUNT_IDENTITIES_SQL: &str = r#"CREATE TABLE account_identities ( 34 public_key BLOB NOT NULL PRIMARY KEY CHECK (length(public_key) = 32), 35 npub TEXT NOT NULL UNIQUE CHECK (length(CAST(npub AS BLOB)) = 63), 36 label TEXT CHECK (label IS NULL OR length(CAST(label AS BLOB)) BETWEEN 1 AND 80), 37 created_at_unix_s INTEGER NOT NULL CHECK (created_at_unix_s >= 0), 38 last_used_at_unix_s INTEGER CHECK (last_used_at_unix_s IS NULL OR last_used_at_unix_s >= 0) 39 ) STRICT"#; 40 41 pub(crate) const CREATE_LOCAL_SIGNER_BINDINGS_SQL: &str = r#"CREATE TABLE local_signer_bindings ( 42 account_public_key BLOB NOT NULL, 43 binding_public_key BLOB NOT NULL, 44 binding_kind TEXT NOT NULL CHECK (binding_kind = 'local_secret'), 45 availability TEXT NOT NULL CHECK ( 46 availability IN ('available', 'credential_missing', 'store_unavailable') 47 ), 48 PRIMARY KEY (account_public_key, binding_public_key), 49 UNIQUE (account_public_key, binding_kind), 50 FOREIGN KEY (account_public_key) REFERENCES account_identities(public_key) ON DELETE CASCADE, 51 CHECK (length(account_public_key) = 32), 52 CHECK (length(binding_public_key) = 32), 53 CHECK (account_public_key = binding_public_key) 54 ) STRICT"#; 55 56 pub(crate) const CREATE_RUNTIME_STATE_SQL: &str = r#"CREATE TABLE runtime_state ( 57 singleton INTEGER NOT NULL PRIMARY KEY CHECK (singleton = 1), 58 selected_public_key BLOB REFERENCES account_identities(public_key) ON DELETE SET NULL, 59 active_account_public_key BLOB, 60 active_binding_public_key BLOB, 61 session_generation INTEGER NOT NULL DEFAULT 0 CHECK (session_generation >= 0), 62 FOREIGN KEY (active_account_public_key, active_binding_public_key) 63 REFERENCES local_signer_bindings(account_public_key, binding_public_key) 64 ON DELETE SET NULL, 65 CHECK (selected_public_key IS NULL OR length(selected_public_key) = 32), 66 CHECK ( 67 (active_account_public_key IS NULL AND active_binding_public_key IS NULL) 68 OR 69 (length(active_account_public_key) = 32 AND length(active_binding_public_key) = 32) 70 ) 71 ) STRICT"#; 72 73 pub(crate) const CREATE_PROFILE_CACHE_SQL: &str = r#"CREATE TABLE profile_cache ( 74 subject_public_key BLOB NOT NULL PRIMARY KEY 75 REFERENCES account_identities(public_key) ON DELETE CASCADE, 76 event_id BLOB NOT NULL CHECK (length(event_id) = 32), 77 event_created_at_unix_s INTEGER NOT NULL CHECK (event_created_at_unix_s >= 0), 78 name TEXT CHECK (name IS NULL OR length(CAST(name AS BLOB)) BETWEEN 1 AND 128), 79 display_name TEXT CHECK (display_name IS NULL OR length(CAST(display_name AS BLOB)) BETWEEN 1 AND 128), 80 nip05 TEXT CHECK (nip05 IS NULL OR length(CAST(nip05 AS BLOB)) BETWEEN 1 AND 320), 81 about TEXT CHECK (about IS NULL OR length(CAST(about AS BLOB)) BETWEEN 1 AND 4096), 82 picture TEXT CHECK (picture IS NULL OR length(CAST(picture AS BLOB)) BETWEEN 1 AND 2048), 83 refreshed_at_unix_s INTEGER NOT NULL CHECK (refreshed_at_unix_s >= 0), 84 refresh_status TEXT NOT NULL CHECK (refresh_status IN ('success', 'offline', 'invalid_data')) 85 ) STRICT"#; 86 87 pub(crate) const CREATE_ACCOUNT_PREFERENCES_SQL: &str = r#"CREATE TABLE account_preferences ( 88 owner_public_key BLOB NOT NULL REFERENCES account_identities(public_key) ON DELETE CASCADE, 89 preference_key TEXT NOT NULL CHECK (preference_key = 'namespace_probe'), 90 preference_value TEXT NOT NULL CHECK ( 91 length(CAST(preference_value AS BLOB)) BETWEEN 1 AND 4096 92 ), 93 PRIMARY KEY (owner_public_key, preference_key), 94 CHECK (length(owner_public_key) = 32) 95 ) STRICT"#; 96 97 pub(crate) const CREATE_DURABLE_OPERATIONS_SQL: &str = r#"CREATE TABLE durable_operations ( 98 request_id TEXT NOT NULL PRIMARY KEY CHECK ( 99 length(CAST(request_id AS BLOB)) = 36 100 AND request_id = lower(request_id) 101 AND request_id NOT GLOB '*[^0-9a-f-]*' 102 AND substr(request_id, 9, 1) = '-' 103 AND substr(request_id, 14, 1) = '-' 104 AND substr(request_id, 15, 1) = '7' 105 AND substr(request_id, 19, 1) = '-' 106 AND substr(request_id, 20, 1) IN ('8', '9', 'a', 'b') 107 AND substr(request_id, 24, 1) = '-' 108 ), 109 operation_kind TEXT NOT NULL CHECK (operation_kind IN ('create', 'import', 'repair', 'remove')), 110 account_public_key BLOB NOT NULL CHECK (length(account_public_key) = 32), 111 binding_public_key BLOB NOT NULL CHECK (length(binding_public_key) = 32), 112 expected_revision INTEGER CHECK (expected_revision IS NULL OR expected_revision >= 0), 113 phase TEXT NOT NULL CHECK (phase IN ( 114 'intent_recorded', 'credential_written', 'metadata_committed', 'selection_committed', 115 'compensation_pending', 'credential_deleted', 'metadata_deleted', 'finalized' 116 )), 117 terminal_outcome TEXT CHECK ( 118 terminal_outcome IS NULL OR terminal_outcome IN ('completed', 'cancelled', 'failed') 119 ), 120 prior_selected_public_key BLOB CHECK ( 121 prior_selected_public_key IS NULL OR length(prior_selected_public_key) = 32 122 ), 123 prior_binding_availability TEXT CHECK ( 124 prior_binding_availability IS NULL OR prior_binding_availability IN ( 125 'available', 'credential_missing', 'store_unavailable' 126 ) 127 ), 128 resulting_revision INTEGER CHECK (resulting_revision IS NULL OR resulting_revision >= 0), 129 updated_at_unix_s INTEGER NOT NULL CHECK (updated_at_unix_s >= 0), 130 diagnostic_code TEXT CHECK (diagnostic_code IS NULL OR diagnostic_code IN ( 131 'storage_unavailable', 'keyring_unavailable', 'credential_missing', 132 'compensation_failed', 'conflict', 'expired' 133 )), 134 CHECK (account_public_key = binding_public_key), 135 CHECK ( 136 (phase = 'finalized' AND terminal_outcome IS NOT NULL) 137 OR 138 (phase <> 'finalized' AND terminal_outcome IS NULL AND resulting_revision IS NULL) 139 ) 140 ) STRICT"#; 141 142 const CREATE_DURABLE_OPERATIONS_V2_SQL: &str = r#"CREATE TABLE durable_operations ( 143 request_id TEXT NOT NULL PRIMARY KEY CHECK ( 144 length(CAST(request_id AS BLOB)) = 36 145 AND request_id = lower(request_id) 146 AND request_id NOT GLOB '*[^0-9a-f-]*' 147 AND substr(request_id, 9, 1) = '-' 148 AND substr(request_id, 14, 1) = '-' 149 AND substr(request_id, 15, 1) = '7' 150 AND substr(request_id, 19, 1) = '-' 151 AND substr(request_id, 20, 1) IN ('8', '9', 'a', 'b') 152 AND substr(request_id, 24, 1) = '-' 153 ), 154 operation_kind TEXT NOT NULL CHECK (operation_kind IN ('create', 'import', 'repair', 'remove')), 155 account_public_key BLOB NOT NULL CHECK (length(account_public_key) = 32), 156 binding_public_key BLOB NOT NULL CHECK (length(binding_public_key) = 32), 157 expected_revision INTEGER CHECK (expected_revision IS NULL OR expected_revision >= 0), 158 phase TEXT NOT NULL CHECK (phase IN ( 159 'intent_recorded', 'credential_written', 'metadata_committed', 'selection_committed', 160 'compensation_pending', 'credential_deleted', 'metadata_deleted', 'finalized' 161 )), 162 terminal_outcome TEXT CHECK ( 163 terminal_outcome IS NULL OR terminal_outcome IN ('completed', 'cancelled', 'failed') 164 ), 165 prior_selected_public_key BLOB CHECK ( 166 prior_selected_public_key IS NULL OR length(prior_selected_public_key) = 32 167 ), 168 prior_binding_availability TEXT CHECK ( 169 prior_binding_availability IS NULL OR prior_binding_availability IN ( 170 'available', 'credential_missing', 'store_unavailable' 171 ) 172 ), 173 resulting_revision INTEGER CHECK (resulting_revision IS NULL OR resulting_revision >= 0), 174 updated_at_unix_s INTEGER NOT NULL CHECK (updated_at_unix_s >= 0), 175 diagnostic_code TEXT CHECK (diagnostic_code IS NULL OR diagnostic_code IN ( 176 'storage_unavailable', 'keyring_unavailable', 'credential_missing', 177 'compensation_failed', 'conflict', 'expired' 178 )), completed_at_unix_s INTEGER CHECK ( 179 completed_at_unix_s IS NULL OR completed_at_unix_s >= 0 180 ), 181 CHECK (account_public_key = binding_public_key), 182 CHECK ( 183 (phase = 'finalized' AND terminal_outcome IS NOT NULL) 184 OR 185 (phase <> 'finalized' AND terminal_outcome IS NULL AND resulting_revision IS NULL) 186 ) 187 ) STRICT"#; 188 189 const CREATE_DURABLE_OPERATIONS_RECEIPT_INSERT_GUARD_SQL: &str = r#"CREATE TRIGGER durable_operations_receipt_insert_guard 190 BEFORE INSERT ON durable_operations 191 WHEN NOT ( 192 ( 193 NEW.phase = 'finalized' 194 AND NEW.terminal_outcome IS NOT NULL 195 AND NEW.completed_at_unix_s IS NOT NULL 196 ) 197 OR 198 ( 199 NEW.phase <> 'finalized' 200 AND NEW.terminal_outcome IS NULL 201 AND NEW.resulting_revision IS NULL 202 AND NEW.completed_at_unix_s IS NULL 203 ) 204 ) 205 BEGIN 206 SELECT RAISE(ABORT, 'durable operation receipt invariant'); 207 END"#; 208 209 const CREATE_DURABLE_OPERATIONS_RECEIPT_UPDATE_GUARD_SQL: &str = r#"CREATE TRIGGER durable_operations_receipt_update_guard 210 BEFORE UPDATE ON durable_operations 211 WHEN NOT ( 212 ( 213 NEW.phase = 'finalized' 214 AND NEW.terminal_outcome IS NOT NULL 215 AND NEW.completed_at_unix_s IS NOT NULL 216 ) 217 OR 218 ( 219 NEW.phase <> 'finalized' 220 AND NEW.terminal_outcome IS NULL 221 AND NEW.resulting_revision IS NULL 222 AND NEW.completed_at_unix_s IS NULL 223 ) 224 ) 225 BEGIN 226 SELECT RAISE(ABORT, 'durable operation receipt invariant'); 227 END"#; 228 229 pub(crate) const MIGRATE_DURABLE_OPERATIONS_V2_SQL: &str = r#"ALTER TABLE durable_operations 230 ADD COLUMN completed_at_unix_s INTEGER CHECK ( 231 completed_at_unix_s IS NULL OR completed_at_unix_s >= 0 232 ); 233 UPDATE durable_operations 234 SET completed_at_unix_s = updated_at_unix_s 235 WHERE phase = 'finalized'; 236 CREATE TRIGGER durable_operations_receipt_insert_guard 237 BEFORE INSERT ON durable_operations 238 WHEN NOT ( 239 ( 240 NEW.phase = 'finalized' 241 AND NEW.terminal_outcome IS NOT NULL 242 AND NEW.completed_at_unix_s IS NOT NULL 243 ) 244 OR 245 ( 246 NEW.phase <> 'finalized' 247 AND NEW.terminal_outcome IS NULL 248 AND NEW.resulting_revision IS NULL 249 AND NEW.completed_at_unix_s IS NULL 250 ) 251 ) 252 BEGIN 253 SELECT RAISE(ABORT, 'durable operation receipt invariant'); 254 END; 255 CREATE TRIGGER durable_operations_receipt_update_guard 256 BEFORE UPDATE ON durable_operations 257 WHEN NOT ( 258 ( 259 NEW.phase = 'finalized' 260 AND NEW.terminal_outcome IS NOT NULL 261 AND NEW.completed_at_unix_s IS NOT NULL 262 ) 263 OR 264 ( 265 NEW.phase <> 'finalized' 266 AND NEW.terminal_outcome IS NULL 267 AND NEW.resulting_revision IS NULL 268 AND NEW.completed_at_unix_s IS NULL 269 ) 270 ) 271 BEGIN 272 SELECT RAISE(ABORT, 'durable operation receipt invariant'); 273 END; 274 UPDATE durable_operations SET request_id = request_id"#; 275 276 pub(crate) const CREATE_INSTALLATION_IDENTITY_SQL: &str = r#"CREATE TABLE installation_identity ( 277 singleton INTEGER NOT NULL PRIMARY KEY CHECK (singleton = 1), 278 installation_id BLOB NOT NULL CHECK (length(installation_id) = 16) 279 ) STRICT"#; 280 281 pub(crate) const MIGRATE_AVAILABILITY_EVIDENCE_V3_SQL: &str = r#"UPDATE account_preferences SET preference_value = preference_value; 282 CREATE TABLE availability_versions ( 283 event_id BLOB NOT NULL PRIMARY KEY CHECK (length(event_id) = 32), 284 author BLOB NOT NULL CHECK (length(author) = 32), 285 kind INTEGER NOT NULL CHECK (kind = 30402), 286 raw_d TEXT NOT NULL CHECK (length(CAST(raw_d AS BLOB)) BETWEEN 0 AND 4096), 287 signed_at BLOB NOT NULL CHECK (length(signed_at) = 8), 288 published_at BLOB CHECK (published_at IS NULL OR length(published_at) = 8), 289 original_json TEXT NOT NULL CHECK ( 290 length(CAST(original_json AS BLOB)) BETWEEN 1 AND 262144 291 ), 292 admission_label TEXT NOT NULL CHECK (admission_label IN ( 293 'focused', 'excluded_focused', 'excluded_operational', 'excluded_generic', 294 'excluded_ambiguous', 'projection_rejected' 295 )), 296 rejection_code TEXT CHECK ( 297 rejection_code IS NULL OR ( 298 length(CAST(rejection_code AS BLOB)) BETWEEN 1 AND 64 299 AND rejection_code NOT GLOB '*[^ -~]*' 300 ) 301 ), 302 source TEXT NOT NULL CHECK (length(CAST(source AS BLOB)) BETWEEN 1 AND 2048), 303 observed_at_unix_s INTEGER NOT NULL CHECK (observed_at_unix_s >= 0), 304 payload_bytes INTEGER NOT NULL CHECK ( 305 payload_bytes = 92 + length(CAST(original_json AS BLOB)) 306 + length(CAST(raw_d AS BLOB)) + length(CAST(source AS BLOB)) 307 + length(CAST(admission_label AS BLOB)) 308 + coalesce(length(CAST(rejection_code AS BLOB)), 0) 309 ), 310 CHECK ( 311 (admission_label = 'focused' AND rejection_code IS NULL AND published_at IS NOT NULL) 312 OR 313 (admission_label IN ( 314 'excluded_focused', 'excluded_operational', 'excluded_generic', 'excluded_ambiguous' 315 ) AND rejection_code IS NULL AND published_at IS NULL) 316 OR 317 (admission_label = 'projection_rejected' AND rejection_code IS NOT NULL AND published_at IS NULL) 318 ) 319 ) STRICT; 320 CREATE TABLE public_payload_usage ( 321 singleton INTEGER NOT NULL PRIMARY KEY CHECK (singleton = 1), 322 version_count INTEGER NOT NULL CHECK (version_count BETWEEN 0 AND 4096), 323 payload_bytes INTEGER NOT NULL CHECK (payload_bytes BETWEEN 0 AND 134217728) 324 ) STRICT; 325 INSERT INTO public_payload_usage (singleton, version_count, payload_bytes) VALUES (1, 0, 0)"#; 326 327 pub(crate) const CREATE_INSTALLATION_IDENTITY_NO_UPDATE_SQL: &str = r#"CREATE TRIGGER installation_identity_no_update 328 BEFORE UPDATE ON installation_identity 329 BEGIN 330 SELECT RAISE(ABORT, 'installation identity is immutable'); 331 END"#; 332 333 pub(crate) const CREATE_INSTALLATION_IDENTITY_NO_DELETE_SQL: &str = r#"CREATE TRIGGER installation_identity_no_delete 334 BEFORE DELETE ON installation_identity 335 BEGIN 336 SELECT RAISE(ABORT, 'installation identity is immutable'); 337 END"#; 338 339 const INITIAL_SCHEMA_SQL: [&str; 9] = [ 340 CREATE_ACCOUNT_IDENTITIES_SQL, 341 CREATE_LOCAL_SIGNER_BINDINGS_SQL, 342 CREATE_RUNTIME_STATE_SQL, 343 CREATE_PROFILE_CACHE_SQL, 344 CREATE_ACCOUNT_PREFERENCES_SQL, 345 CREATE_DURABLE_OPERATIONS_SQL, 346 CREATE_INSTALLATION_IDENTITY_SQL, 347 CREATE_INSTALLATION_IDENTITY_NO_UPDATE_SQL, 348 CREATE_INSTALLATION_IDENTITY_NO_DELETE_SQL, 349 ]; 350 351 const OBJECT_DIGESTS: [[u8; 32]; 9] = [ 352 [ 353 203, 193, 254, 189, 121, 130, 165, 156, 1, 155, 21, 35, 130, 72, 131, 44, 34, 217, 168, 354 187, 96, 155, 40, 226, 113, 78, 22, 255, 8, 65, 23, 38, 355 ], 356 [ 357 204, 249, 174, 98, 165, 65, 184, 31, 254, 31, 101, 17, 139, 176, 170, 131, 88, 225, 158, 6, 358 174, 77, 192, 131, 84, 153, 142, 200, 213, 235, 141, 82, 359 ], 360 [ 361 9, 60, 207, 79, 94, 50, 51, 84, 228, 163, 119, 152, 227, 137, 166, 31, 166, 235, 70, 79, 362 228, 160, 229, 246, 4, 87, 11, 172, 36, 151, 102, 234, 363 ], 364 [ 365 17, 90, 168, 107, 5, 179, 134, 52, 25, 51, 228, 255, 236, 36, 157, 152, 26, 66, 108, 147, 366 239, 116, 3, 99, 82, 220, 182, 236, 209, 136, 126, 97, 367 ], 368 [ 369 180, 232, 35, 143, 72, 174, 82, 223, 52, 122, 142, 211, 5, 167, 155, 75, 69, 223, 34, 117, 370 131, 5, 132, 107, 175, 198, 215, 16, 71, 114, 4, 127, 371 ], 372 [ 373 135, 198, 48, 230, 122, 86, 86, 153, 66, 95, 22, 123, 24, 164, 49, 229, 246, 218, 210, 233, 374 61, 182, 81, 194, 251, 121, 165, 203, 8, 29, 63, 27, 375 ], 376 [ 377 132, 111, 227, 84, 42, 121, 244, 99, 22, 255, 131, 104, 48, 33, 7, 146, 174, 120, 176, 103, 378 37, 23, 171, 90, 90, 215, 142, 212, 32, 9, 250, 188, 379 ], 380 [ 381 142, 248, 11, 168, 116, 173, 238, 101, 167, 191, 95, 63, 180, 126, 229, 156, 164, 217, 108, 382 71, 221, 145, 70, 169, 91, 117, 33, 93, 34, 250, 120, 151, 383 ], 384 [ 385 127, 153, 155, 84, 191, 170, 38, 18, 239, 225, 90, 123, 208, 172, 218, 99, 2, 212, 181, 386 212, 194, 19, 99, 242, 225, 249, 202, 134, 204, 219, 200, 23, 387 ], 388 ]; 389 const VERSION_ONE_DIGEST: [u8; 32] = [ 390 61, 122, 56, 39, 178, 126, 179, 157, 145, 167, 19, 2, 172, 134, 213, 107, 151, 196, 212, 57, 391 17, 112, 163, 67, 240, 140, 61, 62, 5, 101, 14, 71, 392 ]; 393 const DURABLE_OPERATIONS_V2_DIGEST: [u8; 32] = [ 394 162, 24, 8, 218, 50, 32, 224, 252, 60, 229, 209, 160, 204, 46, 226, 37, 88, 102, 22, 2, 156, 395 177, 28, 19, 235, 161, 26, 125, 65, 126, 59, 135, 396 ]; 397 const DURABLE_OPERATIONS_RECEIPT_INSERT_GUARD_DIGEST: [u8; 32] = [ 398 130, 178, 229, 235, 158, 182, 51, 151, 133, 196, 115, 85, 8, 166, 169, 136, 62, 26, 211, 92, 399 215, 57, 254, 158, 103, 196, 53, 39, 106, 201, 183, 14, 400 ]; 401 const DURABLE_OPERATIONS_RECEIPT_UPDATE_GUARD_DIGEST: [u8; 32] = [ 402 48, 131, 220, 252, 243, 158, 221, 88, 8, 140, 207, 187, 34, 138, 145, 215, 92, 99, 32, 72, 254, 403 25, 96, 241, 33, 172, 150, 186, 98, 114, 27, 142, 404 ]; 405 const VERSION_TWO_DIGEST: [u8; 32] = [ 406 78, 151, 73, 238, 2, 15, 71, 52, 11, 111, 100, 95, 135, 11, 170, 138, 84, 105, 106, 177, 27, 7, 407 134, 156, 53, 68, 22, 220, 116, 199, 147, 5, 408 ]; 409 const DURABLE_OPERATIONS_V2_MIGRATION_CHECKSUM: MigrationChecksum = 410 MigrationChecksum::from_bytes([ 411 107, 95, 237, 250, 255, 0, 44, 110, 142, 194, 92, 163, 84, 27, 96, 31, 210, 37, 151, 186, 412 210, 83, 137, 114, 251, 20, 30, 31, 11, 136, 207, 168, 413 ]); 414 415 const AVAILABILITY_VERSIONS_DIGEST: [u8; 32] = [ 416 250, 239, 225, 0, 53, 126, 33, 150, 232, 105, 102, 35, 26, 221, 78, 156, 137, 219, 66, 240, 10, 417 18, 96, 99, 102, 199, 219, 253, 8, 45, 171, 52, 418 ]; 419 const PUBLIC_PAYLOAD_USAGE_DIGEST: [u8; 32] = [ 420 36, 83, 232, 46, 105, 160, 105, 237, 4, 189, 147, 119, 164, 56, 77, 154, 100, 122, 111, 62, 421 246, 160, 0, 206, 10, 136, 76, 189, 81, 68, 10, 26, 422 ]; 423 const VERSION_THREE_DIGEST: [u8; 32] = [ 424 53, 128, 250, 106, 143, 97, 51, 33, 195, 227, 254, 35, 19, 135, 136, 171, 229, 241, 204, 234, 425 158, 180, 204, 19, 113, 95, 73, 250, 135, 241, 51, 88, 426 ]; 427 const AVAILABILITY_EVIDENCE_V3_MIGRATION_CHECKSUM: MigrationChecksum = 428 MigrationChecksum::from_bytes([ 429 254, 36, 47, 105, 27, 136, 68, 240, 103, 108, 27, 205, 97, 203, 221, 84, 234, 64, 5, 123, 430 229, 227, 10, 199, 30, 127, 170, 170, 11, 2, 78, 81, 431 ]); 432 433 /// A sealed binding between one HarvestCircle runtime context and the governed state catalogs. 434 /// 435 /// External callers cannot forge alternate paths or catalogs: 436 /// 437 /// ```compile_fail 438 /// use harvestcircle_storage::HarvestCircleStorageContract; 439 /// 440 /// let _ = HarvestCircleStorageContract { 441 /// paths: todo!(), 442 /// migrations: todo!(), 443 /// schema: todo!(), 444 /// }; 445 /// ``` 446 #[derive(Clone, PartialEq, Eq)] 447 pub struct HarvestCircleStorageContract { 448 paths: ServiceSqlitePaths, 449 migrations: MigrationCatalog, 450 schema: SchemaCatalog, 451 } 452 453 impl HarvestCircleStorageContract { 454 /// Binds the exact HarvestCircle service and desktop instance to canonical paths. 455 pub fn from_runtime_context( 456 context: &RuntimeContext, 457 ) -> Result<Self, HarvestCircleStorageContractError> { 458 if context.service().as_str() != HARVESTCIRCLE_SERVICE_ID 459 || context.instance().as_str() != HARVESTCIRCLE_INSTANCE_ID 460 { 461 return Err(HarvestCircleStorageContractError::ContextIdentity); 462 } 463 let paths = ServiceSqlitePaths::from_runtime_context(context) 464 .map_err(|_| HarvestCircleStorageContractError::CanonicalPaths)?; 465 let migrations = harvestcircle_migration_catalog()?; 466 let schema = schema_catalog_for(&migrations)?; 467 Ok(Self { 468 paths, 469 migrations, 470 schema, 471 }) 472 } 473 474 #[must_use] 475 pub const fn paths(&self) -> &ServiceSqlitePaths { 476 &self.paths 477 } 478 479 #[must_use] 480 pub const fn migrations(&self) -> &MigrationCatalog { 481 &self.migrations 482 } 483 484 #[must_use] 485 pub const fn schema(&self) -> &SchemaCatalog { 486 &self.schema 487 } 488 489 #[must_use] 490 pub fn application_id(&self) -> ServiceSqliteApplicationId { 491 ServiceSqliteApplicationId::new(HARVESTCIRCLE_APPLICATION_ID) 492 .expect("HCR1 is a valid SQLite application ID") 493 } 494 495 #[must_use] 496 pub const fn state_schema_version(&self) -> NonZeroU32 { 497 NonZeroU32::new(HARVESTCIRCLE_STATE_SCHEMA_VERSION) 498 .expect("HarvestCircle current schema version is nonzero") 499 } 500 } 501 502 impl fmt::Debug for HarvestCircleStorageContract { 503 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 504 formatter 505 .debug_struct("HarvestCircleStorageContract") 506 .field("service", &HARVESTCIRCLE_SERVICE_ID) 507 .field("instance", &HARVESTCIRCLE_INSTANCE_ID) 508 .field("paths", &"[redacted]") 509 .field("schema_version", &HARVESTCIRCLE_STATE_SCHEMA_VERSION) 510 .finish() 511 } 512 } 513 514 /// Stable, path-free storage-contract construction failure. 515 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 516 pub enum HarvestCircleStorageContractError { 517 ContextIdentity, 518 CanonicalPaths, 519 MigrationCatalog, 520 SchemaCatalog, 521 } 522 523 impl fmt::Display for HarvestCircleStorageContractError { 524 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 525 formatter.write_str(match self { 526 Self::ContextIdentity => "HarvestCircle storage context identity is invalid", 527 Self::CanonicalPaths => "HarvestCircle storage paths are invalid", 528 Self::MigrationCatalog => "HarvestCircle migration catalog is invalid", 529 Self::SchemaCatalog => "HarvestCircle schema catalog is invalid", 530 }) 531 } 532 } 533 534 impl Error for HarvestCircleStorageContractError {} 535 536 #[must_use] 537 pub(crate) const fn harvestcircle_initial_schema_sql() -> &'static [&'static str] { 538 &INITIAL_SCHEMA_SQL 539 } 540 541 pub fn harvestcircle_migration_catalog() 542 -> Result<MigrationCatalog, HarvestCircleStorageContractError> { 543 let migration = MigrationDescriptor::sql( 544 2, 545 "bound_durable_operation_receipts", 546 MIGRATE_DURABLE_OPERATIONS_V2_SQL, 547 DURABLE_OPERATIONS_V2_MIGRATION_CHECKSUM, 548 ) 549 .map_err(|_| HarvestCircleStorageContractError::MigrationCatalog)?; 550 let evidence = MigrationDescriptor::sql( 551 3, 552 "add_verified_listing_evidence", 553 MIGRATE_AVAILABILITY_EVIDENCE_V3_SQL, 554 AVAILABILITY_EVIDENCE_V3_MIGRATION_CHECKSUM, 555 ) 556 .map_err(|_| HarvestCircleStorageContractError::MigrationCatalog)?; 557 MigrationCatalog::new([migration, evidence]) 558 .map_err(|_| HarvestCircleStorageContractError::MigrationCatalog) 559 } 560 561 pub fn harvestcircle_schema_catalog() -> Result<SchemaCatalog, HarvestCircleStorageContractError> { 562 let migrations = harvestcircle_migration_catalog()?; 563 schema_catalog_for(&migrations) 564 } 565 566 fn schema_catalog_for( 567 migrations: &MigrationCatalog, 568 ) -> Result<SchemaCatalog, HarvestCircleStorageContractError> { 569 let version_one = SchemaVersionCatalog::new( 570 HARVESTCIRCLE_INITIAL_STATE_SCHEMA_VERSION, 571 schema_objects(CREATE_DURABLE_OPERATIONS_SQL)?, 572 SchemaDigest::from_bytes(VERSION_ONE_DIGEST), 573 ) 574 .map_err(schema_error)?; 575 let version_two_objects = schema_objects(CREATE_DURABLE_OPERATIONS_V2_SQL)?; 576 let version_two = SchemaVersionCatalog::new( 577 2, 578 version_two_objects, 579 SchemaDigest::from_bytes(VERSION_TWO_DIGEST), 580 ) 581 .map_err(schema_error)?; 582 let version_three = SchemaVersionCatalog::new( 583 3, 584 schema_objects_v3()?, 585 SchemaDigest::from_bytes(VERSION_THREE_DIGEST), 586 ) 587 .map_err(schema_error)?; 588 SchemaCatalog::new(migrations, [version_one, version_two, version_three]).map_err(schema_error) 589 } 590 591 fn schema_objects_v3() -> Result<Vec<SchemaObject>, HarvestCircleStorageContractError> { 592 let mut objects = schema_objects(CREATE_DURABLE_OPERATIONS_V2_SQL)?; 593 let mut statements = MIGRATE_AVAILABILITY_EVIDENCE_V3_SQL.split(";\n").skip(1); 594 for (name, digest) in [ 595 ("availability_versions", AVAILABILITY_VERSIONS_DIGEST), 596 ("public_payload_usage", PUBLIC_PAYLOAD_USAGE_DIGEST), 597 ] { 598 let sql = statements 599 .next() 600 .ok_or(HarvestCircleStorageContractError::SchemaCatalog)?; 601 objects.push( 602 SchemaObject::new( 603 SchemaObjectKind::Table, 604 name, 605 name, 606 sql, 607 SchemaDigest::from_bytes(digest), 608 ) 609 .map_err(schema_error)?, 610 ); 611 } 612 Ok(objects) 613 } 614 615 fn schema_objects( 616 durable_operations_sql: &'static str, 617 ) -> Result<Vec<SchemaObject>, HarvestCircleStorageContractError> { 618 let identities = [ 619 ( 620 SchemaObjectKind::Table, 621 "account_identities", 622 "account_identities", 623 ), 624 ( 625 SchemaObjectKind::Table, 626 "local_signer_bindings", 627 "local_signer_bindings", 628 ), 629 (SchemaObjectKind::Table, "runtime_state", "runtime_state"), 630 (SchemaObjectKind::Table, "profile_cache", "profile_cache"), 631 ( 632 SchemaObjectKind::Table, 633 "account_preferences", 634 "account_preferences", 635 ), 636 ( 637 SchemaObjectKind::Table, 638 "durable_operations", 639 "durable_operations", 640 ), 641 ( 642 SchemaObjectKind::Table, 643 "installation_identity", 644 "installation_identity", 645 ), 646 ( 647 SchemaObjectKind::Trigger, 648 "installation_identity_no_update", 649 "installation_identity", 650 ), 651 ( 652 SchemaObjectKind::Trigger, 653 "installation_identity_no_delete", 654 "installation_identity", 655 ), 656 ]; 657 let schema_sql = [ 658 CREATE_ACCOUNT_IDENTITIES_SQL, 659 CREATE_LOCAL_SIGNER_BINDINGS_SQL, 660 CREATE_RUNTIME_STATE_SQL, 661 CREATE_PROFILE_CACHE_SQL, 662 CREATE_ACCOUNT_PREFERENCES_SQL, 663 durable_operations_sql, 664 CREATE_INSTALLATION_IDENTITY_SQL, 665 CREATE_INSTALLATION_IDENTITY_NO_UPDATE_SQL, 666 CREATE_INSTALLATION_IDENTITY_NO_DELETE_SQL, 667 ]; 668 let mut objects = identities 669 .into_iter() 670 .zip(schema_sql) 671 .zip(OBJECT_DIGESTS) 672 .map(|(((kind, name, table), sql), digest)| { 673 let digest = if sql == CREATE_DURABLE_OPERATIONS_V2_SQL { 674 SchemaDigest::from_bytes(DURABLE_OPERATIONS_V2_DIGEST) 675 } else { 676 SchemaDigest::from_bytes(digest) 677 }; 678 SchemaObject::new(kind, name, table, sql, digest).map_err(schema_error) 679 }) 680 .collect::<Result<Vec<_>, _>>()?; 681 if durable_operations_sql == CREATE_DURABLE_OPERATIONS_V2_SQL { 682 for (name, sql, digest) in [ 683 ( 684 "durable_operations_receipt_insert_guard", 685 CREATE_DURABLE_OPERATIONS_RECEIPT_INSERT_GUARD_SQL, 686 DURABLE_OPERATIONS_RECEIPT_INSERT_GUARD_DIGEST, 687 ), 688 ( 689 "durable_operations_receipt_update_guard", 690 CREATE_DURABLE_OPERATIONS_RECEIPT_UPDATE_GUARD_SQL, 691 DURABLE_OPERATIONS_RECEIPT_UPDATE_GUARD_DIGEST, 692 ), 693 ] { 694 objects.push( 695 SchemaObject::new( 696 SchemaObjectKind::Trigger, 697 name, 698 "durable_operations", 699 sql, 700 SchemaDigest::from_bytes(digest), 701 ) 702 .map_err(schema_error)?, 703 ); 704 } 705 } 706 Ok(objects) 707 } 708 709 const fn schema_error(_: SchemaCatalogContractError) -> HarvestCircleStorageContractError { 710 HarvestCircleStorageContractError::SchemaCatalog 711 } 712 713 #[cfg(test)] 714 #[path = "availability_evidence_tests.rs"] 715 mod availability_evidence_tests; 716 717 #[cfg(test)] 718 mod tests { 719 use super::*; 720 use radroots_runtime_paths::{ 721 InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, 722 RadrootsPlatform, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, 723 }; 724 use sqlx::{Connection, Row}; 725 726 fn context(service: &str, instance: &str) -> RuntimeContext { 727 RuntimeContext::resolve( 728 &RadrootsPathResolver::new(RadrootsPlatform::Macos, RadrootsHostEnvironment::default()), 729 RuntimeContextBootstrap::new( 730 RadrootsPathProfile::RepoLocal, 731 Some(std::path::PathBuf::from("/tmp/harvestcircle-contract")), 732 RuntimeContextSource::BootstrapCli, 733 RuntimeContextSource::BootstrapCli, 734 ) 735 .expect("bootstrap"), 736 ServiceId::new(service).expect("service"), 737 InstanceId::new(instance).expect("instance"), 738 ) 739 .expect("context") 740 } 741 742 #[test] 743 fn exact_context_paths_and_catalogs_are_sealed() { 744 let contract = HarvestCircleStorageContract::from_runtime_context(&context( 745 HARVESTCIRCLE_SERVICE_ID, 746 HARVESTCIRCLE_INSTANCE_ID, 747 )) 748 .expect("contract"); 749 assert!(contract.paths().state_database().ends_with("state.sqlite")); 750 assert!(contract.paths().state_lock().ends_with("state.lock")); 751 assert_eq!( 752 contract.application_id().get(), 753 HARVESTCIRCLE_APPLICATION_ID 754 ); 755 assert_eq!(contract.state_schema_version().get(), 3); 756 assert_eq!(contract.migrations().current_version(), 3); 757 assert_eq!(contract.migrations().descriptors().len(), 2); 758 assert_eq!(contract.schema().versions().len(), 3); 759 assert_eq!(harvestcircle_initial_schema_sql().len(), 9); 760 } 761 762 #[test] 763 fn context_identity_and_public_diagnostics_fail_closed() { 764 for invalid in [ 765 context("myc", "desktop"), 766 context("harvestcircle", "primary"), 767 ] { 768 assert_eq!( 769 HarvestCircleStorageContract::from_runtime_context(&invalid), 770 Err(HarvestCircleStorageContractError::ContextIdentity) 771 ); 772 } 773 for error in [ 774 HarvestCircleStorageContractError::ContextIdentity, 775 HarvestCircleStorageContractError::CanonicalPaths, 776 HarvestCircleStorageContractError::MigrationCatalog, 777 HarvestCircleStorageContractError::SchemaCatalog, 778 ] { 779 assert!(error.source().is_none()); 780 assert!(!error.to_string().contains("/tmp")); 781 } 782 let debug = format!( 783 "{:?}", 784 HarvestCircleStorageContract::from_runtime_context(&context( 785 HARVESTCIRCLE_SERVICE_ID, 786 HARVESTCIRCLE_INSTANCE_ID, 787 )) 788 .unwrap() 789 ); 790 assert!(debug.contains("[redacted]")); 791 assert!(!debug.contains("/tmp")); 792 } 793 794 #[test] 795 fn machine_coordinates_match_the_typed_contract() { 796 assert_eq!( 797 harvestcircle_product::STORAGE_SERVICE_ID, 798 HARVESTCIRCLE_SERVICE_ID 799 ); 800 assert_eq!( 801 harvestcircle_product::STORAGE_INSTANCE_ID, 802 HARVESTCIRCLE_INSTANCE_ID 803 ); 804 assert_eq!( 805 harvestcircle_product::STORAGE_DATABASE_FILENAME, 806 "state.sqlite" 807 ); 808 assert_eq!(harvestcircle_product::STORAGE_LOCK_FILENAME, "state.lock"); 809 assert_eq!( 810 harvestcircle_product::STORAGE_APPLICATION_ID, 811 HARVESTCIRCLE_APPLICATION_ID.to_string() 812 ); 813 assert_eq!(harvestcircle_product::STORAGE_APPLICATION_ID_TEXT, "HCR1"); 814 assert_eq!( 815 harvestcircle_product::STORAGE_INITIAL_SCHEMA_VERSION, 816 HARVESTCIRCLE_INITIAL_STATE_SCHEMA_VERSION.to_string() 817 ); 818 assert_eq!( 819 harvestcircle_product::LEGACY_DATABASE_FILENAME, 820 "harvestcircle.sqlite3" 821 ); 822 assert_eq!( 823 harvestcircle_product::LEGACY_DATABASE_DISPOSITION, 824 "untouched_and_unsupported" 825 ); 826 assert_eq!( 827 harvestcircle_product::PLATFORM_MACOS_ARCHITECTURE, 828 "aarch64" 829 ); 830 assert_eq!(harvestcircle_product::PLATFORM_LINUX_ARCHITECTURE, "x86_64"); 831 assert_eq!( 832 harvestcircle_product::LIMIT_IDENTITIES, 833 HARVESTCIRCLE_IDENTITY_CAPACITY.to_string() 834 ); 835 assert_eq!( 836 harvestcircle_product::LIMIT_UNFINISHED_DURABLE_OPERATIONS, 837 HARVESTCIRCLE_UNFINISHED_DURABLE_OPERATION_CAPACITY.to_string() 838 ); 839 assert_eq!( 840 harvestcircle_product::LIMIT_PREFERENCE_VALUE_UTF8_BYTES, 841 HARVESTCIRCLE_PREFERENCE_VALUE_UTF8_BYTES.to_string() 842 ); 843 assert_eq!( 844 harvestcircle_product::LIMIT_RELAY_ENDPOINTS, 845 HARVESTCIRCLE_RELAY_ENDPOINT_CAPACITY.to_string() 846 ); 847 assert_eq!( 848 harvestcircle_product::LIMIT_RELAY_URL_BYTES, 849 HARVESTCIRCLE_RELAY_URL_UTF8_BYTES.to_string() 850 ); 851 assert_eq!( 852 harvestcircle_product::LIMIT_EVENTS_PER_RELAY, 853 HARVESTCIRCLE_EVENTS_PER_RELAY_CAPACITY.to_string() 854 ); 855 assert_eq!( 856 harvestcircle_product::LIMIT_EVENTS_TOTAL, 857 HARVESTCIRCLE_EVENTS_TOTAL_CAPACITY.to_string() 858 ); 859 assert_eq!( 860 harvestcircle_product::LIMIT_OBSERVERS, 861 HARVESTCIRCLE_OBSERVER_CAPACITY.to_string() 862 ); 863 assert_eq!( 864 harvestcircle_product::LIMIT_ACTOR_MAILBOX, 865 HARVESTCIRCLE_ACTOR_MAILBOX_CAPACITY.to_string() 866 ); 867 assert_eq!( 868 harvestcircle_product::LIMIT_COMMAND_DEADLINE_MIN_MS, 869 HARVESTCIRCLE_COMMAND_DEADLINE_MIN_MS.to_string() 870 ); 871 assert_eq!( 872 harvestcircle_product::LIMIT_COMMAND_DEADLINE_MAX_MS, 873 HARVESTCIRCLE_COMMAND_DEADLINE_MAX_MS.to_string() 874 ); 875 assert_eq!( 876 harvestcircle_product::BACKUP_MEMBER_LIMIT, 877 "caller_supplied_positive" 878 ); 879 } 880 881 #[tokio::test] 882 async fn schema_sql_executes_as_one_fresh_strict_v1_inventory() { 883 let mut connection = sqlx::SqliteConnection::connect(":memory:") 884 .await 885 .expect("memory database"); 886 sqlx::query("PRAGMA foreign_keys = ON") 887 .execute(&mut connection) 888 .await 889 .expect("foreign keys"); 890 sqlx::query("PRAGMA trusted_schema = OFF") 891 .execute(&mut connection) 892 .await 893 .expect("trusted schema"); 894 for statement in harvestcircle_initial_schema_sql() { 895 sqlx::query(*statement) 896 .execute(&mut connection) 897 .await 898 .expect("schema statement"); 899 } 900 let rows = sqlx::query( 901 "SELECT type, name, tbl_name FROM sqlite_schema \ 902 WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name LIMIT 10", 903 ) 904 .fetch_all(&mut connection) 905 .await 906 .expect("inventory rows"); 907 let inventory = rows 908 .iter() 909 .map(|row| { 910 ( 911 row.get::<String, _>("type"), 912 row.get::<String, _>("name"), 913 row.get::<String, _>("tbl_name"), 914 ) 915 }) 916 .collect::<Vec<_>>(); 917 assert_eq!(inventory.len(), 9); 918 assert_eq!( 919 inventory 920 .iter() 921 .filter(|(kind, _, _)| kind == "table") 922 .count(), 923 7 924 ); 925 assert_eq!( 926 inventory 927 .iter() 928 .filter(|(kind, _, _)| kind == "trigger") 929 .count(), 930 2 931 ); 932 assert!(inventory.iter().all(|(_, name, _)| { 933 !matches!( 934 name.as_str(), 935 "application_schema" | "operation_journal" | "refinery_schema_history" 936 ) 937 })); 938 } 939 940 #[tokio::test] 941 async fn migration_v2_adds_the_exact_bounded_journal_schema() { 942 let mut connection = sqlx::SqliteConnection::connect(":memory:") 943 .await 944 .expect("memory database"); 945 sqlx::query("PRAGMA foreign_keys = ON") 946 .execute(&mut connection) 947 .await 948 .expect("foreign keys"); 949 sqlx::query("PRAGMA trusted_schema = OFF") 950 .execute(&mut connection) 951 .await 952 .expect("trusted schema"); 953 for statement in harvestcircle_initial_schema_sql() { 954 sqlx::query(*statement) 955 .execute(&mut connection) 956 .await 957 .expect("schema statement"); 958 } 959 let identity = [7_u8; 32]; 960 sqlx::query( 961 "INSERT INTO durable_operations (request_id, operation_kind, account_public_key, \ 962 binding_public_key, phase, terminal_outcome, updated_at_unix_s) \ 963 VALUES ('01890f3e-7b1c-7000-8000-000000000001', 'create', ?, ?, \ 964 'finalized', 'completed', 10)", 965 ) 966 .bind(identity.as_slice()) 967 .bind(identity.as_slice()) 968 .execute(&mut connection) 969 .await 970 .expect("terminal v1 row"); 971 sqlx::query( 972 "INSERT INTO durable_operations (request_id, operation_kind, account_public_key, \ 973 binding_public_key, phase, updated_at_unix_s) \ 974 VALUES ('01890f3e-7b1c-7000-8000-000000000002', 'remove', ?, ?, \ 975 'intent_recorded', 11)", 976 ) 977 .bind(identity.as_slice()) 978 .bind(identity.as_slice()) 979 .execute(&mut connection) 980 .await 981 .expect("unfinished v1 row"); 982 sqlx::raw_sql(MIGRATE_DURABLE_OPERATIONS_V2_SQL) 983 .execute(&mut connection) 984 .await 985 .expect("migration"); 986 let actual: String = sqlx::query_scalar( 987 "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'durable_operations'", 988 ) 989 .fetch_one(&mut connection) 990 .await 991 .expect("schema SQL"); 992 assert_eq!(actual, CREATE_DURABLE_OPERATIONS_V2_SQL); 993 let rows = sqlx::query( 994 "SELECT request_id, completed_at_unix_s FROM durable_operations ORDER BY request_id", 995 ) 996 .fetch_all(&mut connection) 997 .await 998 .expect("migrated rows"); 999 assert_eq!(rows.len(), 2); 1000 assert_eq!( 1001 rows[0].get::<Option<i64>, _>("completed_at_unix_s"), 1002 Some(10) 1003 ); 1004 assert_eq!(rows[1].get::<Option<i64>, _>("completed_at_unix_s"), None); 1005 assert!( 1006 sqlx::query( 1007 "INSERT INTO durable_operations (request_id, operation_kind, account_public_key, \ 1008 binding_public_key, phase, terminal_outcome, updated_at_unix_s) \ 1009 VALUES ('01890f3e-7b1c-7000-8000-000000000004', 'create', ?, ?, \ 1010 'finalized', 'completed', 13)", 1011 ) 1012 .bind(identity.as_slice()) 1013 .bind(identity.as_slice()) 1014 .execute(&mut connection) 1015 .await 1016 .is_err(), 1017 "insert guard must require terminal completion time" 1018 ); 1019 assert!( 1020 sqlx::query( 1021 "UPDATE durable_operations SET phase = 'finalized', \ 1022 terminal_outcome = 'completed' WHERE request_id = \ 1023 '01890f3e-7b1c-7000-8000-000000000002'", 1024 ) 1025 .execute(&mut connection) 1026 .await 1027 .is_err(), 1028 "update guard must require terminal completion time" 1029 ); 1030 1031 let migration = MigrationChecksum::for_sql(MIGRATE_DURABLE_OPERATIONS_V2_SQL); 1032 let objects = schema_objects(CREATE_DURABLE_OPERATIONS_V2_SQL).expect("objects"); 1033 let object = objects 1034 .iter() 1035 .find(|object| object.name() == "durable_operations") 1036 .expect("durable operations") 1037 .digest(); 1038 let snapshot = SchemaVersionCatalog::computed_digest(2, objects).expect("snapshot"); 1039 assert_eq!(migration, DURABLE_OPERATIONS_V2_MIGRATION_CHECKSUM); 1040 assert_eq!( 1041 object, 1042 SchemaDigest::from_bytes(DURABLE_OPERATIONS_V2_DIGEST) 1043 ); 1044 assert_eq!(snapshot, SchemaDigest::from_bytes(VERSION_TWO_DIGEST)); 1045 } 1046 1047 #[tokio::test] 1048 async fn migration_v2_rolls_back_without_partial_schema_on_invalid_v1_state() { 1049 let mut connection = sqlx::SqliteConnection::connect(":memory:") 1050 .await 1051 .expect("memory database"); 1052 for statement in harvestcircle_initial_schema_sql() { 1053 sqlx::query(*statement) 1054 .execute(&mut connection) 1055 .await 1056 .expect("schema statement"); 1057 } 1058 sqlx::query("PRAGMA ignore_check_constraints = ON") 1059 .execute(&mut connection) 1060 .await 1061 .expect("fixture policy"); 1062 let identity = [9_u8; 32]; 1063 sqlx::query( 1064 "INSERT INTO durable_operations (request_id, operation_kind, account_public_key, \ 1065 binding_public_key, phase, updated_at_unix_s) \ 1066 VALUES ('01890f3e-7b1c-7000-8000-000000000003', 'create', ?, ?, 'finalized', 12)", 1067 ) 1068 .bind(identity.as_slice()) 1069 .bind(identity.as_slice()) 1070 .execute(&mut connection) 1071 .await 1072 .expect("invalid v1 fixture"); 1073 sqlx::query("PRAGMA ignore_check_constraints = OFF") 1074 .execute(&mut connection) 1075 .await 1076 .expect("restore policy"); 1077 1078 let mut transaction = connection.begin().await.expect("migration transaction"); 1079 assert!( 1080 sqlx::raw_sql(MIGRATE_DURABLE_OPERATIONS_V2_SQL) 1081 .execute(&mut *transaction) 1082 .await 1083 .is_err() 1084 ); 1085 transaction.rollback().await.expect("rollback"); 1086 1087 let columns: i64 = sqlx::query_scalar( 1088 "SELECT count(*) FROM pragma_table_info('durable_operations') \ 1089 WHERE name = 'completed_at_unix_s'", 1090 ) 1091 .fetch_one(&mut connection) 1092 .await 1093 .expect("column inventory"); 1094 let retained: i64 = sqlx::query_scalar("SELECT count(*) FROM durable_operations") 1095 .fetch_one(&mut connection) 1096 .await 1097 .expect("retained row"); 1098 assert_eq!(columns, 0); 1099 assert_eq!(retained, 1); 1100 } 1101 }