lib.rs (48586B)
1 #![forbid(unsafe_code)] 2 3 mod error; 4 mod interop; 5 mod migration_audit; 6 mod migrations; 7 mod repo; 8 mod sdk_migration_receipts; 9 mod sync; 10 11 use std::{collections::BTreeSet, fs, path::PathBuf, time::Duration}; 12 13 use radroots_app_sync::{ 14 AppRelayIngestScopeFreshness, PendingSyncOperation, SyncCheckpointStatus, SyncConflict, 15 SyncConflictResolutionStatus, 16 }; 17 use radroots_app_view::{ 18 AccountSurfaceActivationProjection, AppActivityContext, AppActivityEvent, AppActivityKind, 19 BuyerCartProjection, BuyerContext, BuyerListingsProjection, BuyerOrderDetailProjection, 20 BuyerOrderReviewDraft, BuyerOrderReviewProjection, BuyerOrdersProjection, 21 BuyerProductDetailProjection, FarmId, FarmOrderMethod, FarmRulesProjection, 22 FarmSetupProjection, FarmSummary, FulfillmentWindowId, OrderDetailProjection, OrderId, 23 OrdersListProjection, OrdersScreenQueryState, PackDayOutputSource, PackDayProjection, 24 PackDayScreenQueryState, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, 25 ProductsListProjection, ProductsSort, ReminderFeedProjection, ReminderLogEntryProjection, 26 ReminderLogProjection, TodayAgendaProjection, 27 }; 28 use rusqlite::Connection; 29 30 pub use error::AppSqliteError; 31 pub use interop::{ 32 AppLocalInteropImportReport, AppLocalInteropRepository, StoredLocalInteropRecord, 33 projected_order_id_from_trade_request, 34 }; 35 pub use migration_audit::{ 36 APP_SDK_MIGRATION_AUDIT_DEFAULT_BATCH_SIZE, APP_SDK_MIGRATION_AUDIT_MAX_BATCH_SIZE, 37 AppSdkMigrationAuditClassification, AppSdkMigrationAuditCount, 38 AppSdkMigrationAuditDuplicateCandidate, AppSdkMigrationAuditIssue, AppSdkMigrationAuditReport, 39 AppSdkMigrationAuditRequest, AppSdkMigrationAuditSource, AppSdkMigrationAuditSourceReport, 40 }; 41 pub use migrations::latest_schema_version; 42 pub use repo::{ 43 APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivationRepository, 44 AppActivityRepository, AppBuyerRepository, AppFarmRulesRepository, AppFarmSetupRepository, 45 AppOrdersRepository, AppProductsRepository, AppRemindersRepository, AppTodayAgendaRepository, 46 BuyerOrderCoordinationRecord, BuyerOrderCoordinationState, BuyerOrderLocalEventExport, 47 BuyerOrderLocalEventLine, BuyerRepeatDemandApplyOutcome, SelectedBuyerOrderScope, 48 SellerOrderDecisionExport, SellerOrderDecisionLineExport, TODAY_AGENDA_LIST_LIMIT, 49 TODAY_AGENDA_LOW_STOCK_THRESHOLD, derive_farm_rules_readiness, 50 }; 51 pub use sdk_migration_receipts::{ 52 AppSdkMigrationReceipt, AppSdkMigrationReceiptInput, AppSdkMigrationReceiptRepository, 53 AppSdkMigrationReceiptSourceKind, AppSdkMigrationState, 54 }; 55 pub use sync::{ 56 AppSyncRepository, StoredPendingSyncOperation, StoredRelayIngestCursor, StoredSyncConflict, 57 }; 58 59 const SQLITE_BUSY_TIMEOUT_MS: u64 = 5_000; 60 61 #[derive(Clone, Debug, Eq, PartialEq)] 62 pub enum DatabaseTarget { 63 InMemory, 64 Path(PathBuf), 65 } 66 67 pub struct AppSqliteStore { 68 connection: Connection, 69 } 70 71 impl AppSqliteStore { 72 pub fn open(target: DatabaseTarget) -> Result<Self, AppSqliteError> { 73 let mut connection = open_connection(&target)?; 74 bootstrap_connection(&mut connection, &target)?; 75 76 Ok(Self { connection }) 77 } 78 79 pub fn connection(&self) -> &Connection { 80 &self.connection 81 } 82 83 pub fn into_connection(self) -> Connection { 84 self.connection 85 } 86 87 pub fn schema_version(&self) -> Result<u32, AppSqliteError> { 88 schema_version(&self.connection) 89 } 90 91 pub fn today_agenda_repository(&self) -> AppTodayAgendaRepository<'_> { 92 AppTodayAgendaRepository::new(&self.connection) 93 } 94 95 pub fn activity_repository(&self) -> AppActivityRepository<'_> { 96 AppActivityRepository::new(&self.connection) 97 } 98 99 pub fn activation_repository(&self) -> AppActivationRepository<'_> { 100 AppActivationRepository::new(&self.connection) 101 } 102 103 pub fn farm_setup_repository(&self) -> AppFarmSetupRepository<'_> { 104 AppFarmSetupRepository::new(&self.connection) 105 } 106 107 pub fn farm_rules_repository(&self) -> AppFarmRulesRepository<'_> { 108 AppFarmRulesRepository::new(&self.connection) 109 } 110 111 pub fn buyer_repository(&self) -> AppBuyerRepository<'_> { 112 AppBuyerRepository::new(&self.connection) 113 } 114 115 pub fn products_repository(&self) -> AppProductsRepository<'_> { 116 AppProductsRepository::new(&self.connection) 117 } 118 119 pub fn orders_repository(&self) -> AppOrdersRepository<'_> { 120 AppOrdersRepository::new(&self.connection) 121 } 122 123 pub fn sync_repository(&self) -> AppSyncRepository<'_> { 124 AppSyncRepository::new(&self.connection) 125 } 126 127 pub fn sdk_migration_receipt_repository(&self) -> AppSdkMigrationReceiptRepository<'_> { 128 AppSdkMigrationReceiptRepository::new(&self.connection) 129 } 130 131 pub fn reminders_repository(&self) -> AppRemindersRepository<'_> { 132 AppRemindersRepository::new(&self.connection) 133 } 134 135 pub fn load_today_agenda( 136 &self, 137 farm_id: Option<FarmId>, 138 ) -> Result<TodayAgendaProjection, AppSqliteError> { 139 self.today_agenda_repository().load(farm_id) 140 } 141 142 pub fn save_farm_summary(&self, farm: &FarmSummary) -> Result<(), AppSqliteError> { 143 self.today_agenda_repository().save_farm_summary(farm) 144 } 145 146 pub fn record_activity_event(&self, kind: &AppActivityKind) -> Result<(), AppSqliteError> { 147 self.activity_repository().record(kind) 148 } 149 150 pub fn load_recent_activity_events( 151 &self, 152 limit: usize, 153 ) -> Result<Vec<AppActivityEvent>, AppSqliteError> { 154 self.activity_repository().load_recent(limit) 155 } 156 157 pub fn load_activity_context( 158 &self, 159 limit: usize, 160 ) -> Result<AppActivityContext, AppSqliteError> { 161 self.activity_repository().load_context(limit) 162 } 163 164 pub fn load_surface_activation( 165 &self, 166 account_id: &str, 167 ) -> Result<Option<AccountSurfaceActivationProjection>, AppSqliteError> { 168 self.activation_repository() 169 .load_surface_activation(account_id) 170 } 171 172 pub fn save_surface_activation( 173 &self, 174 projection: &AccountSurfaceActivationProjection, 175 ) -> Result<(), AppSqliteError> { 176 self.activation_repository() 177 .save_surface_activation(projection) 178 } 179 180 pub fn clear_surface_activation(&self, account_id: &str) -> Result<(), AppSqliteError> { 181 self.activation_repository() 182 .clear_surface_activation(account_id) 183 } 184 185 pub fn load_farm_setup(&self, account_id: &str) -> Result<FarmSetupProjection, AppSqliteError> { 186 self.farm_setup_repository().load_farm_setup(account_id) 187 } 188 189 pub fn save_farm_setup( 190 &self, 191 account_id: &str, 192 projection: &FarmSetupProjection, 193 ) -> Result<(), AppSqliteError> { 194 self.farm_setup_repository() 195 .save_farm_setup(account_id, projection) 196 } 197 198 pub fn clear_farm_setup(&self, account_id: &str) -> Result<(), AppSqliteError> { 199 self.farm_setup_repository().clear_farm_setup(account_id) 200 } 201 202 pub fn load_farm_rules(&self, farm_id: FarmId) -> Result<FarmRulesProjection, AppSqliteError> { 203 self.farm_rules_repository().load_farm_rules(farm_id) 204 } 205 206 pub fn save_farm_rules(&self, projection: &FarmRulesProjection) -> Result<(), AppSqliteError> { 207 self.farm_rules_repository().save_farm_rules(projection) 208 } 209 210 pub fn load_products( 211 &self, 212 farm_id: FarmId, 213 search_query: &str, 214 filter: ProductsFilter, 215 sort: ProductsSort, 216 ) -> Result<ProductsListProjection, AppSqliteError> { 217 self.products_repository() 218 .load_products(farm_id, search_query, filter, sort) 219 } 220 221 pub fn load_product_editor_draft( 222 &self, 223 product_id: ProductId, 224 ) -> Result<Option<ProductEditorDraft>, AppSqliteError> { 225 self.products_repository() 226 .load_product_editor_draft(product_id) 227 } 228 229 pub fn create_product_draft(&self, farm_id: FarmId) -> Result<ProductId, AppSqliteError> { 230 self.products_repository().create_product_draft(farm_id) 231 } 232 233 pub fn load_orders_list( 234 &self, 235 farm_id: FarmId, 236 query: &OrdersScreenQueryState, 237 ) -> Result<OrdersListProjection, AppSqliteError> { 238 self.orders_repository().load_orders_list(farm_id, query) 239 } 240 241 pub fn load_order_detail( 242 &self, 243 farm_id: FarmId, 244 order_id: OrderId, 245 ) -> Result<Option<OrderDetailProjection>, AppSqliteError> { 246 self.orders_repository() 247 .load_order_detail(farm_id, order_id) 248 } 249 250 pub fn load_seller_order_decision_export( 251 &self, 252 farm_id: FarmId, 253 order_id: OrderId, 254 ) -> Result<Option<SellerOrderDecisionExport>, AppSqliteError> { 255 self.orders_repository() 256 .load_seller_order_decision_export(farm_id, order_id) 257 } 258 259 pub fn load_pack_day( 260 &self, 261 farm_id: FarmId, 262 query: &PackDayScreenQueryState, 263 ) -> Result<PackDayProjection, AppSqliteError> { 264 self.orders_repository().load_pack_day(farm_id, query) 265 } 266 267 pub fn load_pack_day_output_source( 268 &self, 269 farm_id: FarmId, 270 fulfillment_window_id: FulfillmentWindowId, 271 ) -> Result<Option<PackDayOutputSource>, AppSqliteError> { 272 self.orders_repository() 273 .load_pack_day_output_source(farm_id, fulfillment_window_id) 274 } 275 276 pub fn load_reminder_schedule( 277 &self, 278 account_id: &str, 279 farm_id: FarmId, 280 ) -> Result<ReminderFeedProjection, AppSqliteError> { 281 self.reminders_repository() 282 .load_reminder_schedule(account_id, farm_id) 283 } 284 285 pub fn replace_reminder_schedule( 286 &self, 287 account_id: &str, 288 farm_id: FarmId, 289 projection: &ReminderFeedProjection, 290 ) -> Result<(), AppSqliteError> { 291 self.reminders_repository() 292 .replace_reminder_schedule(account_id, farm_id, projection) 293 } 294 295 pub fn apply_reminder_schedule_update( 296 &self, 297 account_id: &str, 298 farm_id: FarmId, 299 projection: &ReminderFeedProjection, 300 log_entries: &[ReminderLogEntryProjection], 301 ) -> Result<(), AppSqliteError> { 302 self.reminders_repository().apply_reminder_schedule_update( 303 account_id, 304 farm_id, 305 projection, 306 log_entries, 307 ) 308 } 309 310 pub fn record_reminder_log_entry( 311 &self, 312 account_id: &str, 313 farm_id: FarmId, 314 entry: &ReminderLogEntryProjection, 315 ) -> Result<String, AppSqliteError> { 316 self.reminders_repository() 317 .record_reminder_log_entry(account_id, farm_id, entry) 318 } 319 320 pub fn load_reminder_log( 321 &self, 322 account_id: &str, 323 farm_id: FarmId, 324 limit: usize, 325 ) -> Result<ReminderLogProjection, AppSqliteError> { 326 self.reminders_repository() 327 .load_reminder_log(account_id, farm_id, limit) 328 } 329 330 pub fn save_product_editor_draft( 331 &self, 332 product_id: ProductId, 333 draft: &ProductEditorDraft, 334 ) -> Result<bool, AppSqliteError> { 335 self.products_repository() 336 .save_product_editor_draft(product_id, draft) 337 } 338 339 pub fn update_product_stock( 340 &self, 341 product_id: ProductId, 342 stock_quantity: u32, 343 ) -> Result<bool, AppSqliteError> { 344 self.products_repository() 345 .update_product_stock(product_id, stock_quantity) 346 } 347 348 pub fn evaluate_product_publish_blockers( 349 &self, 350 product_id: ProductId, 351 ) -> Result<Option<Vec<ProductPublishBlocker>>, AppSqliteError> { 352 self.products_repository() 353 .evaluate_product_publish_blockers(product_id) 354 } 355 356 pub fn load_buyer_listings( 357 &self, 358 search_query: &str, 359 fulfillment_methods: &BTreeSet<FarmOrderMethod>, 360 ) -> Result<BuyerListingsProjection, AppSqliteError> { 361 self.buyer_repository() 362 .load_buyer_listings(search_query, fulfillment_methods) 363 } 364 365 pub fn load_buyer_product_detail( 366 &self, 367 product_id: ProductId, 368 ) -> Result<Option<BuyerProductDetailProjection>, AppSqliteError> { 369 self.buyer_repository() 370 .load_buyer_product_detail(product_id) 371 } 372 373 pub fn load_buyer_cart( 374 &self, 375 context: &BuyerContext, 376 ) -> Result<BuyerCartProjection, AppSqliteError> { 377 self.buyer_repository().load_buyer_cart(context) 378 } 379 380 pub fn replace_buyer_cart( 381 &self, 382 context: &BuyerContext, 383 cart: &BuyerCartProjection, 384 ) -> Result<(), AppSqliteError> { 385 self.buyer_repository().replace_buyer_cart(context, cart) 386 } 387 388 pub fn clear_buyer_cart(&self, context: &BuyerContext) -> Result<(), AppSqliteError> { 389 self.buyer_repository().clear_buyer_cart(context) 390 } 391 392 pub fn load_buyer_order_review( 393 &self, 394 context: &BuyerContext, 395 ) -> Result<BuyerOrderReviewProjection, AppSqliteError> { 396 self.buyer_repository().load_buyer_order_review(context) 397 } 398 399 pub fn save_buyer_order_review_draft( 400 &self, 401 context: &BuyerContext, 402 draft: &BuyerOrderReviewDraft, 403 ) -> Result<(), AppSqliteError> { 404 self.buyer_repository() 405 .save_buyer_order_review_draft(context, draft) 406 } 407 408 pub fn place_buyer_order(&self, context: &BuyerContext) -> Result<OrderId, AppSqliteError> { 409 self.buyer_repository().place_buyer_order(context) 410 } 411 412 pub fn load_buyer_orders( 413 &self, 414 context: &BuyerContext, 415 ) -> Result<BuyerOrdersProjection, AppSqliteError> { 416 self.buyer_repository().load_buyer_orders(context) 417 } 418 419 pub fn load_buyer_orders_for_scope( 420 &self, 421 scope: &SelectedBuyerOrderScope, 422 ) -> Result<BuyerOrdersProjection, AppSqliteError> { 423 self.buyer_repository().load_buyer_orders_for_scope(scope) 424 } 425 426 pub fn load_buyer_order_detail( 427 &self, 428 context: &BuyerContext, 429 order_id: OrderId, 430 ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> { 431 self.buyer_repository() 432 .load_buyer_order_detail(context, order_id) 433 } 434 435 pub fn load_buyer_order_detail_for_scope( 436 &self, 437 scope: &SelectedBuyerOrderScope, 438 order_id: OrderId, 439 ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> { 440 self.buyer_repository() 441 .load_buyer_order_detail_for_scope(scope, order_id) 442 } 443 444 pub fn load_buyer_order_local_event_export( 445 &self, 446 context: &BuyerContext, 447 order_id: OrderId, 448 ) -> Result<Option<BuyerOrderLocalEventExport>, AppSqliteError> { 449 self.buyer_repository() 450 .load_buyer_order_local_event_export(context, order_id) 451 } 452 453 pub fn load_buyer_order_coordination_record( 454 &self, 455 context: &BuyerContext, 456 order_id: OrderId, 457 ) -> Result<Option<BuyerOrderCoordinationRecord>, AppSqliteError> { 458 self.buyer_repository() 459 .load_buyer_order_coordination_record(context, order_id) 460 } 461 462 pub fn load_recoverable_buyer_order_coordination_records( 463 &self, 464 context: &BuyerContext, 465 ) -> Result<Vec<BuyerOrderCoordinationRecord>, AppSqliteError> { 466 self.buyer_repository() 467 .load_recoverable_buyer_order_coordination_records(context) 468 } 469 470 pub fn buyer_order_coordination_is_synced( 471 &self, 472 context: &BuyerContext, 473 order_id: OrderId, 474 ) -> Result<bool, AppSqliteError> { 475 self.buyer_repository() 476 .buyer_order_coordination_is_synced(context, order_id) 477 } 478 479 pub fn prepare_buyer_order_coordination_attempt( 480 &self, 481 context: &BuyerContext, 482 order_id: OrderId, 483 record_id: &str, 484 payload_json: &str, 485 ) -> Result<bool, AppSqliteError> { 486 self.buyer_repository() 487 .prepare_buyer_order_coordination_attempt(context, order_id, record_id, payload_json) 488 } 489 490 pub fn mark_buyer_order_coordination_synced( 491 &self, 492 context: &BuyerContext, 493 order_id: OrderId, 494 ) -> Result<bool, AppSqliteError> { 495 self.buyer_repository() 496 .mark_buyer_order_coordination_synced(context, order_id) 497 } 498 499 pub fn mark_buyer_order_coordination_failed( 500 &self, 501 context: &BuyerContext, 502 order_id: OrderId, 503 error_message: &str, 504 ) -> Result<bool, AppSqliteError> { 505 self.buyer_repository() 506 .mark_buyer_order_coordination_failed(context, order_id, error_message) 507 } 508 509 pub fn apply_buyer_repeat_demand_to_cart( 510 &self, 511 context: &BuyerContext, 512 order_id: OrderId, 513 replace_existing: bool, 514 ) -> Result<BuyerRepeatDemandApplyOutcome, AppSqliteError> { 515 self.buyer_repository().apply_buyer_repeat_demand_to_cart( 516 context, 517 order_id, 518 replace_existing, 519 ) 520 } 521 522 pub fn apply_buyer_repeat_demand_from_scope_to_cart( 523 &self, 524 source_scope: &SelectedBuyerOrderScope, 525 cart_context: &BuyerContext, 526 order_id: OrderId, 527 replace_existing: bool, 528 ) -> Result<BuyerRepeatDemandApplyOutcome, AppSqliteError> { 529 self.buyer_repository() 530 .apply_buyer_repeat_demand_from_scope_to_cart( 531 source_scope, 532 cart_context, 533 order_id, 534 replace_existing, 535 ) 536 } 537 538 pub fn enqueue_pending_sync_operation( 539 &self, 540 account_id: &str, 541 operation: &PendingSyncOperation, 542 ) -> Result<String, AppSqliteError> { 543 self.sync_repository() 544 .enqueue_pending_operation(account_id, operation) 545 } 546 547 pub fn load_pending_sync_operations( 548 &self, 549 account_id: &str, 550 ) -> Result<Vec<StoredPendingSyncOperation>, AppSqliteError> { 551 self.sync_repository().load_pending_operations(account_id) 552 } 553 554 pub fn update_pending_sync_operation_retry( 555 &self, 556 account_id: &str, 557 operation_id: &str, 558 available_at: &str, 559 attempt_count: u32, 560 last_error_message: Option<&str>, 561 ) -> Result<bool, AppSqliteError> { 562 self.sync_repository().update_pending_operation_retry( 563 account_id, 564 operation_id, 565 available_at, 566 attempt_count, 567 last_error_message, 568 ) 569 } 570 571 pub fn dequeue_pending_sync_operation( 572 &self, 573 account_id: &str, 574 operation_id: &str, 575 ) -> Result<bool, AppSqliteError> { 576 self.sync_repository() 577 .dequeue_pending_operation(account_id, operation_id) 578 } 579 580 pub fn load_sync_checkpoint( 581 &self, 582 account_id: &str, 583 ) -> Result<SyncCheckpointStatus, AppSqliteError> { 584 self.sync_repository().load_checkpoint(account_id) 585 } 586 587 pub fn save_sync_checkpoint( 588 &self, 589 account_id: &str, 590 checkpoint: &SyncCheckpointStatus, 591 ) -> Result<(), AppSqliteError> { 592 self.sync_repository() 593 .save_checkpoint(account_id, checkpoint) 594 } 595 596 pub fn load_relay_ingest_cursors( 597 &self, 598 scope_key: &str, 599 relay_urls: &[String], 600 ) -> Result<Vec<StoredRelayIngestCursor>, AppSqliteError> { 601 self.sync_repository() 602 .load_relay_ingest_cursors(scope_key, relay_urls) 603 } 604 605 pub fn load_relay_ingest_freshness( 606 &self, 607 scope_key: &str, 608 relay_urls: &[String], 609 now_unix_seconds: i64, 610 stale_after_seconds: i64, 611 ) -> Result<AppRelayIngestScopeFreshness, AppSqliteError> { 612 self.sync_repository().load_relay_ingest_freshness( 613 scope_key, 614 relay_urls, 615 now_unix_seconds, 616 stale_after_seconds, 617 ) 618 } 619 620 pub fn record_relay_ingest_success( 621 &self, 622 scope_key: &str, 623 relay_url: &str, 624 cursor_since_unix_seconds: i64, 625 last_event_created_at_unix_seconds: Option<i64>, 626 started_at: &str, 627 started_unix_seconds: i64, 628 completed_at: &str, 629 completed_unix_seconds: i64, 630 ) -> Result<(), AppSqliteError> { 631 self.sync_repository().record_relay_ingest_success( 632 scope_key, 633 relay_url, 634 cursor_since_unix_seconds, 635 last_event_created_at_unix_seconds, 636 started_at, 637 started_unix_seconds, 638 completed_at, 639 completed_unix_seconds, 640 ) 641 } 642 643 pub fn record_relay_ingest_failure( 644 &self, 645 scope_key: &str, 646 relay_url: &str, 647 started_at: &str, 648 started_unix_seconds: i64, 649 completed_at: &str, 650 completed_unix_seconds: i64, 651 error_message: &str, 652 ) -> Result<(), AppSqliteError> { 653 self.sync_repository().record_relay_ingest_failure( 654 scope_key, 655 relay_url, 656 started_at, 657 started_unix_seconds, 658 completed_at, 659 completed_unix_seconds, 660 error_message, 661 ) 662 } 663 664 pub fn record_sync_conflict( 665 &self, 666 account_id: &str, 667 conflict: &SyncConflict, 668 ) -> Result<String, AppSqliteError> { 669 self.sync_repository().record_conflict(account_id, conflict) 670 } 671 672 pub fn replace_sync_conflicts( 673 &self, 674 account_id: &str, 675 conflicts: &[SyncConflict], 676 ) -> Result<(), AppSqliteError> { 677 self.sync_repository() 678 .replace_conflicts(account_id, conflicts) 679 } 680 681 pub fn load_sync_conflicts( 682 &self, 683 account_id: &str, 684 ) -> Result<Vec<StoredSyncConflict>, AppSqliteError> { 685 self.sync_repository().load_conflicts(account_id) 686 } 687 688 pub fn resolve_sync_conflict( 689 &self, 690 account_id: &str, 691 conflict_id: &str, 692 resolution: SyncConflictResolutionStatus, 693 resolved_at: &str, 694 ) -> Result<bool, AppSqliteError> { 695 self.sync_repository() 696 .resolve_conflict(account_id, conflict_id, resolution, resolved_at) 697 } 698 } 699 700 fn open_connection(target: &DatabaseTarget) -> Result<Connection, AppSqliteError> { 701 match target { 702 DatabaseTarget::InMemory => { 703 Connection::open_in_memory().map_err(|source| AppSqliteError::OpenInMemory { source }) 704 } 705 DatabaseTarget::Path(path) => { 706 if let Some(parent) = path.parent() { 707 if !parent.as_os_str().is_empty() { 708 fs::create_dir_all(parent).map_err(|source| { 709 AppSqliteError::CreateParentDirectory { 710 path: parent.to_path_buf(), 711 source, 712 } 713 })?; 714 } 715 } 716 717 Connection::open(path).map_err(|source| AppSqliteError::OpenPath { 718 path: path.clone(), 719 source, 720 }) 721 } 722 } 723 } 724 725 fn bootstrap_connection( 726 connection: &mut Connection, 727 target: &DatabaseTarget, 728 ) -> Result<(), AppSqliteError> { 729 connection 730 .busy_timeout(Duration::from_millis(SQLITE_BUSY_TIMEOUT_MS)) 731 .map_err(|source| AppSqliteError::ConfigureBusyTimeout { source })?; 732 733 apply_pragma(connection, "foreign_keys", "ON")?; 734 apply_pragma(connection, "synchronous", "NORMAL")?; 735 736 if matches!(target, DatabaseTarget::Path(_)) { 737 connection 738 .query_row("PRAGMA journal_mode = WAL", [], |row| { 739 row.get::<_, String>(0) 740 }) 741 .map_err(|source| AppSqliteError::ApplyPragma { 742 pragma: "journal_mode", 743 source, 744 })?; 745 } 746 747 apply_migrations(connection) 748 } 749 750 fn apply_pragma( 751 connection: &Connection, 752 pragma: &'static str, 753 value: &str, 754 ) -> Result<(), AppSqliteError> { 755 let sql = format!("PRAGMA {pragma} = {value}"); 756 connection 757 .execute_batch(&sql) 758 .map_err(|source| AppSqliteError::ApplyPragma { pragma, source }) 759 } 760 761 fn schema_version(connection: &Connection) -> Result<u32, AppSqliteError> { 762 connection 763 .query_row("PRAGMA user_version", [], |row| row.get(0)) 764 .map_err(|source| AppSqliteError::ReadSchemaVersion { source }) 765 } 766 767 fn apply_migrations(connection: &mut Connection) -> Result<(), AppSqliteError> { 768 let current_version = schema_version(connection)?; 769 let latest_version = migrations::latest_schema_version(); 770 771 if current_version > latest_version { 772 return Err(AppSqliteError::UnsupportedSchemaVersion { 773 current: current_version, 774 latest: latest_version, 775 }); 776 } 777 778 for (version, sql) in migrations::pending_migrations(current_version) { 779 let transaction = connection 780 .transaction() 781 .map_err(|source| AppSqliteError::BeginMigration { version, source })?; 782 783 transaction 784 .execute_batch(sql) 785 .map_err(|source| AppSqliteError::ExecuteMigration { version, source })?; 786 transaction 787 .pragma_update(None, "user_version", version) 788 .map_err(|source| AppSqliteError::RecordSchemaVersion { version, source })?; 789 transaction 790 .commit() 791 .map_err(|source| AppSqliteError::CommitMigration { version, source })?; 792 } 793 794 Ok(()) 795 } 796 797 #[cfg(test)] 798 mod tests { 799 use super::{AppSqliteStore, DatabaseTarget, latest_schema_version, migrations}; 800 use rusqlite::{Connection, params}; 801 use std::{ 802 env, fs, 803 path::PathBuf, 804 time::{SystemTime, UNIX_EPOCH}, 805 }; 806 807 #[test] 808 fn file_store_bootstrap_applies_pragmas_and_migrations() { 809 let path = temp_database_path("bootstrap"); 810 let store = 811 AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("store should open"); 812 let connection = store.connection(); 813 814 assert_eq!( 815 store.schema_version().expect("schema version"), 816 latest_schema_version() 817 ); 818 assert_eq!(pragma_i64(connection, "foreign_keys"), 1); 819 assert_eq!(pragma_text(connection, "journal_mode"), "wal"); 820 assert!(table_exists(connection, "farms")); 821 assert!(table_exists(connection, "products")); 822 assert!(table_exists(connection, "orders")); 823 assert!(table_exists(connection, "local_outbox")); 824 assert!(table_exists(connection, "local_conflicts")); 825 assert!(table_exists(connection, "sync_checkpoints")); 826 assert!(table_exists(connection, "app_relay_ingest_freshness")); 827 assert!(table_exists(connection, "activity_events")); 828 assert!(table_exists(connection, "account_surface_activations")); 829 assert!(table_exists(connection, "account_farm_setups")); 830 assert!(table_exists(connection, "farm_operating_rules")); 831 assert!(table_exists(connection, "pickup_locations")); 832 assert!(table_exists(connection, "blackout_periods")); 833 assert!(table_exists(connection, "order_lines")); 834 assert!(table_exists(connection, "buyer_carts")); 835 assert!(table_exists(connection, "buyer_cart_lines")); 836 assert!(table_exists(connection, "reminder_schedules")); 837 assert!(table_exists(connection, "reminder_log_entries")); 838 assert!(table_exists(connection, "buyer_order_coordination_records")); 839 assert!(table_exists(connection, "order_validation_receipts")); 840 assert!(table_exists(connection, "app_sdk_migration_receipts")); 841 assert!(column_exists(connection, "farms", "timezone")); 842 assert!(column_exists(connection, "farms", "currency_code")); 843 assert!(column_exists(connection, "local_outbox", "account_id")); 844 assert!(column_exists(connection, "local_outbox", "operation_key")); 845 assert!(column_exists(connection, "local_outbox", "state")); 846 assert!(column_exists( 847 connection, 848 "local_outbox", 849 "last_error_message" 850 )); 851 assert!(column_exists(connection, "local_conflicts", "account_id")); 852 assert!(column_exists(connection, "local_conflicts", "severity")); 853 assert!(column_exists( 854 connection, 855 "local_conflicts", 856 "resolution_status" 857 )); 858 assert!(column_exists(connection, "sync_checkpoints", "account_id")); 859 assert!(column_exists(connection, "sync_checkpoints", "state")); 860 assert!(column_exists( 861 connection, 862 "app_relay_ingest_freshness", 863 "scope_key" 864 )); 865 assert!(column_exists( 866 connection, 867 "app_relay_ingest_freshness", 868 "relay_url" 869 )); 870 assert!(column_exists( 871 connection, 872 "app_relay_ingest_freshness", 873 "cursor_since_unix_seconds" 874 )); 875 assert!(column_exists( 876 connection, 877 "fulfillment_windows", 878 "pickup_location_id" 879 )); 880 assert!(column_exists(connection, "fulfillment_windows", "label")); 881 assert!(column_exists( 882 connection, 883 "fulfillment_windows", 884 "order_cutoff_at" 885 )); 886 assert!(column_exists(connection, "order_lines", "quantity_value")); 887 assert!(column_exists( 888 connection, 889 "order_lines", 890 "quantity_unit_label" 891 )); 892 assert!(column_exists(connection, "order_lines", "quantity_display")); 893 assert!(column_exists(connection, "order_lines", "listing_bin_id")); 894 assert!(column_exists( 895 connection, 896 "order_lines", 897 "unit_price_minor_units" 898 )); 899 assert!(column_exists(connection, "order_lines", "price_currency")); 900 assert!(column_exists(connection, "order_lines", "listing_addr")); 901 assert!(column_exists( 902 connection, 903 "order_lines", 904 "listing_relays_json" 905 )); 906 assert!(column_exists(connection, "products", "category")); 907 assert!(column_exists(connection, "products", "listing_bin_id")); 908 assert!(column_exists(connection, "buyer_carts", "buyer_email")); 909 assert!(column_exists(connection, "buyer_carts", "buyer_phone")); 910 assert!(column_exists(connection, "buyer_carts", "buyer_order_note")); 911 assert!(column_exists( 912 connection, 913 "buyer_cart_lines", 914 "listing_bin_id" 915 )); 916 assert!(column_exists( 917 connection, 918 "buyer_cart_lines", 919 "quantity_unit_label" 920 )); 921 assert!(column_exists( 922 connection, 923 "buyer_cart_lines", 924 "unit_price_minor_units" 925 )); 926 assert!(column_exists(connection, "buyer_cart_lines", "farm_key")); 927 assert!(column_exists( 928 connection, 929 "buyer_cart_lines", 930 "listing_event_id" 931 )); 932 assert!(column_exists( 933 connection, 934 "buyer_cart_lines", 935 "listing_relays_json" 936 )); 937 assert!(column_exists(connection, "orders", "buyer_context_key")); 938 assert!(column_exists(connection, "orders", "buyer_email")); 939 assert!(column_exists(connection, "orders", "buyer_phone")); 940 assert!(column_exists(connection, "orders", "buyer_order_note")); 941 assert!(column_exists( 942 connection, 943 "reminder_schedules", 944 "account_id" 945 )); 946 assert!(column_exists( 947 connection, 948 "reminder_schedules", 949 "delivery_state" 950 )); 951 assert!(column_exists( 952 connection, 953 "reminder_log_entries", 954 "recorded_at" 955 )); 956 assert!(column_exists( 957 connection, 958 "buyer_order_coordination_records", 959 "state" 960 )); 961 assert!(column_exists( 962 connection, 963 "buyer_order_coordination_records", 964 "payload_json" 965 )); 966 assert!(column_exists( 967 connection, 968 "buyer_order_coordination_records", 969 "last_error_message" 970 )); 971 assert!(column_exists( 972 connection, 973 "order_validation_receipts", 974 "event_id" 975 )); 976 assert!(column_exists( 977 connection, 978 "order_validation_receipts", 979 "order_id" 980 )); 981 assert!(column_exists( 982 connection, 983 "order_validation_receipts", 984 "raw_order_id" 985 )); 986 assert!(column_exists( 987 connection, 988 "order_validation_receipts", 989 "root_event_id" 990 )); 991 assert!(column_exists( 992 connection, 993 "order_validation_receipts", 994 "target_event_id" 995 )); 996 assert!(column_exists( 997 connection, 998 "order_validation_receipts", 999 "result" 1000 )); 1001 assert!(column_exists( 1002 connection, 1003 "order_validation_receipts", 1004 "proof_system" 1005 )); 1006 assert!(column_exists( 1007 connection, 1008 "app_sdk_migration_receipts", 1009 "source_record_id" 1010 )); 1011 assert!(column_exists( 1012 connection, 1013 "app_sdk_migration_receipts", 1014 "source_kind" 1015 )); 1016 assert!(column_exists( 1017 connection, 1018 "app_sdk_migration_receipts", 1019 "sdk_operation_kind" 1020 )); 1021 assert!(column_exists( 1022 connection, 1023 "app_sdk_migration_receipts", 1024 "sdk_outbox_event_ids_json" 1025 )); 1026 assert!(column_exists( 1027 connection, 1028 "app_sdk_migration_receipts", 1029 "expected_event_id" 1030 )); 1031 assert!(column_exists( 1032 connection, 1033 "app_sdk_migration_receipts", 1034 "actor_pubkey" 1035 )); 1036 assert!(column_exists( 1037 connection, 1038 "app_sdk_migration_receipts", 1039 "idempotency_digest_prefix" 1040 )); 1041 assert!(column_exists( 1042 connection, 1043 "app_sdk_migration_receipts", 1044 "migration_state" 1045 )); 1046 assert!(column_exists( 1047 connection, 1048 "app_sdk_migration_receipts", 1049 "detail_json" 1050 )); 1051 connection 1052 .execute( 1053 "INSERT INTO local_interop_imports ( 1054 record_id, 1055 local_seq, 1056 record_family, 1057 local_status, 1058 source_runtime, 1059 projected_kind, 1060 outbox_status, 1061 imported_at 1062 ) VALUES ( 1063 'schema_validation_receipt_projection_kind', 1064 0, 1065 'signed_event', 1066 'published', 1067 'cli', 1068 'validation_receipt', 1069 'acknowledged', 1070 '2026-01-01T00:00:00Z' 1071 )", 1072 [], 1073 ) 1074 .expect("local interop imports should accept validation receipt projections"); 1075 assert_eq!(row_count(connection, "sync_checkpoints"), 0); 1076 1077 drop(store); 1078 remove_database_artifacts(&path); 1079 } 1080 1081 #[test] 1082 fn reopening_existing_store_is_idempotent() { 1083 let path = temp_database_path("reopen"); 1084 AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("first open should work"); 1085 let reopened = AppSqliteStore::open(DatabaseTarget::Path(path.clone())) 1086 .expect("second open should work"); 1087 1088 assert_eq!( 1089 reopened.schema_version().expect("schema version"), 1090 latest_schema_version() 1091 ); 1092 assert_eq!(row_count(reopened.connection(), "sync_checkpoints"), 0); 1093 1094 drop(reopened); 1095 remove_database_artifacts(&path); 1096 } 1097 1098 #[test] 1099 fn in_memory_store_bootstraps_without_file_only_pragmas() { 1100 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1101 1102 assert_eq!( 1103 store.schema_version().expect("schema version"), 1104 latest_schema_version() 1105 ); 1106 assert_eq!(pragma_i64(store.connection(), "foreign_keys"), 1); 1107 assert!(table_exists(store.connection(), "farms")); 1108 } 1109 1110 #[test] 1111 fn order_workflow_schema_is_agreement_only() { 1112 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1113 let connection = store.connection(); 1114 assert!(column_exists(connection, "orders", "workflow_agreement")); 1115 assert!(column_exists(connection, "orders", "workflow_inventory")); 1116 assert!(column_exists( 1117 connection, 1118 "orders", 1119 "workflow_provenance_source" 1120 )); 1121 assert!(!column_exists(connection, "orders", "workflow_fulfillment")); 1122 assert!(!column_exists(connection, "orders", "workflow_payment")); 1123 assert!(!column_exists( 1124 connection, 1125 "orders", 1126 "workflow_receipt_event_id" 1127 )); 1128 connection 1129 .execute( 1130 "INSERT INTO farms (id, display_name, readiness, created_at, updated_at) 1131 VALUES (?1, 'Schema Farm', 'ready', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", 1132 params!["farm_schema"], 1133 ) 1134 .expect("farm should insert"); 1135 connection 1136 .execute( 1137 "INSERT INTO orders ( 1138 id, 1139 farm_id, 1140 order_number, 1141 customer_display_name, 1142 status, 1143 updated_at, 1144 workflow_agreement, 1145 workflow_inventory 1146 ) VALUES ( 1147 'order_needs_review', 1148 'farm_schema', 1149 'needs review', 1150 'Buyer', 1151 'needs_review', 1152 '2026-01-01T00:00:00Z', 1153 'needs_review', 1154 'needs_review' 1155 )", 1156 [], 1157 ) 1158 .expect("agreement-only workflow projection should insert"); 1159 1160 let invalid_result = connection.execute( 1161 "INSERT INTO orders ( 1162 id, 1163 farm_id, 1164 order_number, 1165 customer_display_name, 1166 status, 1167 updated_at, 1168 workflow_agreement 1169 ) VALUES ('order_agreement_invalid', 'farm_schema', 'invalid', 'Buyer', 'scheduled', '2026-01-01T00:00:00Z', 'complete')", 1170 [], 1171 ); 1172 assert!(invalid_result.is_err()); 1173 } 1174 1175 #[test] 1176 fn legacy_sync_scaffolding_migrates_to_account_scoped_contract() { 1177 let path = temp_database_path("legacy-sync-contract"); 1178 fs::create_dir_all(path.parent().expect("temp database should have a parent")) 1179 .expect("legacy database parent should exist"); 1180 let connection = Connection::open(&path).expect("legacy database should open"); 1181 1182 for (version, sql) in migrations::pending_migrations(0) 1183 .filter(|(version, _)| *version < latest_schema_version()) 1184 { 1185 connection 1186 .execute_batch(sql) 1187 .expect("legacy migration should apply"); 1188 connection 1189 .pragma_update(None, "user_version", version) 1190 .expect("legacy schema version should record"); 1191 } 1192 1193 drop(connection); 1194 1195 let store = 1196 AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("store should open"); 1197 let connection = store.connection(); 1198 1199 assert_eq!( 1200 store.schema_version().expect("schema version"), 1201 latest_schema_version() 1202 ); 1203 assert!(column_exists(connection, "local_outbox", "account_id")); 1204 assert!(column_exists(connection, "local_outbox", "operation_key")); 1205 assert!(column_exists(connection, "local_outbox", "state")); 1206 assert!(column_exists(connection, "local_conflicts", "severity")); 1207 assert!(column_exists( 1208 connection, 1209 "local_conflicts", 1210 "resolution_status" 1211 )); 1212 assert!(column_exists(connection, "sync_checkpoints", "state")); 1213 assert!(table_exists(connection, "app_relay_ingest_freshness")); 1214 assert_eq!(row_count(connection, "sync_checkpoints"), 0); 1215 1216 drop(store); 1217 remove_database_artifacts(&path); 1218 } 1219 1220 #[test] 1221 fn legacy_orders_status_migration_preserves_child_rows_and_accepts_declined() { 1222 let path = temp_database_path("legacy-declined-orders"); 1223 fs::create_dir_all(path.parent().expect("temp database should have a parent")) 1224 .expect("legacy database parent should exist"); 1225 let connection = Connection::open(&path).expect("legacy database should open"); 1226 connection 1227 .execute_batch("PRAGMA foreign_keys = ON") 1228 .expect("foreign keys should enable"); 1229 1230 for (version, sql) in migrations::pending_migrations(0).filter(|(version, _)| *version < 20) 1231 { 1232 connection 1233 .execute_batch(sql) 1234 .expect("legacy migration should apply"); 1235 connection 1236 .pragma_update(None, "user_version", version) 1237 .expect("legacy schema version should record"); 1238 } 1239 1240 connection 1241 .execute( 1242 "INSERT INTO farms (id, display_name, readiness, created_at, updated_at) 1243 VALUES (?1, 'Legacy Farm', 'ready', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", 1244 params!["farm_legacy"], 1245 ) 1246 .expect("legacy farm should insert"); 1247 connection 1248 .execute( 1249 "INSERT INTO orders ( 1250 id, 1251 farm_id, 1252 fulfillment_window_id, 1253 order_number, 1254 customer_display_name, 1255 status, 1256 updated_at, 1257 buyer_context_key, 1258 buyer_email, 1259 buyer_phone, 1260 buyer_order_note 1261 ) VALUES ( 1262 'order_legacy', 1263 'farm_legacy', 1264 NULL, 1265 'R-900', 1266 'Legacy Buyer', 1267 'needs_action', 1268 '2026-01-01T00:00:00Z', 1269 'account:buyer', 1270 '', 1271 '', 1272 '' 1273 )", 1274 [], 1275 ) 1276 .expect("legacy order should insert"); 1277 connection 1278 .execute( 1279 "INSERT INTO order_lines ( 1280 id, 1281 order_id, 1282 title, 1283 quantity_value, 1284 quantity_display 1285 ) VALUES ( 1286 'line_legacy', 1287 'order_legacy', 1288 'Legacy Eggs', 1289 2, 1290 '2 each' 1291 )", 1292 [], 1293 ) 1294 .expect("legacy order line should insert"); 1295 connection 1296 .execute( 1297 "INSERT INTO buyer_order_coordination_records ( 1298 order_id, 1299 buyer_context_key, 1300 state, 1301 created_at, 1302 updated_at 1303 ) VALUES ( 1304 'order_legacy', 1305 'account:buyer', 1306 'pending', 1307 '2026-01-01T00:00:00Z', 1308 '2026-01-01T00:00:00Z' 1309 )", 1310 [], 1311 ) 1312 .expect("legacy buyer coordination should insert"); 1313 1314 drop(connection); 1315 1316 let store = 1317 AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("store should open"); 1318 let connection = store.connection(); 1319 1320 assert_eq!( 1321 store.schema_version().expect("schema version"), 1322 latest_schema_version() 1323 ); 1324 assert_eq!(row_count(connection, "orders"), 1); 1325 assert_eq!(row_count(connection, "order_lines"), 1); 1326 assert_eq!(row_count(connection, "buyer_order_coordination_records"), 1); 1327 assert_eq!(foreign_key_violation_count(connection), 0); 1328 1329 connection 1330 .execute( 1331 "UPDATE orders SET status = 'declined' WHERE id = 'order_legacy'", 1332 [], 1333 ) 1334 .expect("declined status should satisfy migrated check"); 1335 connection 1336 .execute( 1337 "UPDATE orders SET status = 'needs_review' WHERE id = 'order_legacy'", 1338 [], 1339 ) 1340 .expect("needs review status should satisfy migrated check"); 1341 1342 let status: String = connection 1343 .query_row( 1344 "SELECT status FROM orders WHERE id = 'order_legacy'", 1345 [], 1346 |row| row.get(0), 1347 ) 1348 .expect("status should load"); 1349 assert_eq!(status, "needs_review"); 1350 1351 drop(store); 1352 remove_database_artifacts(&path); 1353 } 1354 1355 fn table_exists(connection: &Connection, table_name: &str) -> bool { 1356 connection 1357 .query_row( 1358 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", 1359 [table_name], 1360 |row| row.get::<_, i64>(0), 1361 ) 1362 .expect("table existence query should succeed") 1363 == 1 1364 } 1365 1366 fn row_count(connection: &Connection, table_name: &str) -> i64 { 1367 let sql = format!("SELECT COUNT(*) FROM {table_name}"); 1368 connection 1369 .query_row(&sql, [], |row| row.get(0)) 1370 .expect("row count query should succeed") 1371 } 1372 1373 fn column_exists(connection: &Connection, table_name: &str, column_name: &str) -> bool { 1374 let sql = format!("PRAGMA table_info({table_name})"); 1375 let mut statement = connection 1376 .prepare(&sql) 1377 .expect("table info statement should prepare"); 1378 let mut rows = statement 1379 .query([]) 1380 .expect("table info query should succeed"); 1381 1382 while let Some(row) = rows.next().expect("table info row should load") { 1383 if row 1384 .get::<_, String>(1) 1385 .expect("table info name should load") 1386 == column_name 1387 { 1388 return true; 1389 } 1390 } 1391 1392 false 1393 } 1394 1395 fn foreign_key_violation_count(connection: &Connection) -> usize { 1396 let mut statement = connection 1397 .prepare("PRAGMA foreign_key_check") 1398 .expect("foreign key check should prepare"); 1399 let mut rows = statement.query([]).expect("foreign key check should run"); 1400 let mut count = 0; 1401 while rows 1402 .next() 1403 .expect("foreign key check row should load") 1404 .is_some() 1405 { 1406 count += 1; 1407 } 1408 count 1409 } 1410 1411 fn pragma_i64(connection: &Connection, pragma_name: &str) -> i64 { 1412 let sql = format!("PRAGMA {pragma_name}"); 1413 connection 1414 .query_row(&sql, [], |row| row.get(0)) 1415 .expect("pragma query should succeed") 1416 } 1417 1418 fn pragma_text(connection: &Connection, pragma_name: &str) -> String { 1419 let sql = format!("PRAGMA {pragma_name}"); 1420 connection 1421 .query_row(&sql, [], |row| row.get(0)) 1422 .expect("pragma query should succeed") 1423 } 1424 1425 fn temp_database_path(test_name: &str) -> PathBuf { 1426 let nonce = SystemTime::now() 1427 .duration_since(UNIX_EPOCH) 1428 .expect("time should move forward") 1429 .as_nanos(); 1430 1431 env::temp_dir() 1432 .join("radroots_app_sqlite_tests") 1433 .join(format!("{test_name}-{nonce}")) 1434 .join("app.sqlite3") 1435 } 1436 1437 fn remove_database_artifacts(database_path: &std::path::Path) { 1438 if let Some(parent) = database_path.parent() { 1439 let wal_path = database_path.with_extension("sqlite3-wal"); 1440 let shm_path = database_path.with_extension("sqlite3-shm"); 1441 1442 let _ = fs::remove_file(&wal_path); 1443 let _ = fs::remove_file(&shm_path); 1444 let _ = fs::remove_file(database_path); 1445 let _ = fs::remove_dir_all(parent); 1446 } 1447 } 1448 }