1use khive_storage::blob::ContentRef;
16use rusqlite::{Connection, OptionalExtension};
17use std::path::{Path, PathBuf};
18
19use crate::error::SqliteError;
20use crate::pool::WriteAdmission;
21use crate::stores::blob::{try_acquire_database_gc_owner_for_path, DatabaseGcOwnerGuard};
22
23#[path = "raw_migration_settlement.rs"]
24mod raw_migration_settlement;
25use raw_migration_settlement::{RawMigrationTransactions, RawMigrationWriteUnit};
26
27pub(crate) trait MigrationTransactions {
34 fn admitted<T>(
37 &mut self,
38 operation: impl FnOnce(&mut Connection) -> Result<T, SqliteError>,
39 ) -> Result<T, SqliteError>;
40}
41
42#[derive(Clone, Debug)]
48pub struct MigrationWritePolicy {
49 disk_guard: crate::EffectiveDiskGuardConfig,
50 volume_lock_dir: PathBuf,
51}
52
53impl MigrationWritePolicy {
54 pub fn new(
55 disk_guard: crate::EffectiveDiskGuardConfig,
56 volume_lock_dir: impl Into<PathBuf>,
57 ) -> Result<Self, SqliteError> {
58 disk_guard.validate()?;
59 let volume_lock_dir = volume_lock_dir.into();
60 if !volume_lock_dir.is_absolute() {
61 return Err(SqliteError::InvalidConfig(
62 "migration volume-lock directory must be absolute".to_string(),
63 ));
64 }
65 Ok(Self {
66 disk_guard,
67 volume_lock_dir,
68 })
69 }
70
71 pub fn from_environment() -> Result<Self, SqliteError> {
75 let disk_guard = crate::DiskGuardEnvironment::capture().resolve(None, None)?;
76 Self::new(disk_guard, crate::default_volume_lock_dir()?)
77 }
78
79 pub fn disk_guard_config(&self) -> crate::EffectiveDiskGuardConfig {
80 self.disk_guard
81 }
82
83 pub fn volume_lock_dir(&self) -> &Path {
84 &self.volume_lock_dir
85 }
86}
87
88#[path = "session_identity_migration.rs"]
89mod session_identity_migration;
90
91mod memory_visibility;
92
93pub struct Migration {
99 pub id: &'static str,
101 pub up_sql: &'static str,
103 pub down_sql: Option<&'static str>,
105 pub is_already_applied: Option<fn(&Connection) -> bool>,
109}
110
111pub struct ServiceSchemaPlan {
113 pub service: &'static str,
115 pub sqlite: &'static [Migration],
117 pub postgres: &'static [Migration],
119}
120
121const SCHEMA_VERSION_TABLE: &str = include_str!("../sql/schema-version-table.sql");
122
123pub fn apply_schema_plan(
126 conn: &mut Connection,
127 plan: &ServiceSchemaPlan,
128) -> Result<(), SqliteError> {
129 let admission = WriteAdmission::for_canonical_path(canonical_connection_database_path(conn)?)?;
130 apply_schema_plan_with_admission(
131 &mut RawMigrationTransactions::new(conn, &admission),
132 plan,
133 &admission,
134 )
135}
136
137pub fn apply_schema_plan_with_policy(
139 conn: &mut Connection,
140 plan: &ServiceSchemaPlan,
141 policy: &MigrationWritePolicy,
142) -> Result<(), SqliteError> {
143 let admission =
144 WriteAdmission::for_migration_policy(canonical_connection_database_path(conn)?, policy)?;
145 apply_schema_plan_with_admission(
146 &mut RawMigrationTransactions::new(conn, &admission),
147 plan,
148 &admission,
149 )
150}
151
152pub(crate) fn apply_schema_plan_with_admission(
155 writes: &mut impl MigrationTransactions,
156 plan: &ServiceSchemaPlan,
157 admission: &WriteAdmission,
158) -> Result<(), SqliteError> {
159 writes.admitted(|conn| {
160 admission.check()?;
161 conn.execute_batch(SCHEMA_VERSION_TABLE)?;
162 require_autocommit(conn, "schema-version bootstrap")
163 })?;
164
165 for migration in plan.sqlite {
166 writes.admitted(|conn| apply_service_migration(conn, plan, migration, admission))?;
167 }
168
169 Ok(())
170}
171
172fn apply_service_migration(
175 conn: &Connection,
176 plan: &ServiceSchemaPlan,
177 migration: &Migration,
178 admission: &WriteAdmission,
179) -> Result<(), SqliteError> {
180 let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
184 if let Err(error) = admission.check() {
185 let rollback = tx.rollback();
186 return Err(capacity_refusal_after_rollback(
187 conn,
188 rollback,
189 error,
190 "service schema migration",
191 ));
192 }
193
194 if let Some(check) = migration.is_already_applied {
196 if check(&tx) {
197 return Ok(());
198 }
199 }
200
201 let already: bool = tx.query_row(
203 "SELECT COUNT(*) > 0 FROM _schema_versions WHERE service = ?1 AND migration_id = ?2",
204 rusqlite::params![plan.service, migration.id],
205 |row| row.get(0),
206 )?;
207
208 if already {
209 return Ok(());
210 }
211
212 tx.execute_batch(migration.up_sql)?;
213
214 tx.execute(
215 "INSERT INTO _schema_versions (service, migration_id, applied_at) VALUES (?1, ?2, ?3)",
216 rusqlite::params![
217 plan.service,
218 migration.id,
219 chrono::Utc::now().timestamp_micros(),
220 ],
221 )?;
222 tx.commit()?;
223 Ok(())
224}
225
226fn require_autocommit(conn: &Connection, operation: &str) -> Result<(), SqliteError> {
227 if conn.is_autocommit() {
228 Ok(())
229 } else {
230 Err(SqliteError::InvalidData(format!(
231 "{operation} did not return the SQLite connection to autocommit"
232 )))
233 }
234}
235
236pub(crate) fn capacity_refusal_after_rollback(
237 conn: &Connection,
238 rollback: rusqlite::Result<()>,
239 refusal: SqliteError,
240 operation: &str,
241) -> SqliteError {
242 if let Err(error) = rollback {
243 return SqliteError::InvalidData(format!(
244 "{operation} capacity refusal could not roll back: {error}; \
245 initial refusal: {refusal}"
246 ));
247 }
248 if !conn.is_autocommit() {
249 return SqliteError::InvalidData(format!(
250 "{operation} capacity refusal rolled back without restoring autocommit; \
251 initial refusal: {refusal}"
252 ));
253 }
254 refusal
255}
256
257pub struct VersionedMigration {
267 pub version: u32,
269 pub name: &'static str,
271 pub up: &'static str,
274}
275
276const V1_UP: &str = include_str!("../sql/schema.sql");
279
280const V2_UP: &str = include_str!("../sql/002-narrow-fts-sections-update-trigger.sql");
281
282const V3_UP: &str = include_str!("../sql/003-backfill-domain-mirror-atoms.sql");
283
284const V4_UP: &str = include_str!("../sql/004-fts-consolidation.sql");
285
286const V5_UP: &str = include_str!("../sql/005-unique-comm-external-id.sql");
287
288const V6_UP: &str = include_str!("../sql/006-brain-retune-driver.sql");
289
290const V7_UP: &str = include_str!("../sql/007-notes-seq.sql");
291
292const V8_UP: &str = include_str!("../sql/008-notes-seq-repair.sql");
293
294const V9_UP: &str = include_str!("../sql/009-entities-name-ci-index.sql");
295
296const V10_UP: &str = include_str!("../sql/010-entities-content-ref.sql");
297
298const V11_UP: &str = include_str!("../sql/011-ann-write-log.sql");
299
300const V12_UP: &str = include_str!("../sql/012-ann-write-log-model-seq-index.sql");
301
302const V13_UP: &str = include_str!("../sql/013-list-cursor-sequences.sql");
303
304const V14_UP: &str = include_str!("../sql/014-graph-edges-id-unique.sql");
305
306const V15_UP: &str = include_str!("../sql/015-serve-ledger-attribution.sql");
307
308const V16_UP: &str = include_str!("../sql/016-gtd-dependency-cycle-guards.sql");
309
310const V17_UP: &str = include_str!("../sql/017-agents-ddl.sql");
311
312const V18_UP: &str = include_str!("../sql/018-ann-consumer-pending.sql");
313
314const V19_UP: &str = include_str!("../sql/019-list-cursor-backfill-repair.sql");
315
316const V20_UP: &str = include_str!("../sql/020-blob-gc-claims.sql");
317
318const V22_UP: &str = include_str!("../sql/022-notes-unread-probe-recipient.sql");
319
320const V23_UP: &str = include_str!("../sql/023-fts-record-kind.sql");
321
322const V24_UP: &str = include_str!("../sql/024-fts-rowid-map.sql");
323
324const V25_UP: &str = include_str!("../sql/025-notes-unread-probe-recipient-direction.sql");
325
326const V26_UP: &str = include_str!("../sql/026-knowledge-fts-repair.sql");
327
328const V27_UP: &str = include_str!("../sql/027-notes-hot-property-indexes.sql");
329const V28_UP: &str = include_str!("../sql/028-notes-key.sql");
330
331const V29_UP: &str = include_str!("../sql/029-note-streams.sql");
332const V30_UP: &str = include_str!("../sql/030-tool-source-mounts.sql");
333const V31_UP: &str = include_str!("../sql/031-note-versions.sql");
334const V32_UP: &str = include_str!("../sql/032-knowledge-count-indexes.sql");
335const V33_UP: &str = include_str!("../sql/033-notes-message-recipient-direction.sql");
336const V34_UP: &str = include_str!("../sql/034-notes-namespace-created.sql");
337const V35_UP: &str = include_str!("../sql/035-notes-unread-probe-recipient-type-direction.sql");
338const V36_UP: &str = include_str!("../sql/036-events-operation-attribution.sql");
339const V37_UP: &str = include_str!("../sql/037-entity-versions.sql");
340const V38_UP: &str = include_str!("../sql/038-entities-legacy-type-index.sql");
341const V39_UP: &str = include_str!("../sql/039-knowledge-cursor-indexes.sql");
342const SESSION_IDENTITY_UP: &str = include_str!("../sql/040-session-source-scope.sql");
343const SESSION_IDENTITY_MIGRATION_NAME: &str = "session_source_scoped_identity";
344const V41_UP: &str = include_str!("../sql/041-sender-transport.sql");
345const V42_UP: &str = include_str!("../sql/042-comm-external-id-channel-scope.sql");
346const V43_UP: &str = include_str!("../sql/043-vector-provenance.sql");
347const V44_COLUMNS: &str = include_str!("../sql/044-comm-outbound-due-a-columns.sql");
348const V44_UP: &str = include_str!("../sql/044-comm-outbound-due-b-index.sql");
349
350pub(crate) fn migrate_outbound_due_key(tx: &rusqlite::Transaction<'_>) -> rusqlite::Result<()> {
354 let has_column = |name: &str| -> rusqlite::Result<bool> {
355 tx.query_row(
356 "SELECT EXISTS(SELECT 1 FROM pragma_table_info('notes') WHERE name = ?1)",
357 [name],
358 |row| row.get(0),
359 )
360 };
361 match (has_column("strict_due_key")?, has_column("due_source")?) {
362 (false, false) => tx.execute_batch(V44_COLUMNS)?,
363 (true, true) => {}
364 _ => return Err(rusqlite::Error::InvalidQuery),
365 }
366
367 let mut after_id = String::new();
368 let mut first_page = true;
369 loop {
370 let rows: Vec<(String, String)> = {
371 let comparator = if first_page { ">=" } else { ">" };
372 let mut stmt = tx.prepare(&format!(
373 "SELECT id, json_extract(properties, '$.next_attempt_at') FROM notes \
374 WHERE id {comparator} ?1 AND json_type(properties, '$.next_attempt_at') = 'text' \
375 ORDER BY id LIMIT 500"
376 ))?;
377 let collected = stmt
378 .query_map([&after_id], |row| Ok((row.get(0)?, row.get(1)?)))?
379 .collect::<rusqlite::Result<_>>()?;
380 collected
381 };
382 if rows.is_empty() {
383 break;
384 }
385 after_id = rows.last().expect("nonempty V44 page").0.clone();
386 first_page = false;
387 for (id, source) in rows {
388 if let Some(key) = crate::pool::strict_rfc3339_key(&source) {
389 tx.execute(
390 "UPDATE notes SET strict_due_key = ?1, due_source = ?2 \
391 WHERE id = ?3 AND (strict_due_key IS NOT ?1 OR due_source IS NOT ?2)",
392 rusqlite::params![key, source, id],
393 )?;
394 }
395 }
396 }
397 tx.execute_batch(V44_UP)
398}
399
400const RECIPIENT_TRANSPORT_VERSION: u32 = 45;
401const V45_UP: &str = include_str!("../sql/045-recipient-transport.sql");
402const V46_UP: &str = include_str!("../sql/046-memory-visibility-receipts.sql");
403const V47_UP: &str = include_str!("../sql/047-attachment-role-quarantine.sql");
404const V49_UP: &str = include_str!("../sql/049-git-note-property-indexes.sql");
405
406const V50_UP: &str = include_str!("../sql/050-entity-list-plans.sql");
407const V51_UP: &str = include_str!("../sql/051-schedule-core-indexes.sql");
408const V52_UP: &str = include_str!("../sql/052-comm-core-indexes.sql");
409const V53_UP: &str = include_str!("../sql/053-entity-kind-list-order.sql");
410const MEMORY_VISIBILITY_CUTOVER_VERSION: u32 = 54;
411const V54_UP: &str = include_str!("../sql/054-memory-visibility-epochs.sql");
412
413const V55_UP: &str = include_str!("../sql/055-knowledge-cursor-domain-tags.sql");
414const V56_UP: &str = include_str!("../sql/056-atom-weights.sql");
415const V48_UP: &str = include_str!("../sql/048-acknowledgement-journal-a-table.sql");
416const ACKNOWLEDGEMENT_JOURNAL_INDEX: &str =
417 include_str!("../sql/048-acknowledgement-journal-b-index.sql");
418
419pub(crate) fn migrate_acknowledgement_journal(
421 tx: &rusqlite::Transaction<'_>,
422) -> rusqlite::Result<()> {
423 let columns: i64 = tx.query_row(
424 "SELECT count(*) FROM pragma_table_info('comm_ack_work') \
425 WHERE name IN ('attempt_count','not_before','retirement_reason')",
426 [],
427 |row| row.get(0),
428 )?;
429 match columns {
430 0 => tx.execute_batch(V48_UP)?,
431 3 => {}
432 _ => return Err(rusqlite::Error::InvalidQuery),
433 }
434 tx.execute_batch(ACKNOWLEDGEMENT_JOURNAL_INDEX)
435}
436
437const V21_STAGE_UP: &str = include_str!("../sql/021-attachments-a-stage.sql");
438
439const V21_ATTACHMENT_FENCES_UP: &str = include_str!("../sql/021-attachments-b-claim-fences.sql");
440
441pub const ATTACHMENT_CUTOVER_VERSION: u32 = 21;
443
444pub fn latest_schema_version() -> u32 {
449 MIGRATIONS.last().map(|m| m.version).unwrap_or(0)
450}
451
452pub const ANN_WRITE_LOG_DDL: &str = V11_UP;
460
461pub const ANN_WRITE_LOG_MODEL_SEQ_INDEX_DDL: &str = V12_UP;
466
467pub const ANN_CONSUMER_PENDING_DDL: &str = include_str!("../sql/ann-consumer-pending-ddl.sql");
474
475pub const VECTOR_PROVENANCE_DDL: &str = V43_UP;
478
479pub const EMBEDDING_MODELS_DDL: &str = include_str!("../sql/embedding-models-ddl.sql");
485
486pub const MIGRATIONS: &[VersionedMigration] = &[
493 VersionedMigration {
494 version: 1,
495 name: "initial_schema",
496 up: V1_UP,
497 },
498 VersionedMigration {
499 version: 2,
500 name: "narrow_fts_sections_update_trigger",
501 up: V2_UP,
502 },
503 VersionedMigration {
504 version: 3,
505 name: "backfill_domain_mirror_atoms",
506 up: V3_UP,
507 },
508 VersionedMigration {
509 version: 4,
510 name: "fts_consolidation",
511 up: V4_UP,
512 },
513 VersionedMigration {
514 version: 5,
515 name: "unique_comm_message_external_id",
516 up: V5_UP,
517 },
518 VersionedMigration {
519 version: 6,
520 name: "brain_retune_driver",
521 up: V6_UP,
522 },
523 VersionedMigration {
524 version: 7,
525 name: "notes_seq",
526 up: V7_UP,
527 },
528 VersionedMigration {
529 version: 8,
530 name: "notes_seq_repair",
531 up: V8_UP,
532 },
533 VersionedMigration {
534 version: 9,
535 name: "entities_name_ci_index",
536 up: V9_UP,
537 },
538 VersionedMigration {
539 version: 10,
540 name: "entities_content_ref",
541 up: V10_UP,
542 },
543 VersionedMigration {
544 version: 11,
545 name: "ann_write_log",
546 up: V11_UP,
547 },
548 VersionedMigration {
549 version: 12,
550 name: "ann_write_log_model_seq_index",
551 up: V12_UP,
552 },
553 VersionedMigration {
554 version: 13,
555 name: "list_cursor_sequences",
556 up: V13_UP,
557 },
558 VersionedMigration {
559 version: 14,
560 name: "graph_edges_id_unique",
561 up: V14_UP,
562 },
563 VersionedMigration {
564 version: 15,
565 name: "serve_ledger_attribution",
566 up: V15_UP,
567 },
568 VersionedMigration {
569 version: 16,
570 name: "gtd_dependency_cycle_guards",
571 up: V16_UP,
572 },
573 VersionedMigration {
574 version: 17,
575 name: "agents_ddl",
576 up: V17_UP,
577 },
578 VersionedMigration {
579 version: 18,
580 name: "ann_consumer_pending",
581 up: V18_UP,
582 },
583 VersionedMigration {
584 version: 19,
585 name: "list_cursor_backfill_repair",
586 up: V19_UP,
587 },
588 VersionedMigration {
589 version: 20,
590 name: "blob_gc_claims",
591 up: V20_UP,
592 },
593 VersionedMigration {
594 version: ATTACHMENT_CUTOVER_VERSION,
595 name: "attachments_first_class",
596 up: V21_STAGE_UP,
600 },
601 VersionedMigration {
602 version: 22,
603 name: "notes_unread_probe_recipient",
604 up: V22_UP,
605 },
606 VersionedMigration {
607 version: 23,
608 name: "fts_record_kind",
609 up: V23_UP,
610 },
611 VersionedMigration {
612 version: 24,
613 name: "fts_rowid_map",
614 up: V24_UP,
615 },
616 VersionedMigration {
617 version: 25,
618 name: "notes_unread_probe_recipient_direction",
619 up: V25_UP,
620 },
621 VersionedMigration {
622 version: 26,
623 name: "knowledge_fts_repair",
624 up: V26_UP,
625 },
626 VersionedMigration {
627 version: 27,
628 name: "notes_hot_property_indexes",
629 up: V27_UP,
630 },
631 VersionedMigration {
632 version: 28,
633 name: "notes_key",
634 up: V28_UP,
635 },
636 VersionedMigration {
637 version: 29,
638 name: "note_streams",
639 up: V29_UP,
640 },
641 VersionedMigration {
642 version: 30,
643 name: "tool_source_mounts",
644 up: V30_UP,
645 },
646 VersionedMigration {
647 version: 31,
648 name: "note_versions",
649 up: V31_UP,
650 },
651 VersionedMigration {
652 version: 32,
653 name: "knowledge_count_indexes",
654 up: V32_UP,
655 },
656 VersionedMigration {
657 version: 33,
658 name: "notes_message_recipient_direction",
659 up: V33_UP,
660 },
661 VersionedMigration {
662 version: 34,
663 name: "notes_namespace_created",
664 up: V34_UP,
665 },
666 VersionedMigration {
667 version: 35,
668 name: "notes_unread_probe_recipient_type_direction",
669 up: V35_UP,
670 },
671 VersionedMigration {
672 version: 36,
673 name: "events_operation_attribution",
674 up: V36_UP,
675 },
676 VersionedMigration {
677 version: 37,
678 name: "entity_versions",
679 up: V37_UP,
680 },
681 VersionedMigration {
682 version: 38,
683 name: "entities_legacy_type_index",
684 up: V38_UP,
685 },
686 VersionedMigration {
687 version: 39,
688 name: "knowledge_cursor_indexes",
689 up: V39_UP,
690 },
691 VersionedMigration {
692 version: 40,
693 name: SESSION_IDENTITY_MIGRATION_NAME,
694 up: SESSION_IDENTITY_UP,
695 },
696 VersionedMigration {
697 version: 41,
698 name: "sender_transport",
699 up: V41_UP,
700 },
701 VersionedMigration {
702 version: 42,
703 name: "comm_external_id_channel_scope",
704 up: V42_UP,
705 },
706 VersionedMigration {
707 version: 43,
708 name: "vector_provenance",
709 up: V43_UP,
710 },
711 VersionedMigration {
712 version: 44,
713 name: "comm_outbound_due",
714 up: V44_UP,
715 },
716 VersionedMigration {
717 version: RECIPIENT_TRANSPORT_VERSION,
718 name: "recipient_transport",
719 up: V45_UP,
720 },
721 VersionedMigration {
722 version: 46,
723 name: "memory_visibility_receipts",
724 up: V46_UP,
725 },
726 VersionedMigration {
727 version: 47,
728 name: "attachment_role_quarantine",
729 up: V47_UP,
730 },
731 VersionedMigration {
732 version: 48,
733 name: "acknowledgement_journal",
734 up: V48_UP,
735 },
736 VersionedMigration {
737 version: 49,
738 name: "git_note_property_indexes",
739 up: V49_UP,
740 },
741 VersionedMigration {
742 version: 50,
743 name: "entity_list_plans",
744 up: V50_UP,
745 },
746 VersionedMigration {
747 version: 51,
748 name: "schedule_core_indexes",
749 up: V51_UP,
750 },
751 VersionedMigration {
752 version: 52,
753 name: "comm_core_indexes",
754 up: V52_UP,
755 },
756 VersionedMigration {
757 version: 53,
758 name: "entity_kind_list_order",
759 up: V53_UP,
760 },
761 VersionedMigration {
762 version: MEMORY_VISIBILITY_CUTOVER_VERSION,
763 name: "memory_visibility_epochs",
764 up: V54_UP,
765 },
766 VersionedMigration {
767 version: 55,
768 name: "knowledge_atom_cursor_all_live_tags",
769 up: V55_UP,
770 },
771 VersionedMigration {
772 version: 56,
773 name: "atom_weight_tables",
774 up: V56_UP,
775 },
776];
777
778#[derive(Clone, Copy, Debug, Eq, PartialEq)]
780pub enum AttachmentCutoverStatus {
781 Pending,
783 Incomplete,
785 Complete,
787}
788
789fn schema_object_exists(
790 conn: &Connection,
791 object_type: &str,
792 name: &str,
793) -> Result<bool, SqliteError> {
794 conn.query_row(
795 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = ?1 AND name = ?2",
796 rusqlite::params![object_type, name],
797 |row| row.get(0),
798 )
799 .map_err(Into::into)
800}
801
802fn schema_column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool, SqliteError> {
803 conn.query_row(
804 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = ?2",
805 rusqlite::params![table, column],
806 |row| row.get(0),
807 )
808 .map_err(Into::into)
809}
810
811fn require_attachment_schema_objects(
812 conn: &Connection,
813 objects: &[(&str, &str)],
814 phase: &str,
815) -> Result<(), SqliteError> {
816 for (object_type, name) in objects {
817 if !schema_object_exists(conn, object_type, name)? {
818 return Err(SqliteError::InvalidData(format!(
819 "attachment cutover {phase} state is missing {object_type} {name:?}"
820 )));
821 }
822 }
823 Ok(())
824}
825
826fn validate_incomplete_attachment_schema(conn: &Connection) -> Result<(), SqliteError> {
827 require_attachment_schema_objects(
828 conn,
829 &[
830 ("table", "attachments"),
831 ("index", "idx_attachments_content_ref"),
832 ],
833 "incomplete",
834 )?;
835 require_legacy_attachment_fences(conn)
836}
837
838fn validate_complete_attachment_schema(conn: &Connection) -> Result<(), SqliteError> {
839 require_attachment_schema_objects(
840 conn,
841 &[
842 ("table", "attachments"),
843 ("table", "blob_gc_claims"),
844 ("index", "idx_attachments_content_ref"),
845 ("index", "idx_blob_gc_claims_content_ref"),
846 ("trigger", "attachments_reject_claimed_blob_insert"),
847 ("trigger", "attachments_reject_claimed_blob_update"),
848 ],
849 "complete",
850 )?;
851 if schema_column_exists(conn, "entities", "content_ref")? {
852 return Err(SqliteError::InvalidData(
853 "attachment cutover is complete but entities.content_ref still exists".into(),
854 ));
855 }
856 for (object_type, name) in [
857 ("index", "idx_entities_content_ref"),
858 ("trigger", "entities_reject_claimed_blob_insert"),
859 ("trigger", "entities_reject_claimed_blob_update"),
860 ] {
861 if schema_object_exists(conn, object_type, name)? {
862 return Err(SqliteError::InvalidData(format!(
863 "attachment cutover is complete but legacy {object_type} {name:?} still exists"
864 )));
865 }
866 }
867 Ok(())
868}
869
870pub fn attachment_cutover_status(
875 conn: &Connection,
876) -> Result<AttachmentCutoverStatus, SqliteError> {
877 let version = read_schema_version(conn)?;
878 let marker_table = schema_object_exists(conn, "table", "attachment_cutover_state")?;
879 if !marker_table {
880 if version >= ATTACHMENT_CUTOVER_VERSION {
881 return Err(SqliteError::InvalidData(format!(
882 "migration V{ATTACHMENT_CUTOVER_VERSION} is recorded but its attachment cutover marker is absent"
883 )));
884 }
885 if schema_object_exists(conn, "table", "attachments")? {
886 return Err(SqliteError::InvalidData(
887 "attachments table exists without the durable attachment cutover marker".into(),
888 ));
889 }
890 return Ok(AttachmentCutoverStatus::Pending);
891 }
892
893 let marker: Option<(String, Option<i64>)> = conn
894 .query_row(
895 "SELECT state, completed_at FROM attachment_cutover_state WHERE singleton = 1",
896 [],
897 |row| Ok((row.get(0)?, row.get(1)?)),
898 )
899 .optional()?;
900 match marker {
901 Some((state, None)) if state == "incomplete" => {
902 if version >= ATTACHMENT_CUTOVER_VERSION {
903 Err(SqliteError::InvalidData(format!(
904 "attachment cutover is incomplete but migration V{ATTACHMENT_CUTOVER_VERSION} is already recorded"
905 )))
906 } else {
907 validate_incomplete_attachment_schema(conn)?;
908 Ok(AttachmentCutoverStatus::Incomplete)
909 }
910 }
911 Some((state, Some(_))) if state == "complete" => {
912 if version >= ATTACHMENT_CUTOVER_VERSION {
916 validate_complete_attachment_schema(conn)?;
917 Ok(AttachmentCutoverStatus::Complete)
918 } else {
919 Err(SqliteError::InvalidData(format!(
920 "attachment cutover is complete but schema ledger is at V{version}, below V{ATTACHMENT_CUTOVER_VERSION}"
921 )))
922 }
923 }
924 Some((state, completed_at)) => Err(SqliteError::InvalidData(format!(
925 "invalid attachment cutover marker state {state:?} with completed_at={completed_at:?}"
926 ))),
927 None => Err(SqliteError::InvalidData(
928 "attachment cutover marker table exists without its singleton row".into(),
929 )),
930 }
931}
932
933fn require_legacy_attachment_fences(conn: &Connection) -> Result<(), SqliteError> {
934 if !schema_column_exists(conn, "entities", "content_ref")? {
935 return Err(SqliteError::InvalidData(
936 "attachment cutover requires legacy entities.content_ref until finalization".into(),
937 ));
938 }
939 for (object_type, name) in [
940 ("table", "blob_gc_claims"),
941 ("index", "idx_blob_gc_claims_content_ref"),
942 ("index", "idx_entities_content_ref"),
943 ("trigger", "entities_reject_claimed_blob_insert"),
944 ("trigger", "entities_reject_claimed_blob_update"),
945 ] {
946 if !schema_object_exists(conn, object_type, name)? {
947 return Err(SqliteError::InvalidData(format!(
948 "attachment cutover requires legacy {object_type} {name:?} until finalization"
949 )));
950 }
951 }
952 Ok(())
953}
954
955fn canonical_content_ref_byte_width(conn: &Connection) -> Result<i64, SqliteError> {
962 let width: i64 = conn.query_row("SELECT length(CAST('x' AS BLOB))", [], |row| row.get(0))?;
963 if !(1..=4).contains(&width) {
964 return Err(SqliteError::InvalidData(format!(
965 "the text-encoding width probe returned {width}; refusing canonicality validation"
966 )));
967 }
968 Ok(width * 64)
969}
970
971fn validate_canonical_legacy_refs(conn: &Connection) -> Result<(), SqliteError> {
972 let canonical_bytes = canonical_content_ref_byte_width(conn)?;
973 let invalid: Option<String> = conn
974 .query_row(
975 "SELECT id FROM entities \
976 WHERE content_ref IS NOT NULL \
977 AND (typeof(content_ref) <> 'text' \
978 OR length(content_ref) <> 64 \
979 OR length(CAST(content_ref AS BLOB)) <> ?1 \
980 OR content_ref GLOB '*[^0-9a-f]*') \
981 LIMIT 1",
982 [canonical_bytes],
983 |row| row.get(0),
984 )
985 .optional()?;
986 if let Some(id) = invalid {
987 return Err(SqliteError::InvalidData(format!(
988 "entities.content_ref for record {id:?} is not a canonical 64-character lowercase hexadecimal ContentRef"
989 )));
990 }
991 Ok(())
992}
993
994fn validate_canonical_attachment_and_claim_refs(conn: &Connection) -> Result<(), SqliteError> {
995 let canonical_bytes = canonical_content_ref_byte_width(conn)?;
996 for (table, identity) in [
997 ("attachments", "record_uuid"),
998 ("blob_gc_claims", "root_key"),
999 ] {
1000 let sql = format!(
1001 "SELECT {identity} FROM {table} \
1002 WHERE typeof(content_ref) <> 'text' \
1003 OR length(content_ref) <> 64 \
1004 OR length(CAST(content_ref AS BLOB)) <> ?1 \
1005 OR content_ref GLOB '*[^0-9a-f]*' \
1006 LIMIT 1"
1007 );
1008 let invalid: Option<String> = conn
1009 .query_row(&sql, [canonical_bytes], |row| row.get(0))
1010 .optional()?;
1011 if let Some(owner) = invalid {
1012 return Err(SqliteError::InvalidData(format!(
1013 "{table}.content_ref for {identity} {owner:?} is not canonical"
1014 )));
1015 }
1016 }
1017 Ok(())
1018}
1019
1020fn validate_attachment_record_owners(conn: &Connection) -> Result<(), SqliteError> {
1021 let dangling: Option<(String, String)> = conn
1022 .query_row(
1023 "SELECT record_uuid, substrate FROM attachments AS attachment \
1024 WHERE (substrate = 'entity' AND NOT EXISTS ( \
1025 SELECT 1 FROM entities WHERE id = attachment.record_uuid \
1026 )) \
1027 OR (substrate = 'note' AND NOT EXISTS ( \
1028 SELECT 1 FROM notes WHERE id = attachment.record_uuid \
1029 )) \
1030 LIMIT 1",
1031 [],
1032 |row| Ok((row.get(0)?, row.get(1)?)),
1033 )
1034 .optional()?;
1035 if let Some((record_uuid, substrate)) = dangling {
1036 return Err(SqliteError::InvalidData(format!(
1037 "attachment role references absent {substrate} record {record_uuid:?}"
1038 )));
1039 }
1040 Ok(())
1041}
1042
1043fn validate_legacy_content_backfill(conn: &Connection) -> Result<(), SqliteError> {
1044 let conflict: Option<String> = conn
1045 .query_row(
1046 "SELECT entity.id FROM entities AS entity \
1047 LEFT JOIN attachments AS attachment \
1048 ON attachment.record_uuid = entity.id AND attachment.role = 'content' \
1049 WHERE entity.content_ref IS NOT NULL \
1050 AND (attachment.record_uuid IS NULL \
1051 OR attachment.substrate <> 'entity' \
1052 OR attachment.content_ref <> entity.content_ref) \
1053 LIMIT 1",
1054 [],
1055 |row| row.get(0),
1056 )
1057 .optional()?;
1058 if let Some(record_uuid) = conflict {
1059 return Err(SqliteError::InvalidData(format!(
1060 "legacy content attachment for entity {record_uuid:?} is missing or conflicts with entities.content_ref"
1061 )));
1062 }
1063 Ok(())
1064}
1065
1066fn stage_attachment_cutover_on_connection(conn: &Connection, now: i64) -> Result<(), SqliteError> {
1067 require_legacy_attachment_fences(conn)?;
1068 conn.execute_batch(V21_STAGE_UP)?;
1069 validate_canonical_legacy_refs(conn)?;
1070 validate_canonical_attachment_and_claim_refs(conn)?;
1071
1072 let conflict: Option<String> = conn
1073 .query_row(
1074 "SELECT entity.id FROM entities AS entity \
1075 JOIN attachments AS attachment \
1076 ON attachment.record_uuid = entity.id AND attachment.role = 'content' \
1077 WHERE entity.content_ref IS NOT NULL \
1078 AND (attachment.substrate <> 'entity' \
1079 OR attachment.content_ref <> entity.content_ref) \
1080 LIMIT 1",
1081 [],
1082 |row| row.get(0),
1083 )
1084 .optional()?;
1085 if let Some(record_uuid) = conflict {
1086 return Err(SqliteError::InvalidData(format!(
1087 "existing content attachment for entity {record_uuid:?} conflicts with entities.content_ref"
1088 )));
1089 }
1090
1091 conn.execute(
1092 "INSERT INTO attachments \
1093 (record_uuid, substrate, role, content_ref, media_type, size_bytes, created_at) \
1094 SELECT id, 'entity', 'content', content_ref, NULL, NULL, created_at \
1095 FROM entities WHERE content_ref IS NOT NULL \
1096 ON CONFLICT(record_uuid, role) DO NOTHING",
1097 [],
1098 )?;
1099 validate_legacy_content_backfill(conn)?;
1100
1101 conn.execute("DELETE FROM blob_gc_claims", [])?;
1105 conn.execute(
1106 "INSERT INTO attachment_cutover_state \
1107 (singleton, state, started_at, completed_at) \
1108 VALUES (1, 'incomplete', ?1, NULL) \
1109 ON CONFLICT(singleton) DO NOTHING",
1110 [now],
1111 )?;
1112 Ok(())
1113}
1114
1115pub fn stage_attachment_cutover(conn: &mut Connection) -> Result<(), SqliteError> {
1122 let admission = WriteAdmission::for_canonical_path(canonical_connection_database_path(conn)?)?;
1123 RawMigrationWriteUnit::new(conn, &admission)?
1124 .run(|conn| stage_attachment_cutover_with_admission(conn, &admission))
1125}
1126
1127pub fn stage_attachment_cutover_with_policy(
1129 conn: &mut Connection,
1130 policy: &MigrationWritePolicy,
1131) -> Result<(), SqliteError> {
1132 let admission =
1133 WriteAdmission::for_migration_policy(canonical_connection_database_path(conn)?, policy)?;
1134 RawMigrationWriteUnit::new(conn, &admission)?
1135 .run(|conn| stage_attachment_cutover_with_admission(conn, &admission))
1136}
1137
1138pub(crate) fn stage_attachment_cutover_with_admission(
1140 conn: &mut Connection,
1141 admission: &WriteAdmission,
1142) -> Result<(), SqliteError> {
1143 match attachment_cutover_status(conn)? {
1144 AttachmentCutoverStatus::Complete => return Ok(()),
1145 AttachmentCutoverStatus::Pending | AttachmentCutoverStatus::Incomplete => {}
1146 }
1147 if read_schema_version(conn)? != ATTACHMENT_CUTOVER_VERSION - 1 {
1148 return Err(SqliteError::InvalidData(format!(
1149 "attachment cutover stage requires canonical V{} schema",
1150 ATTACHMENT_CUTOVER_VERSION - 1
1151 )));
1152 }
1153
1154 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1155 if let Err(error) = admission.check() {
1156 let rollback = tx.rollback();
1157 return Err(capacity_refusal_after_rollback(
1158 conn,
1159 rollback,
1160 error,
1161 "attachment cutover stage",
1162 ));
1163 }
1164 let status = attachment_cutover_status(&tx)?;
1165 if status == AttachmentCutoverStatus::Complete {
1166 return Ok(());
1167 }
1168 stage_attachment_cutover_on_connection(&tx, chrono::Utc::now().timestamp_micros())?;
1169 tx.commit()?;
1170 Ok(())
1171}
1172
1173#[allow(clippy::too_many_arguments)]
1181pub fn apply_generic_verified_attachment(
1182 conn: &Connection,
1183 record_uuid: &str,
1184 substrate: &str,
1185 role: &str,
1186 content_ref: &ContentRef,
1187 media_type: Option<&str>,
1188 size_bytes: Option<u64>,
1189 created_at: i64,
1190) -> Result<(), SqliteError> {
1191 if attachment_cutover_status(conn)? != AttachmentCutoverStatus::Incomplete {
1192 return Err(SqliteError::InvalidData(
1193 "verified application attachments may only be applied while V21 cutover is incomplete"
1194 .into(),
1195 ));
1196 }
1197 if role.is_empty() || role.chars().any(char::is_control) {
1198 return Err(SqliteError::InvalidData(
1199 "attachment role must be non-empty and contain no control characters".into(),
1200 ));
1201 }
1202 let size_bytes = size_bytes.map(i64::try_from).transpose().map_err(|_| {
1203 SqliteError::InvalidData("attachment size_bytes exceeds SQLite INTEGER".into())
1204 })?;
1205 let owner_table = match substrate {
1206 "entity" => "entities",
1207 "note" => "notes",
1208 other => {
1209 return Err(SqliteError::InvalidData(format!(
1210 "attachment substrate must be 'entity' or 'note', got {other:?}"
1211 )))
1212 }
1213 };
1214 let owner_sql = format!("SELECT COUNT(*) > 0 FROM {owner_table} WHERE id = ?1");
1215 let owner_exists: bool = conn.query_row(&owner_sql, [record_uuid], |row| row.get(0))?;
1216 if !owner_exists {
1217 return Err(SqliteError::InvalidData(format!(
1218 "cannot attach role {role:?}: {substrate} record {record_uuid:?} does not exist"
1219 )));
1220 }
1221 let claimed: bool = conn.query_row(
1222 "SELECT COUNT(*) > 0 FROM blob_gc_claims WHERE content_ref = ?1",
1223 [content_ref.as_str()],
1224 |row| row.get(0),
1225 )?;
1226 if claimed {
1227 return Err(SqliteError::InvalidData(format!(
1228 "cannot attach claimed content_ref {} during V21 cutover",
1229 content_ref.as_str()
1230 )));
1231 }
1232
1233 let changed = conn.execute(
1234 "INSERT INTO attachments \
1235 (record_uuid, substrate, role, content_ref, media_type, size_bytes, created_at) \
1236 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
1237 ON CONFLICT(record_uuid, role) DO UPDATE SET \
1238 media_type = excluded.media_type, \
1239 size_bytes = excluded.size_bytes, \
1240 created_at = excluded.created_at \
1241 WHERE attachments.substrate = excluded.substrate \
1242 AND attachments.content_ref = excluded.content_ref",
1243 rusqlite::params![
1244 record_uuid,
1245 substrate,
1246 role,
1247 content_ref.as_str(),
1248 media_type,
1249 size_bytes,
1250 created_at,
1251 ],
1252 )?;
1253 if changed == 0 {
1254 return Err(SqliteError::InvalidData(format!(
1255 "attachment role {role:?} for record {record_uuid:?} conflicts with an existing substrate or content_ref"
1256 )));
1257 }
1258 Ok(())
1259}
1260
1261fn finalize_attachment_cutover_on_connection(
1262 conn: &Connection,
1263 now: i64,
1264) -> Result<(), SqliteError> {
1265 require_legacy_attachment_fences(conn)?;
1266 validate_canonical_legacy_refs(conn)?;
1267 validate_canonical_attachment_and_claim_refs(conn)?;
1268 validate_attachment_record_owners(conn)?;
1269 validate_legacy_content_backfill(conn)?;
1270
1271 let remaining_claims: i64 =
1272 conn.query_row("SELECT COUNT(*) FROM blob_gc_claims", [], |row| row.get(0))?;
1273 if remaining_claims != 0 {
1274 return Err(SqliteError::InvalidData(format!(
1275 "attachment cutover cannot finalize while {remaining_claims} blob GC claim rows remain"
1276 )));
1277 }
1278
1279 let uncovered_model: Option<String> = conn
1280 .query_row(
1281 "SELECT model.id FROM entities AS model \
1282 WHERE model.entity_type = 'moodboard_model' \
1283 AND model.content_ref IS NOT NULL \
1284 AND NOT EXISTS ( \
1285 SELECT 1 FROM attachments AS attachment \
1286 WHERE attachment.record_uuid = model.id \
1287 AND attachment.substrate = 'entity' \
1288 AND attachment.role = 'fann-network' \
1289 ) \
1290 LIMIT 1",
1291 [],
1292 |row| row.get(0),
1293 )
1294 .optional()?;
1295 if let Some(record_uuid) = uncovered_model {
1296 return Err(SqliteError::InvalidData(format!(
1297 "moodboard_model {record_uuid:?} has legacy content but no verified 'fann-network' attachment"
1298 )));
1299 }
1300
1301 conn.execute_batch(V21_ATTACHMENT_FENCES_UP)?;
1302 conn.execute_batch(
1303 "DROP TRIGGER entities_reject_claimed_blob_insert; \
1304 DROP TRIGGER entities_reject_claimed_blob_update; \
1305 DROP INDEX idx_entities_content_ref; \
1306 ALTER TABLE entities DROP COLUMN content_ref;",
1307 )?;
1308 conn.execute(
1309 "UPDATE attachment_cutover_state \
1310 SET state = 'complete', completed_at = ?1 \
1311 WHERE singleton = 1 AND state = 'incomplete'",
1312 [now],
1313 )?;
1314 Ok(())
1315}
1316
1317fn record_attachment_cutover_migration(conn: &Connection, now: i64) -> Result<(), SqliteError> {
1318 let migration = MIGRATIONS
1319 .iter()
1320 .find(|migration| migration.version == ATTACHMENT_CUTOVER_VERSION)
1321 .expect("V21 migration must be registered");
1322 conn.execute(
1323 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
1324 rusqlite::params![migration.version, migration.name, now],
1325 )?;
1326 Ok(())
1327}
1328
1329pub fn finalize_attachment_cutover(conn: &mut Connection) -> Result<(), SqliteError> {
1336 let admission = WriteAdmission::for_canonical_path(canonical_connection_database_path(conn)?)?;
1337 RawMigrationWriteUnit::new(conn, &admission)?
1338 .run(|conn| finalize_attachment_cutover_with_admission(conn, &admission))
1339}
1340
1341pub fn finalize_attachment_cutover_with_policy(
1343 conn: &mut Connection,
1344 policy: &MigrationWritePolicy,
1345) -> Result<(), SqliteError> {
1346 let admission =
1347 WriteAdmission::for_migration_policy(canonical_connection_database_path(conn)?, policy)?;
1348 RawMigrationWriteUnit::new(conn, &admission)?
1349 .run(|conn| finalize_attachment_cutover_with_admission(conn, &admission))
1350}
1351
1352pub(crate) fn finalize_attachment_cutover_with_admission(
1354 conn: &mut Connection,
1355 admission: &WriteAdmission,
1356) -> Result<(), SqliteError> {
1357 if attachment_cutover_status(conn)? == AttachmentCutoverStatus::Complete {
1358 return Ok(());
1359 }
1360 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?;
1361 if let Err(error) = admission.check() {
1362 let rollback = tx.rollback();
1363 return Err(capacity_refusal_after_rollback(
1364 conn,
1365 rollback,
1366 error,
1367 "attachment cutover finalization",
1368 ));
1369 }
1370 match attachment_cutover_status(&tx)? {
1371 AttachmentCutoverStatus::Complete => return Ok(()),
1372 AttachmentCutoverStatus::Pending => {
1373 return Err(SqliteError::InvalidData(
1374 "attachment cutover must complete stage 1 before finalization".into(),
1375 ))
1376 }
1377 AttachmentCutoverStatus::Incomplete => {}
1378 }
1379 let now = chrono::Utc::now().timestamp_micros();
1380 finalize_attachment_cutover_on_connection(&tx, now)?;
1381 record_attachment_cutover_migration(&tx, now)?;
1382 tx.commit()?;
1383 Ok(())
1384}
1385
1386fn read_applied_migration_ledger(
1388 conn: &Connection,
1389 through_version: u32,
1390) -> Result<Vec<(u32, String)>, SqliteError> {
1391 let mut stmt = conn.prepare(
1392 "SELECT version, name FROM _schema_migrations \
1393 WHERE version <= ?1 ORDER BY version ASC",
1394 )?;
1395 let rows = stmt
1396 .query_map([through_version], |row| {
1397 Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?))
1398 })?
1399 .collect::<Result<Vec<_>, _>>()?;
1400 Ok(rows)
1401}
1402
1403fn validate_applied_migration_versions(
1408 applied: &[(u32, String)],
1409 through_version: u32,
1410) -> Result<(), SqliteError> {
1411 let expected: Vec<&VersionedMigration> = MIGRATIONS
1412 .iter()
1413 .filter(|migration| migration.version <= through_version)
1414 .collect();
1415 let mut applied_index = 0;
1416
1417 for migration in expected {
1418 let Some((version, applied_name)) = applied.get(applied_index) else {
1419 return Err(SqliteError::InvalidData(format!(
1420 "migration history is missing version {} ('{}'); the applied ledger must be \
1421 the exact contiguous canonical sequence through version {through_version}",
1422 migration.version, migration.name,
1423 )));
1424 };
1425 if *version < migration.version {
1426 return Err(SqliteError::InvalidData(format!(
1427 "migration history contains unknown version {version} recorded as \
1428 '{applied_name}'; the applied ledger must contain only canonical versions"
1429 )));
1430 }
1431 if *version > migration.version {
1432 return Err(SqliteError::InvalidData(format!(
1433 "migration history is missing version {} ('{}'); found version {version} \
1434 next instead",
1435 migration.version, migration.name,
1436 )));
1437 }
1438 applied_index += 1;
1439 }
1440
1441 if let Some((version, name)) = applied.get(applied_index) {
1442 return Err(SqliteError::InvalidData(format!(
1443 "migration history contains unknown version {version} recorded as '{name}'; \
1444 the applied ledger must contain only canonical versions"
1445 )));
1446 }
1447
1448 Ok(())
1449}
1450
1451fn validate_applied_migration_names(
1452 applied: &[(u32, String)],
1453 through_version: u32,
1454 allow_known_v19_repairs: bool,
1455) -> Result<(), SqliteError> {
1456 for ((version, applied_name), migration) in applied.iter().zip(
1457 MIGRATIONS
1458 .iter()
1459 .filter(|migration| migration.version <= through_version),
1460 ) {
1461 debug_assert_eq!(*version, migration.version);
1462 if migration.name != applied_name.as_str() {
1463 if allow_known_v19_repairs && matches!(*version, 13 | 14) {
1464 continue;
1465 }
1466 return Err(SqliteError::InvalidData(format!(
1467 "migration version {version} is recorded under name '{applied_name}', \
1468 expected '{expected}'. This database's migration history does not match \
1469 the current binary; recreate it from the current schema or repair the \
1470 specific known divergence via a dedicated migration.",
1471 expected = migration.name,
1472 )));
1473 }
1474 }
1475
1476 Ok(())
1477}
1478
1479fn validate_applied_migration_ledger(
1483 conn: &Connection,
1484 through_version: u32,
1485) -> Result<(), SqliteError> {
1486 let applied = read_applied_migration_ledger(conn, through_version)?;
1487 validate_applied_migration_versions(&applied, through_version)?;
1488 validate_applied_migration_names(&applied, through_version, false)
1489}
1490
1491const MIGRATION_TRACKING_TABLE: &str = include_str!("../sql/schema-migrations-table.sql");
1492
1493const SCHEMA_VERSION_PROBE_SQL: &str = "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations";
1494
1495pub fn schema_version_probe() -> khive_storage::types::SqlStatement {
1497 khive_storage::types::SqlStatement::new(SCHEMA_VERSION_PROBE_SQL, Vec::new())
1498}
1499
1500pub fn read_schema_version(conn: &Connection) -> Result<u32, SqliteError> {
1506 match conn.query_row(SCHEMA_VERSION_PROBE_SQL, [], |row| row.get(0)) {
1507 Ok(version) => Ok(version),
1508 Err(rusqlite::Error::SqliteFailure(_, Some(ref msg)))
1509 if msg.contains("no such table: _schema_migrations") =>
1510 {
1511 Ok(0)
1512 }
1513 Err(e) => Err(e.into()),
1514 }
1515}
1516
1517pub fn inspect_schema_version(path: &std::path::Path) -> Result<u32, SqliteError> {
1522 let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1523 read_schema_version(&conn)
1524}
1525
1526pub fn inspect_schema_is_current(path: &std::path::Path) -> Result<u32, SqliteError> {
1529 let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1530 validate_schema_is_current(&conn)
1531}
1532
1533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1536pub struct PageAccounting {
1537 pub page_size: u64,
1539 pub page_count: u64,
1541 pub freelist_count: u64,
1544}
1545
1546pub fn inspect_page_accounting(path: &std::path::Path) -> Result<PageAccounting, SqliteError> {
1549 let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1550 let read = |pragma: &str| -> Result<u64, SqliteError> {
1551 let value: i64 = conn.query_row(pragma, [], |row| row.get(0))?;
1552 u64::try_from(value)
1553 .map_err(|_| SqliteError::InvalidData(format!("{pragma} returned {value}")))
1554 };
1555 Ok(PageAccounting {
1556 page_size: read("PRAGMA page_size")?,
1557 page_count: read("PRAGMA page_count")?,
1558 freelist_count: read("PRAGMA freelist_count")?,
1559 })
1560}
1561
1562pub fn inspect_integrity(path: &std::path::Path) -> Result<Vec<String>, SqliteError> {
1568 let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1569 let mut statement = conn.prepare(&format!("PRAGMA integrity_check({})", i32::MAX))?;
1570 let rows = statement
1571 .query_map([], |row| row.get::<_, String>(0))?
1572 .collect::<Result<Vec<_>, _>>()?;
1573 Ok(rows)
1574}
1575
1576pub fn validate_schema_is_current(conn: &Connection) -> Result<u32, SqliteError> {
1584 let current_version = read_schema_version(conn)?;
1585 let latest_version = latest_schema_version();
1586
1587 if current_version < latest_version {
1588 return Err(SqliteError::InvalidData(format!(
1589 "read-only database schema version {current_version} is behind the latest known \
1590 migration {latest_version}; migrate a writable copy with this build before opening \
1591 the snapshot read-only"
1592 )));
1593 }
1594 if current_version > latest_version {
1595 return Err(SqliteError::InvalidData(format!(
1596 "read-only database schema version {current_version} is ahead of the latest known \
1597 migration {latest_version}; use a compatible newer build or recreate the snapshot"
1598 )));
1599 }
1600
1601 validate_applied_migration_ledger(conn, current_version)?;
1608 if current_version >= ATTACHMENT_CUTOVER_VERSION
1612 && attachment_cutover_status(conn)? != AttachmentCutoverStatus::Complete
1613 {
1614 return Err(SqliteError::InvalidData(
1615 "read-only database has not completed the V21 attachment cutover".into(),
1616 ));
1617 }
1618
1619 if current_version >= MEMORY_VISIBILITY_CUTOVER_VERSION {
1620 memory_visibility::validate_cutover(conn)?;
1621 }
1622
1623 Ok(current_version)
1624}
1625
1626pub fn validate_memory_visibility_cutover(conn: &Connection) -> Result<(), SqliteError> {
1629 validate_schema_is_current(conn).map(|_| ())
1630}
1631
1632#[cfg(test)]
1633pub(crate) mod test_sync {
1634 use std::sync::atomic::AtomicU32;
1635 use std::sync::{Arc, Barrier, Mutex};
1636
1637 pub(crate) static STALE_READ_BARRIER: Mutex<Option<Arc<Barrier>>> = Mutex::new(None);
1641 pub(crate) static LOCKED_FAST_FORWARDS: AtomicU32 = AtomicU32::new(0);
1643 pub(crate) static BUSY_OBSERVED: std::sync::atomic::AtomicBool =
1647 std::sync::atomic::AtomicBool::new(false);
1648
1649 pub(crate) fn record_busy(_count: i32) -> bool {
1652 BUSY_OBSERVED.store(true, std::sync::atomic::Ordering::SeqCst);
1653 std::thread::sleep(std::time::Duration::from_millis(1));
1654 true
1655 }
1656
1657 pub(crate) static WINNER_COMMITTED: std::sync::atomic::AtomicBool =
1660 std::sync::atomic::AtomicBool::new(false);
1661 pub(crate) static LOSER_SAW_WINNER_COMMIT: std::sync::atomic::AtomicBool =
1666 std::sync::atomic::AtomicBool::new(false);
1667
1668 std::thread_local! {
1669 pub(crate) static PARTICIPATE: std::cell::Cell<bool> =
1672 const { std::cell::Cell::new(false) };
1673 pub(crate) static FIRST_BEGIN_DONE: std::cell::Cell<bool> =
1675 const { std::cell::Cell::new(false) };
1676 }
1677}
1678
1679fn canonical_connection_database_path(conn: &Connection) -> Result<Option<PathBuf>, SqliteError> {
1688 let configured = conn.path().unwrap_or_default();
1689 let raw_path = if configured.is_empty() {
1690 conn.query_row(
1691 "SELECT file FROM pragma_database_list WHERE name = 'main'",
1692 [],
1693 |row| row.get::<_, String>(0),
1694 )?
1695 } else {
1696 configured.to_string()
1697 };
1698
1699 if raw_path.is_empty() {
1700 return Ok(None);
1701 }
1702 let canonical = std::fs::canonicalize(&raw_path).map_err(SqliteError::Io)?;
1703 #[cfg(any(unix, windows))]
1704 crate::pool::opened_sqlite_file_identity(conn, &canonical)?;
1705 Ok(Some(canonical))
1706}
1707
1708fn validate_database_gc_owner(
1709 conn: &Connection,
1710 owner: &DatabaseGcOwnerGuard,
1711) -> Result<(), SqliteError> {
1712 let connection_path = canonical_connection_database_path(conn)?;
1713 if owner.database_path() != connection_path.as_deref() {
1714 return Err(SqliteError::InvalidData(format!(
1715 "database GC owner targets {:?}, but migration connection targets {:?}",
1716 owner.database_path(),
1717 connection_path.as_deref(),
1718 )));
1719 }
1720 Ok(())
1721}
1722
1723pub fn run_migrations(conn: &mut Connection) -> Result<u32, SqliteError> {
1724 let database_path = canonical_connection_database_path(conn)?;
1725 let admission = WriteAdmission::for_canonical_path(database_path.clone())?;
1726 run_raw_migrations_with_admission(conn, database_path, &admission)
1727}
1728
1729pub fn run_migrations_with_policy(
1731 conn: &mut Connection,
1732 policy: &MigrationWritePolicy,
1733) -> Result<u32, SqliteError> {
1734 let database_path = canonical_connection_database_path(conn)?;
1735 let admission = WriteAdmission::for_migration_policy(database_path.clone(), policy)?;
1736 run_raw_migrations_with_admission(conn, database_path, &admission)
1737}
1738
1739fn run_raw_migrations_with_admission(
1740 conn: &mut Connection,
1741 database_path: Option<PathBuf>,
1742 admission: &WriteAdmission,
1743) -> Result<u32, SqliteError> {
1744 if let Some(database_path) = database_path {
1745 let owner = try_acquire_database_gc_owner_for_path(database_path).map_err(|error| {
1750 SqliteError::InvalidData(format!(
1751 "failed to acquire database GC owner before schema migration: {error}"
1752 ))
1753 })?;
1754 return run_migrations_with_database_gc_owner(
1755 &mut RawMigrationTransactions::new(conn, admission),
1756 &owner,
1757 admission,
1758 );
1759 }
1760
1761 run_versioned_migrations(
1764 &mut RawMigrationTransactions::new(conn, admission),
1765 None,
1766 admission,
1767 )
1768}
1769
1770pub(crate) fn run_migrations_with_database_gc_owner(
1771 writes: &mut impl MigrationTransactions,
1772 owner: &DatabaseGcOwnerGuard,
1773 admission: &WriteAdmission,
1774) -> Result<u32, SqliteError> {
1775 run_versioned_migrations(writes, Some(owner), admission)
1776}
1777
1778fn with_migration_busy_timeout<T>(
1784 conn: &mut Connection,
1785 operation: impl FnOnce(&mut Connection) -> Result<T, SqliteError>,
1786) -> Result<T, SqliteError> {
1787 let prior_busy_ms: i64 = conn.query_row("PRAGMA busy_timeout", [], |row| row.get(0))?;
1788 let raised = prior_busy_ms < 5_000;
1789 if raised {
1790 conn.busy_timeout(std::time::Duration::from_secs(5))?;
1791 }
1792 let result = operation(conn);
1793 if raised {
1794 let _ = conn.busy_timeout(std::time::Duration::from_millis(prior_busy_ms.max(0) as u64));
1795 }
1796 result
1797}
1798
1799enum MigrationStep {
1801 Applied,
1803 AppliedBySibling(u32),
1806 Deferred,
1808}
1809
1810fn run_versioned_migrations(
1817 writes: &mut impl MigrationTransactions,
1818 owner: Option<&DatabaseGcOwnerGuard>,
1819 admission: &WriteAdmission,
1820) -> Result<u32, SqliteError> {
1821 let current_version = writes.admitted(|conn| {
1822 if let Some(owner) = owner {
1823 validate_database_gc_owner(conn, owner)?;
1824 }
1825 with_migration_busy_timeout(conn, |conn| bootstrap_migration_ledger(conn, admission))
1826 })?;
1827
1828 #[cfg(test)]
1833 if test_sync::PARTICIPATE.with(|p| p.get()) {
1834 let barrier = test_sync::STALE_READ_BARRIER.lock().unwrap().clone();
1835 if let Some(barrier) = barrier {
1836 barrier.wait();
1837 }
1838 }
1839 let latest_version = latest_schema_version();
1840
1841 let mut applied_version = current_version;
1842 let mut skip_through = current_version;
1846
1847 for migration in MIGRATIONS {
1848 if migration.version <= skip_through {
1849 applied_version = applied_version.max(migration.version);
1850 continue;
1851 }
1852 let step = writes.admitted(|conn| {
1853 with_migration_busy_timeout(conn, |conn| {
1854 apply_versioned_migration(conn, migration, admission, latest_version)
1855 })
1856 })?;
1857 match step {
1858 MigrationStep::Applied => applied_version = migration.version,
1859 MigrationStep::AppliedBySibling(sibling_version) => {
1860 skip_through = sibling_version.min(latest_version);
1861 applied_version = applied_version.max(migration.version);
1862 }
1863 MigrationStep::Deferred => break,
1864 }
1865 }
1866
1867 writes.admitted(|conn| validate_applied_migration_ledger(conn, applied_version))?;
1871
1872 Ok(applied_version)
1873}
1874
1875fn bootstrap_migration_ledger(
1879 conn: &mut Connection,
1880 admission: &WriteAdmission,
1881) -> Result<u32, SqliteError> {
1882 admission.check()?;
1883 conn.execute_batch(MIGRATION_TRACKING_TABLE)?;
1884 require_autocommit(conn, "migration-tracking bootstrap")?;
1885
1886 let current_version: u32 = read_schema_version(conn)?;
1887
1888 let latest_version = latest_schema_version();
1894 if current_version > latest_version {
1895 return Err(SqliteError::InvalidData(format!(
1896 "database schema version {current_version} is ahead of the latest known migration \
1897 {latest_version}. This database predates the consolidated baseline (ADR-015) or was \
1898 written by a newer build. Recreate it from the current schema; in-place downgrade is \
1899 not supported."
1900 )));
1901 }
1902
1903 let applied = read_applied_migration_ledger(conn, current_version)?;
1910 validate_applied_migration_versions(&applied, current_version)?;
1911 validate_applied_migration_names(&applied, current_version, current_version < 19)?;
1912
1913 Ok(current_version)
1914}
1915
1916fn apply_versioned_migration(
1919 conn: &mut Connection,
1920 migration: &VersionedMigration,
1921 admission: &WriteAdmission,
1922 latest_version: u32,
1923) -> Result<MigrationStep, SqliteError> {
1924 #[cfg(test)]
1928 let instrumented_first_begin =
1929 test_sync::PARTICIPATE.with(|p| p.get()) && !test_sync::FIRST_BEGIN_DONE.with(|f| f.get());
1930 #[cfg(test)]
1931 if instrumented_first_begin {
1932 test_sync::FIRST_BEGIN_DONE.with(|f| f.set(true));
1933 }
1934 #[cfg(test)]
1937 if test_sync::PARTICIPATE.with(|p| p.get()) {
1938 conn.busy_handler(Some(test_sync::record_busy))?;
1939 }
1940 let tx = conn
1941 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1942 .map_err(|e| SqliteError::Migration {
1943 version: migration.version,
1944 error: e.to_string(),
1945 })?;
1946 if let Err(error) = admission.check() {
1947 let rollback = tx.rollback();
1948 return Err(capacity_refusal_after_rollback(
1949 conn,
1950 rollback,
1951 error,
1952 "core schema migration",
1953 ));
1954 }
1955
1956 let sibling_version: u32 = tx
1960 .query_row(SCHEMA_VERSION_PROBE_SQL, [], |row| row.get(0))
1961 .map_err(|e| SqliteError::Migration {
1962 version: migration.version,
1963 error: e.to_string(),
1964 })?;
1965 #[cfg(test)]
1966 if instrumented_first_begin {
1967 use std::sync::atomic::Ordering::SeqCst;
1968 if sibling_version == 0 {
1969 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1975 while !test_sync::BUSY_OBSERVED.load(SeqCst) && std::time::Instant::now() < deadline {
1976 std::thread::yield_now();
1977 }
1978 } else {
1979 test_sync::LOSER_SAW_WINNER_COMMIT
1983 .store(test_sync::WINNER_COMMITTED.load(SeqCst), SeqCst);
1984 }
1985 }
1986
1987 if sibling_version > latest_version {
1992 return Err(SqliteError::InvalidData(format!(
1993 "database schema version {sibling_version} is ahead of the latest known \
1994 migration {latest_version} (committed by a concurrent process while this \
1995 one waited for the migration write lock). This build cannot run against \
1996 the newer schema; upgrade the binary or recreate the database."
1997 )));
1998 }
1999
2000 if sibling_version >= migration.version {
2001 #[cfg(test)]
2002 test_sync::LOCKED_FAST_FORWARDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2003 return Ok(MigrationStep::AppliedBySibling(sibling_version));
2004 }
2005
2006 if migration.version == 46 {
2007 memory_visibility::capture_pre_v46(&tx).map_err(|error| SqliteError::Migration {
2008 version: migration.version,
2009 error: error.to_string(),
2010 })?;
2011 #[cfg(test)]
2012 memory_visibility::test_state::stop_at(memory_visibility::test_state::Stop::AfterCapture)?;
2013 }
2014
2015 if migration.version == ATTACHMENT_CUTOVER_VERSION {
2016 let status = attachment_cutover_status(&tx).map_err(|e| SqliteError::Migration {
2017 version: migration.version,
2018 error: e.to_string(),
2019 })?;
2020 let legacy_refs: i64 = tx
2021 .query_row(
2022 "SELECT COUNT(*) FROM entities WHERE content_ref IS NOT NULL",
2023 [],
2024 |row| row.get(0),
2025 )
2026 .map_err(|e| SqliteError::Migration {
2027 version: migration.version,
2028 error: e.to_string(),
2029 })?;
2030
2031 if status == AttachmentCutoverStatus::Incomplete || legacy_refs != 0 {
2035 drop(tx);
2036 return Ok(MigrationStep::Deferred);
2037 }
2038 if status != AttachmentCutoverStatus::Pending {
2039 return Err(SqliteError::Migration {
2040 version: migration.version,
2041 error: format!("unexpected attachment cutover state {status:?}"),
2042 });
2043 }
2044
2045 let now = chrono::Utc::now().timestamp_micros();
2046 stage_attachment_cutover_on_connection(&tx, now).map_err(|e| SqliteError::Migration {
2047 version: migration.version,
2048 error: e.to_string(),
2049 })?;
2050 finalize_attachment_cutover_on_connection(&tx, now).map_err(|e| {
2051 SqliteError::Migration {
2052 version: migration.version,
2053 error: e.to_string(),
2054 }
2055 })?;
2056 } else if migration.version == 36 {
2057 crate::stores::event::ensure_operation_attribution_columns(&tx).map_err(|error| {
2059 SqliteError::Migration {
2060 version: migration.version,
2061 error: error.to_string(),
2062 }
2063 })?;
2064 } else if migration.version == 44 {
2065 migrate_outbound_due_key(&tx).map_err(|error| SqliteError::Migration {
2066 version: migration.version,
2067 error: error.to_string(),
2068 })?;
2069 } else if migration.version == 48 {
2070 migrate_acknowledgement_journal(&tx).map_err(|error| SqliteError::Migration {
2071 version: migration.version,
2072 error: error.to_string(),
2073 })?;
2074 } else if migration.name == SESSION_IDENTITY_MIGRATION_NAME {
2075 tx.execute_batch(migration.up)
2076 .map_err(|error| SqliteError::Migration {
2077 version: migration.version,
2078 error: error.to_string(),
2079 })?;
2080 session_identity_migration::apply(&tx).map_err(|error| SqliteError::Migration {
2081 version: migration.version,
2082 error: error.to_string(),
2083 })?;
2084 } else {
2085 tx.execute_batch(migration.up)
2086 .map_err(|e| SqliteError::Migration {
2087 version: migration.version,
2088 error: e.to_string(),
2089 })?;
2090 }
2091
2092 let visibility_counts = if migration.version == MEMORY_VISIBILITY_CUTOVER_VERSION {
2093 Some(
2094 memory_visibility::cutover_counts(&tx).map_err(|error| SqliteError::Migration {
2095 version: migration.version,
2096 error: error.to_string(),
2097 })?,
2098 )
2099 } else {
2100 None
2101 };
2102
2103 if migration.version == 19 {
2110 tx.execute_batch(
2111 "UPDATE _schema_migrations SET name = 'list_cursor_sequences' WHERE version = 13;\n\
2112 UPDATE _schema_migrations SET name = 'graph_edges_id_unique' WHERE version = 14;",
2113 )
2114 .map_err(|e| SqliteError::Migration {
2115 version: migration.version,
2116 error: e.to_string(),
2117 })?;
2118 }
2119
2120 let now = chrono::Utc::now().timestamp_micros();
2121 tx.execute(
2122 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3) \
2123 ON CONFLICT(version) DO NOTHING",
2124 rusqlite::params![migration.version, migration.name, now],
2125 )
2126 .map_err(|e| SqliteError::Migration {
2127 version: migration.version,
2128 error: e.to_string(),
2129 })?;
2130
2131 #[cfg(test)]
2132 if instrumented_first_begin {
2133 test_sync::WINNER_COMMITTED.store(true, std::sync::atomic::Ordering::SeqCst);
2134 }
2135
2136 #[cfg(test)]
2137 if migration.version == MEMORY_VISIBILITY_CUTOVER_VERSION {
2138 memory_visibility::test_state::stop_at(
2139 memory_visibility::test_state::Stop::BeforeCutoverCommit,
2140 )?;
2141 }
2142 tx.commit().map_err(|e| SqliteError::Migration {
2143 version: migration.version,
2144 error: e.to_string(),
2145 })?;
2146 if let Some(counts) = visibility_counts {
2147 memory_visibility::log_counts(&counts, conn.path().unwrap_or(":memory:"));
2148 }
2149 #[cfg(test)]
2150 if migration.version == 46 {
2151 memory_visibility::test_state::stop_at(
2152 memory_visibility::test_state::Stop::AfterV46Commit,
2153 )?;
2154 }
2155
2156 Ok(MigrationStep::Applied)
2157}
2158
2159#[derive(Debug)]
2160pub struct EmbeddingModelRegistryRecord {
2161 pub engine_name: String,
2163 pub model_id: String,
2165 pub key_version: String,
2167 pub dimensions: u32,
2169 pub status: String,
2171 pub activated_at: Option<i64>,
2173 pub superseded_at: Option<i64>,
2175}
2176
2177pub fn query_embedding_models(
2183 db: Option<&std::path::Path>,
2184 engine_filter: Option<&str>,
2185) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
2186 let path = db.map(std::path::Path::to_path_buf).unwrap_or_else(|| {
2187 std::env::var("HOME")
2188 .map(std::path::PathBuf::from)
2189 .unwrap_or_else(|_| std::path::PathBuf::from("."))
2190 .join(".khive/khive.db")
2191 });
2192 if !path.exists() {
2193 return Ok(Vec::new());
2194 }
2195 let conn = Connection::open_with_flags(
2196 path,
2197 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
2198 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
2199 | rusqlite::OpenFlags::SQLITE_OPEN_URI,
2200 )?;
2201 query_embedding_models_conn(&conn, engine_filter)
2202}
2203
2204pub(crate) fn query_embedding_models_conn(
2208 conn: &Connection,
2209 engine_filter: Option<&str>,
2210) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
2211 let exists: bool = conn.query_row(
2212 "SELECT COUNT(*) > 0 FROM sqlite_master \
2213 WHERE type='table' AND name='_embedding_models'",
2214 [],
2215 |row| row.get(0),
2216 )?;
2217 if !exists {
2218 return Ok(Vec::new());
2219 }
2220
2221 let sql = if engine_filter.is_some() {
2222 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
2223 FROM _embedding_models WHERE engine_name = ?1 \
2224 ORDER BY engine_name, activated_at IS NULL, activated_at"
2225 } else {
2226 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
2227 FROM _embedding_models \
2228 ORDER BY engine_name, activated_at IS NULL, activated_at"
2229 };
2230 let mut stmt = conn.prepare(sql)?;
2231 let map_row = |row: &rusqlite::Row<'_>| {
2232 let dim_raw: i64 = row.get(3)?;
2233 let dimensions = u32::try_from(dim_raw).map_err(|_| {
2234 rusqlite::Error::FromSqlConversionFailure(
2235 3,
2236 rusqlite::types::Type::Integer,
2237 Box::new(std::io::Error::other(format!(
2238 "_embedding_models.dim value {dim_raw} is outside the valid u32 range [0, {}]",
2239 u32::MAX,
2240 ))),
2241 )
2242 })?;
2243 Ok(EmbeddingModelRegistryRecord {
2244 engine_name: row.get(0)?,
2245 model_id: row.get(1)?,
2246 key_version: row.get(2)?,
2247 dimensions,
2248 status: row.get(4)?,
2249 activated_at: row.get(5)?,
2250 superseded_at: row.get(6)?,
2251 })
2252 };
2253
2254 if let Some(engine) = engine_filter {
2255 stmt.query_map([engine], map_row)?
2256 .collect::<Result<Vec<_>, _>>()
2257 .map_err(Into::into)
2258 } else {
2259 stmt.query_map([], map_row)?
2260 .collect::<Result<Vec<_>, _>>()
2261 .map_err(Into::into)
2262 }
2263}
2264
2265#[cfg(test)]
2267pub(crate) fn migration_test_policy() -> MigrationWritePolicy {
2268 MigrationWritePolicy::new(
2269 crate::DiskGuardEnvironment::capture()
2270 .resolve(None, None)
2271 .expect("test disk policy"),
2272 crate::PoolConfig::for_test()
2273 .volume_lock_dir
2274 .expect("test volume-lock directory"),
2275 )
2276 .expect("valid test migration policy")
2277}
2278
2279#[cfg(test)]
2280pub(crate) fn run_migrations_for_test(conn: &mut Connection) -> Result<u32, SqliteError> {
2281 run_migrations_with_policy(conn, &migration_test_policy())
2282}
2283
2284#[cfg(test)]
2285fn apply_schema_plan_for_test(
2286 conn: &mut Connection,
2287 plan: &ServiceSchemaPlan,
2288) -> Result<(), SqliteError> {
2289 apply_schema_plan_with_policy(conn, plan, &migration_test_policy())
2290}
2291
2292#[cfg(test)]
2293fn stage_attachment_cutover_for_test(conn: &mut Connection) -> Result<(), SqliteError> {
2294 stage_attachment_cutover_with_policy(conn, &migration_test_policy())
2295}
2296
2297#[cfg(test)]
2298fn finalize_attachment_cutover_for_test(conn: &mut Connection) -> Result<(), SqliteError> {
2299 finalize_attachment_cutover_with_policy(conn, &migration_test_policy())
2300}
2301
2302#[cfg(test)]
2307#[path = "entity_version_migration_measurement.rs"]
2308mod entity_version_measurement;
2309
2310#[cfg(test)]
2311#[path = "migrations_tests.rs"]
2312mod tests;
2313
2314#[cfg(test)]
2315#[path = "raw_migration_settlement_tests.rs"]
2316mod raw_settlement_tests;
2317
2318#[cfg(test)]
2319#[path = "git_note_index_migration_tests.rs"]
2320mod git_note_indexes;
2321
2322#[cfg(test)]
2323#[path = "entity_list_index_migration_tests.rs"]
2324mod entity_list_indexes;
2325
2326#[cfg(test)]
2327#[path = "schedule_core_index_migration_tests.rs"]
2328mod schedule_core_index_migration_tests;
2329
2330#[cfg(test)]
2331#[path = "comm_core_index_migration_tests.rs"]
2332mod comm_core_index_migration_tests;
2333
2334#[cfg(test)]
2335#[path = "knowledge_cursor_tag_migration_tests.rs"]
2336mod knowledge_cursor_tag_migration_tests;