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 V57_UP: &str = include_str!("../sql/057-retrieval-snapshots.sql");
416const V48_UP: &str = include_str!("../sql/048-acknowledgement-journal-a-table.sql");
417const ACKNOWLEDGEMENT_JOURNAL_INDEX: &str =
418 include_str!("../sql/048-acknowledgement-journal-b-index.sql");
419
420pub(crate) fn migrate_acknowledgement_journal(
422 tx: &rusqlite::Transaction<'_>,
423) -> rusqlite::Result<()> {
424 let columns: i64 = tx.query_row(
425 "SELECT count(*) FROM pragma_table_info('comm_ack_work') \
426 WHERE name IN ('attempt_count','not_before','retirement_reason')",
427 [],
428 |row| row.get(0),
429 )?;
430 match columns {
431 0 => tx.execute_batch(V48_UP)?,
432 3 => {}
433 _ => return Err(rusqlite::Error::InvalidQuery),
434 }
435 tx.execute_batch(ACKNOWLEDGEMENT_JOURNAL_INDEX)
436}
437
438const V21_STAGE_UP: &str = include_str!("../sql/021-attachments-a-stage.sql");
439
440const V21_ATTACHMENT_FENCES_UP: &str = include_str!("../sql/021-attachments-b-claim-fences.sql");
441
442pub const ATTACHMENT_CUTOVER_VERSION: u32 = 21;
444
445pub fn latest_schema_version() -> u32 {
450 MIGRATIONS.last().map(|m| m.version).unwrap_or(0)
451}
452
453pub const ANN_WRITE_LOG_DDL: &str = V11_UP;
461
462pub const ANN_WRITE_LOG_MODEL_SEQ_INDEX_DDL: &str = V12_UP;
467
468pub const ANN_CONSUMER_PENDING_DDL: &str = include_str!("../sql/ann-consumer-pending-ddl.sql");
475
476pub const VECTOR_PROVENANCE_DDL: &str = V43_UP;
479
480pub const EMBEDDING_MODELS_DDL: &str = include_str!("../sql/embedding-models-ddl.sql");
486
487pub const MIGRATIONS: &[VersionedMigration] = &[
494 VersionedMigration {
495 version: 1,
496 name: "initial_schema",
497 up: V1_UP,
498 },
499 VersionedMigration {
500 version: 2,
501 name: "narrow_fts_sections_update_trigger",
502 up: V2_UP,
503 },
504 VersionedMigration {
505 version: 3,
506 name: "backfill_domain_mirror_atoms",
507 up: V3_UP,
508 },
509 VersionedMigration {
510 version: 4,
511 name: "fts_consolidation",
512 up: V4_UP,
513 },
514 VersionedMigration {
515 version: 5,
516 name: "unique_comm_message_external_id",
517 up: V5_UP,
518 },
519 VersionedMigration {
520 version: 6,
521 name: "brain_retune_driver",
522 up: V6_UP,
523 },
524 VersionedMigration {
525 version: 7,
526 name: "notes_seq",
527 up: V7_UP,
528 },
529 VersionedMigration {
530 version: 8,
531 name: "notes_seq_repair",
532 up: V8_UP,
533 },
534 VersionedMigration {
535 version: 9,
536 name: "entities_name_ci_index",
537 up: V9_UP,
538 },
539 VersionedMigration {
540 version: 10,
541 name: "entities_content_ref",
542 up: V10_UP,
543 },
544 VersionedMigration {
545 version: 11,
546 name: "ann_write_log",
547 up: V11_UP,
548 },
549 VersionedMigration {
550 version: 12,
551 name: "ann_write_log_model_seq_index",
552 up: V12_UP,
553 },
554 VersionedMigration {
555 version: 13,
556 name: "list_cursor_sequences",
557 up: V13_UP,
558 },
559 VersionedMigration {
560 version: 14,
561 name: "graph_edges_id_unique",
562 up: V14_UP,
563 },
564 VersionedMigration {
565 version: 15,
566 name: "serve_ledger_attribution",
567 up: V15_UP,
568 },
569 VersionedMigration {
570 version: 16,
571 name: "gtd_dependency_cycle_guards",
572 up: V16_UP,
573 },
574 VersionedMigration {
575 version: 17,
576 name: "agents_ddl",
577 up: V17_UP,
578 },
579 VersionedMigration {
580 version: 18,
581 name: "ann_consumer_pending",
582 up: V18_UP,
583 },
584 VersionedMigration {
585 version: 19,
586 name: "list_cursor_backfill_repair",
587 up: V19_UP,
588 },
589 VersionedMigration {
590 version: 20,
591 name: "blob_gc_claims",
592 up: V20_UP,
593 },
594 VersionedMigration {
595 version: ATTACHMENT_CUTOVER_VERSION,
596 name: "attachments_first_class",
597 up: V21_STAGE_UP,
601 },
602 VersionedMigration {
603 version: 22,
604 name: "notes_unread_probe_recipient",
605 up: V22_UP,
606 },
607 VersionedMigration {
608 version: 23,
609 name: "fts_record_kind",
610 up: V23_UP,
611 },
612 VersionedMigration {
613 version: 24,
614 name: "fts_rowid_map",
615 up: V24_UP,
616 },
617 VersionedMigration {
618 version: 25,
619 name: "notes_unread_probe_recipient_direction",
620 up: V25_UP,
621 },
622 VersionedMigration {
623 version: 26,
624 name: "knowledge_fts_repair",
625 up: V26_UP,
626 },
627 VersionedMigration {
628 version: 27,
629 name: "notes_hot_property_indexes",
630 up: V27_UP,
631 },
632 VersionedMigration {
633 version: 28,
634 name: "notes_key",
635 up: V28_UP,
636 },
637 VersionedMigration {
638 version: 29,
639 name: "note_streams",
640 up: V29_UP,
641 },
642 VersionedMigration {
643 version: 30,
644 name: "tool_source_mounts",
645 up: V30_UP,
646 },
647 VersionedMigration {
648 version: 31,
649 name: "note_versions",
650 up: V31_UP,
651 },
652 VersionedMigration {
653 version: 32,
654 name: "knowledge_count_indexes",
655 up: V32_UP,
656 },
657 VersionedMigration {
658 version: 33,
659 name: "notes_message_recipient_direction",
660 up: V33_UP,
661 },
662 VersionedMigration {
663 version: 34,
664 name: "notes_namespace_created",
665 up: V34_UP,
666 },
667 VersionedMigration {
668 version: 35,
669 name: "notes_unread_probe_recipient_type_direction",
670 up: V35_UP,
671 },
672 VersionedMigration {
673 version: 36,
674 name: "events_operation_attribution",
675 up: V36_UP,
676 },
677 VersionedMigration {
678 version: 37,
679 name: "entity_versions",
680 up: V37_UP,
681 },
682 VersionedMigration {
683 version: 38,
684 name: "entities_legacy_type_index",
685 up: V38_UP,
686 },
687 VersionedMigration {
688 version: 39,
689 name: "knowledge_cursor_indexes",
690 up: V39_UP,
691 },
692 VersionedMigration {
693 version: 40,
694 name: SESSION_IDENTITY_MIGRATION_NAME,
695 up: SESSION_IDENTITY_UP,
696 },
697 VersionedMigration {
698 version: 41,
699 name: "sender_transport",
700 up: V41_UP,
701 },
702 VersionedMigration {
703 version: 42,
704 name: "comm_external_id_channel_scope",
705 up: V42_UP,
706 },
707 VersionedMigration {
708 version: 43,
709 name: "vector_provenance",
710 up: V43_UP,
711 },
712 VersionedMigration {
713 version: 44,
714 name: "comm_outbound_due",
715 up: V44_UP,
716 },
717 VersionedMigration {
718 version: RECIPIENT_TRANSPORT_VERSION,
719 name: "recipient_transport",
720 up: V45_UP,
721 },
722 VersionedMigration {
723 version: 46,
724 name: "memory_visibility_receipts",
725 up: V46_UP,
726 },
727 VersionedMigration {
728 version: 47,
729 name: "attachment_role_quarantine",
730 up: V47_UP,
731 },
732 VersionedMigration {
733 version: 48,
734 name: "acknowledgement_journal",
735 up: V48_UP,
736 },
737 VersionedMigration {
738 version: 49,
739 name: "git_note_property_indexes",
740 up: V49_UP,
741 },
742 VersionedMigration {
743 version: 50,
744 name: "entity_list_plans",
745 up: V50_UP,
746 },
747 VersionedMigration {
748 version: 51,
749 name: "schedule_core_indexes",
750 up: V51_UP,
751 },
752 VersionedMigration {
753 version: 52,
754 name: "comm_core_indexes",
755 up: V52_UP,
756 },
757 VersionedMigration {
758 version: 53,
759 name: "entity_kind_list_order",
760 up: V53_UP,
761 },
762 VersionedMigration {
763 version: MEMORY_VISIBILITY_CUTOVER_VERSION,
764 name: "memory_visibility_epochs",
765 up: V54_UP,
766 },
767 VersionedMigration {
768 version: 55,
769 name: "knowledge_atom_cursor_all_live_tags",
770 up: V55_UP,
771 },
772 VersionedMigration {
773 version: 56,
774 name: "atom_weight_tables",
775 up: V56_UP,
776 },
777 VersionedMigration {
778 version: 57,
779 name: "retrieval_snapshot_tables",
780 up: V57_UP,
781 },
782];
783
784#[derive(Clone, Copy, Debug, Eq, PartialEq)]
786pub enum AttachmentCutoverStatus {
787 Pending,
789 Incomplete,
791 Complete,
793}
794
795fn schema_object_exists(
796 conn: &Connection,
797 object_type: &str,
798 name: &str,
799) -> Result<bool, SqliteError> {
800 conn.query_row(
801 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = ?1 AND name = ?2",
802 rusqlite::params![object_type, name],
803 |row| row.get(0),
804 )
805 .map_err(Into::into)
806}
807
808fn schema_column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool, SqliteError> {
809 conn.query_row(
810 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = ?2",
811 rusqlite::params![table, column],
812 |row| row.get(0),
813 )
814 .map_err(Into::into)
815}
816
817fn require_attachment_schema_objects(
818 conn: &Connection,
819 objects: &[(&str, &str)],
820 phase: &str,
821) -> Result<(), SqliteError> {
822 for (object_type, name) in objects {
823 if !schema_object_exists(conn, object_type, name)? {
824 return Err(SqliteError::InvalidData(format!(
825 "attachment cutover {phase} state is missing {object_type} {name:?}"
826 )));
827 }
828 }
829 Ok(())
830}
831
832fn validate_incomplete_attachment_schema(conn: &Connection) -> Result<(), SqliteError> {
833 require_attachment_schema_objects(
834 conn,
835 &[
836 ("table", "attachments"),
837 ("index", "idx_attachments_content_ref"),
838 ],
839 "incomplete",
840 )?;
841 require_legacy_attachment_fences(conn)
842}
843
844fn validate_complete_attachment_schema(conn: &Connection) -> Result<(), SqliteError> {
845 require_attachment_schema_objects(
846 conn,
847 &[
848 ("table", "attachments"),
849 ("table", "blob_gc_claims"),
850 ("index", "idx_attachments_content_ref"),
851 ("index", "idx_blob_gc_claims_content_ref"),
852 ("trigger", "attachments_reject_claimed_blob_insert"),
853 ("trigger", "attachments_reject_claimed_blob_update"),
854 ],
855 "complete",
856 )?;
857 if schema_column_exists(conn, "entities", "content_ref")? {
858 return Err(SqliteError::InvalidData(
859 "attachment cutover is complete but entities.content_ref still exists".into(),
860 ));
861 }
862 for (object_type, name) in [
863 ("index", "idx_entities_content_ref"),
864 ("trigger", "entities_reject_claimed_blob_insert"),
865 ("trigger", "entities_reject_claimed_blob_update"),
866 ] {
867 if schema_object_exists(conn, object_type, name)? {
868 return Err(SqliteError::InvalidData(format!(
869 "attachment cutover is complete but legacy {object_type} {name:?} still exists"
870 )));
871 }
872 }
873 Ok(())
874}
875
876pub fn attachment_cutover_status(
881 conn: &Connection,
882) -> Result<AttachmentCutoverStatus, SqliteError> {
883 let version = read_schema_version(conn)?;
884 let marker_table = schema_object_exists(conn, "table", "attachment_cutover_state")?;
885 if !marker_table {
886 if version >= ATTACHMENT_CUTOVER_VERSION {
887 return Err(SqliteError::InvalidData(format!(
888 "migration V{ATTACHMENT_CUTOVER_VERSION} is recorded but its attachment cutover marker is absent"
889 )));
890 }
891 if schema_object_exists(conn, "table", "attachments")? {
892 return Err(SqliteError::InvalidData(
893 "attachments table exists without the durable attachment cutover marker".into(),
894 ));
895 }
896 return Ok(AttachmentCutoverStatus::Pending);
897 }
898
899 let marker: Option<(String, Option<i64>)> = conn
900 .query_row(
901 "SELECT state, completed_at FROM attachment_cutover_state WHERE singleton = 1",
902 [],
903 |row| Ok((row.get(0)?, row.get(1)?)),
904 )
905 .optional()?;
906 match marker {
907 Some((state, None)) if state == "incomplete" => {
908 if version >= ATTACHMENT_CUTOVER_VERSION {
909 Err(SqliteError::InvalidData(format!(
910 "attachment cutover is incomplete but migration V{ATTACHMENT_CUTOVER_VERSION} is already recorded"
911 )))
912 } else {
913 validate_incomplete_attachment_schema(conn)?;
914 Ok(AttachmentCutoverStatus::Incomplete)
915 }
916 }
917 Some((state, Some(_))) if state == "complete" => {
918 if version >= ATTACHMENT_CUTOVER_VERSION {
922 validate_complete_attachment_schema(conn)?;
923 Ok(AttachmentCutoverStatus::Complete)
924 } else {
925 Err(SqliteError::InvalidData(format!(
926 "attachment cutover is complete but schema ledger is at V{version}, below V{ATTACHMENT_CUTOVER_VERSION}"
927 )))
928 }
929 }
930 Some((state, completed_at)) => Err(SqliteError::InvalidData(format!(
931 "invalid attachment cutover marker state {state:?} with completed_at={completed_at:?}"
932 ))),
933 None => Err(SqliteError::InvalidData(
934 "attachment cutover marker table exists without its singleton row".into(),
935 )),
936 }
937}
938
939fn require_legacy_attachment_fences(conn: &Connection) -> Result<(), SqliteError> {
940 if !schema_column_exists(conn, "entities", "content_ref")? {
941 return Err(SqliteError::InvalidData(
942 "attachment cutover requires legacy entities.content_ref until finalization".into(),
943 ));
944 }
945 for (object_type, name) in [
946 ("table", "blob_gc_claims"),
947 ("index", "idx_blob_gc_claims_content_ref"),
948 ("index", "idx_entities_content_ref"),
949 ("trigger", "entities_reject_claimed_blob_insert"),
950 ("trigger", "entities_reject_claimed_blob_update"),
951 ] {
952 if !schema_object_exists(conn, object_type, name)? {
953 return Err(SqliteError::InvalidData(format!(
954 "attachment cutover requires legacy {object_type} {name:?} until finalization"
955 )));
956 }
957 }
958 Ok(())
959}
960
961fn canonical_content_ref_byte_width(conn: &Connection) -> Result<i64, SqliteError> {
968 let width: i64 = conn.query_row("SELECT length(CAST('x' AS BLOB))", [], |row| row.get(0))?;
969 if !(1..=4).contains(&width) {
970 return Err(SqliteError::InvalidData(format!(
971 "the text-encoding width probe returned {width}; refusing canonicality validation"
972 )));
973 }
974 Ok(width * 64)
975}
976
977fn validate_canonical_legacy_refs(conn: &Connection) -> Result<(), SqliteError> {
978 let canonical_bytes = canonical_content_ref_byte_width(conn)?;
979 let invalid: Option<String> = conn
980 .query_row(
981 "SELECT id FROM entities \
982 WHERE content_ref IS NOT NULL \
983 AND (typeof(content_ref) <> 'text' \
984 OR length(content_ref) <> 64 \
985 OR length(CAST(content_ref AS BLOB)) <> ?1 \
986 OR content_ref GLOB '*[^0-9a-f]*') \
987 LIMIT 1",
988 [canonical_bytes],
989 |row| row.get(0),
990 )
991 .optional()?;
992 if let Some(id) = invalid {
993 return Err(SqliteError::InvalidData(format!(
994 "entities.content_ref for record {id:?} is not a canonical 64-character lowercase hexadecimal ContentRef"
995 )));
996 }
997 Ok(())
998}
999
1000fn validate_canonical_attachment_and_claim_refs(conn: &Connection) -> Result<(), SqliteError> {
1001 let canonical_bytes = canonical_content_ref_byte_width(conn)?;
1002 for (table, identity) in [
1003 ("attachments", "record_uuid"),
1004 ("blob_gc_claims", "root_key"),
1005 ] {
1006 let sql = format!(
1007 "SELECT {identity} FROM {table} \
1008 WHERE typeof(content_ref) <> 'text' \
1009 OR length(content_ref) <> 64 \
1010 OR length(CAST(content_ref AS BLOB)) <> ?1 \
1011 OR content_ref GLOB '*[^0-9a-f]*' \
1012 LIMIT 1"
1013 );
1014 let invalid: Option<String> = conn
1015 .query_row(&sql, [canonical_bytes], |row| row.get(0))
1016 .optional()?;
1017 if let Some(owner) = invalid {
1018 return Err(SqliteError::InvalidData(format!(
1019 "{table}.content_ref for {identity} {owner:?} is not canonical"
1020 )));
1021 }
1022 }
1023 Ok(())
1024}
1025
1026fn validate_attachment_record_owners(conn: &Connection) -> Result<(), SqliteError> {
1027 let dangling: Option<(String, String)> = conn
1028 .query_row(
1029 "SELECT record_uuid, substrate FROM attachments AS attachment \
1030 WHERE (substrate = 'entity' AND NOT EXISTS ( \
1031 SELECT 1 FROM entities WHERE id = attachment.record_uuid \
1032 )) \
1033 OR (substrate = 'note' AND NOT EXISTS ( \
1034 SELECT 1 FROM notes WHERE id = attachment.record_uuid \
1035 )) \
1036 LIMIT 1",
1037 [],
1038 |row| Ok((row.get(0)?, row.get(1)?)),
1039 )
1040 .optional()?;
1041 if let Some((record_uuid, substrate)) = dangling {
1042 return Err(SqliteError::InvalidData(format!(
1043 "attachment role references absent {substrate} record {record_uuid:?}"
1044 )));
1045 }
1046 Ok(())
1047}
1048
1049fn validate_legacy_content_backfill(conn: &Connection) -> Result<(), SqliteError> {
1050 let conflict: Option<String> = conn
1051 .query_row(
1052 "SELECT entity.id FROM entities AS entity \
1053 LEFT JOIN attachments AS attachment \
1054 ON attachment.record_uuid = entity.id AND attachment.role = 'content' \
1055 WHERE entity.content_ref IS NOT NULL \
1056 AND (attachment.record_uuid IS NULL \
1057 OR attachment.substrate <> 'entity' \
1058 OR attachment.content_ref <> entity.content_ref) \
1059 LIMIT 1",
1060 [],
1061 |row| row.get(0),
1062 )
1063 .optional()?;
1064 if let Some(record_uuid) = conflict {
1065 return Err(SqliteError::InvalidData(format!(
1066 "legacy content attachment for entity {record_uuid:?} is missing or conflicts with entities.content_ref"
1067 )));
1068 }
1069 Ok(())
1070}
1071
1072fn stage_attachment_cutover_on_connection(conn: &Connection, now: i64) -> Result<(), SqliteError> {
1073 require_legacy_attachment_fences(conn)?;
1074 conn.execute_batch(V21_STAGE_UP)?;
1075 validate_canonical_legacy_refs(conn)?;
1076 validate_canonical_attachment_and_claim_refs(conn)?;
1077
1078 let conflict: Option<String> = conn
1079 .query_row(
1080 "SELECT entity.id FROM entities AS entity \
1081 JOIN attachments AS attachment \
1082 ON attachment.record_uuid = entity.id AND attachment.role = 'content' \
1083 WHERE entity.content_ref IS NOT NULL \
1084 AND (attachment.substrate <> 'entity' \
1085 OR attachment.content_ref <> entity.content_ref) \
1086 LIMIT 1",
1087 [],
1088 |row| row.get(0),
1089 )
1090 .optional()?;
1091 if let Some(record_uuid) = conflict {
1092 return Err(SqliteError::InvalidData(format!(
1093 "existing content attachment for entity {record_uuid:?} conflicts with entities.content_ref"
1094 )));
1095 }
1096
1097 conn.execute(
1098 "INSERT INTO attachments \
1099 (record_uuid, substrate, role, content_ref, media_type, size_bytes, created_at) \
1100 SELECT id, 'entity', 'content', content_ref, NULL, NULL, created_at \
1101 FROM entities WHERE content_ref IS NOT NULL \
1102 ON CONFLICT(record_uuid, role) DO NOTHING",
1103 [],
1104 )?;
1105 validate_legacy_content_backfill(conn)?;
1106
1107 conn.execute("DELETE FROM blob_gc_claims", [])?;
1111 conn.execute(
1112 "INSERT INTO attachment_cutover_state \
1113 (singleton, state, started_at, completed_at) \
1114 VALUES (1, 'incomplete', ?1, NULL) \
1115 ON CONFLICT(singleton) DO NOTHING",
1116 [now],
1117 )?;
1118 Ok(())
1119}
1120
1121pub fn stage_attachment_cutover(conn: &mut Connection) -> Result<(), SqliteError> {
1128 let admission = WriteAdmission::for_canonical_path(canonical_connection_database_path(conn)?)?;
1129 RawMigrationWriteUnit::new(conn, &admission)?
1130 .run(|conn| stage_attachment_cutover_with_admission(conn, &admission))
1131}
1132
1133pub fn stage_attachment_cutover_with_policy(
1135 conn: &mut Connection,
1136 policy: &MigrationWritePolicy,
1137) -> Result<(), SqliteError> {
1138 let admission =
1139 WriteAdmission::for_migration_policy(canonical_connection_database_path(conn)?, policy)?;
1140 RawMigrationWriteUnit::new(conn, &admission)?
1141 .run(|conn| stage_attachment_cutover_with_admission(conn, &admission))
1142}
1143
1144pub(crate) fn stage_attachment_cutover_with_admission(
1146 conn: &mut Connection,
1147 admission: &WriteAdmission,
1148) -> Result<(), SqliteError> {
1149 match attachment_cutover_status(conn)? {
1150 AttachmentCutoverStatus::Complete => return Ok(()),
1151 AttachmentCutoverStatus::Pending | AttachmentCutoverStatus::Incomplete => {}
1152 }
1153 if read_schema_version(conn)? != ATTACHMENT_CUTOVER_VERSION - 1 {
1154 return Err(SqliteError::InvalidData(format!(
1155 "attachment cutover stage requires canonical V{} schema",
1156 ATTACHMENT_CUTOVER_VERSION - 1
1157 )));
1158 }
1159
1160 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1161 if let Err(error) = admission.check() {
1162 let rollback = tx.rollback();
1163 return Err(capacity_refusal_after_rollback(
1164 conn,
1165 rollback,
1166 error,
1167 "attachment cutover stage",
1168 ));
1169 }
1170 let status = attachment_cutover_status(&tx)?;
1171 if status == AttachmentCutoverStatus::Complete {
1172 return Ok(());
1173 }
1174 stage_attachment_cutover_on_connection(&tx, chrono::Utc::now().timestamp_micros())?;
1175 tx.commit()?;
1176 Ok(())
1177}
1178
1179#[allow(clippy::too_many_arguments)]
1187pub fn apply_generic_verified_attachment(
1188 conn: &Connection,
1189 record_uuid: &str,
1190 substrate: &str,
1191 role: &str,
1192 content_ref: &ContentRef,
1193 media_type: Option<&str>,
1194 size_bytes: Option<u64>,
1195 created_at: i64,
1196) -> Result<(), SqliteError> {
1197 if attachment_cutover_status(conn)? != AttachmentCutoverStatus::Incomplete {
1198 return Err(SqliteError::InvalidData(
1199 "verified application attachments may only be applied while V21 cutover is incomplete"
1200 .into(),
1201 ));
1202 }
1203 if role.is_empty() || role.chars().any(char::is_control) {
1204 return Err(SqliteError::InvalidData(
1205 "attachment role must be non-empty and contain no control characters".into(),
1206 ));
1207 }
1208 let size_bytes = size_bytes.map(i64::try_from).transpose().map_err(|_| {
1209 SqliteError::InvalidData("attachment size_bytes exceeds SQLite INTEGER".into())
1210 })?;
1211 let owner_table = match substrate {
1212 "entity" => "entities",
1213 "note" => "notes",
1214 other => {
1215 return Err(SqliteError::InvalidData(format!(
1216 "attachment substrate must be 'entity' or 'note', got {other:?}"
1217 )))
1218 }
1219 };
1220 let owner_sql = format!("SELECT COUNT(*) > 0 FROM {owner_table} WHERE id = ?1");
1221 let owner_exists: bool = conn.query_row(&owner_sql, [record_uuid], |row| row.get(0))?;
1222 if !owner_exists {
1223 return Err(SqliteError::InvalidData(format!(
1224 "cannot attach role {role:?}: {substrate} record {record_uuid:?} does not exist"
1225 )));
1226 }
1227 let claimed: bool = conn.query_row(
1228 "SELECT COUNT(*) > 0 FROM blob_gc_claims WHERE content_ref = ?1",
1229 [content_ref.as_str()],
1230 |row| row.get(0),
1231 )?;
1232 if claimed {
1233 return Err(SqliteError::InvalidData(format!(
1234 "cannot attach claimed content_ref {} during V21 cutover",
1235 content_ref.as_str()
1236 )));
1237 }
1238
1239 let changed = conn.execute(
1240 "INSERT INTO attachments \
1241 (record_uuid, substrate, role, content_ref, media_type, size_bytes, created_at) \
1242 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
1243 ON CONFLICT(record_uuid, role) DO UPDATE SET \
1244 media_type = excluded.media_type, \
1245 size_bytes = excluded.size_bytes, \
1246 created_at = excluded.created_at \
1247 WHERE attachments.substrate = excluded.substrate \
1248 AND attachments.content_ref = excluded.content_ref",
1249 rusqlite::params![
1250 record_uuid,
1251 substrate,
1252 role,
1253 content_ref.as_str(),
1254 media_type,
1255 size_bytes,
1256 created_at,
1257 ],
1258 )?;
1259 if changed == 0 {
1260 return Err(SqliteError::InvalidData(format!(
1261 "attachment role {role:?} for record {record_uuid:?} conflicts with an existing substrate or content_ref"
1262 )));
1263 }
1264 Ok(())
1265}
1266
1267fn finalize_attachment_cutover_on_connection(
1268 conn: &Connection,
1269 now: i64,
1270) -> Result<(), SqliteError> {
1271 require_legacy_attachment_fences(conn)?;
1272 validate_canonical_legacy_refs(conn)?;
1273 validate_canonical_attachment_and_claim_refs(conn)?;
1274 validate_attachment_record_owners(conn)?;
1275 validate_legacy_content_backfill(conn)?;
1276
1277 let remaining_claims: i64 =
1278 conn.query_row("SELECT COUNT(*) FROM blob_gc_claims", [], |row| row.get(0))?;
1279 if remaining_claims != 0 {
1280 return Err(SqliteError::InvalidData(format!(
1281 "attachment cutover cannot finalize while {remaining_claims} blob GC claim rows remain"
1282 )));
1283 }
1284
1285 let uncovered_model: Option<String> = conn
1286 .query_row(
1287 "SELECT model.id FROM entities AS model \
1288 WHERE model.entity_type = 'moodboard_model' \
1289 AND model.content_ref IS NOT NULL \
1290 AND NOT EXISTS ( \
1291 SELECT 1 FROM attachments AS attachment \
1292 WHERE attachment.record_uuid = model.id \
1293 AND attachment.substrate = 'entity' \
1294 AND attachment.role = 'fann-network' \
1295 ) \
1296 LIMIT 1",
1297 [],
1298 |row| row.get(0),
1299 )
1300 .optional()?;
1301 if let Some(record_uuid) = uncovered_model {
1302 return Err(SqliteError::InvalidData(format!(
1303 "moodboard_model {record_uuid:?} has legacy content but no verified 'fann-network' attachment"
1304 )));
1305 }
1306
1307 conn.execute_batch(V21_ATTACHMENT_FENCES_UP)?;
1308 conn.execute_batch(
1309 "DROP TRIGGER entities_reject_claimed_blob_insert; \
1310 DROP TRIGGER entities_reject_claimed_blob_update; \
1311 DROP INDEX idx_entities_content_ref; \
1312 ALTER TABLE entities DROP COLUMN content_ref;",
1313 )?;
1314 conn.execute(
1315 "UPDATE attachment_cutover_state \
1316 SET state = 'complete', completed_at = ?1 \
1317 WHERE singleton = 1 AND state = 'incomplete'",
1318 [now],
1319 )?;
1320 Ok(())
1321}
1322
1323fn record_attachment_cutover_migration(conn: &Connection, now: i64) -> Result<(), SqliteError> {
1324 let migration = MIGRATIONS
1325 .iter()
1326 .find(|migration| migration.version == ATTACHMENT_CUTOVER_VERSION)
1327 .expect("V21 migration must be registered");
1328 conn.execute(
1329 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
1330 rusqlite::params![migration.version, migration.name, now],
1331 )?;
1332 Ok(())
1333}
1334
1335pub fn finalize_attachment_cutover(conn: &mut Connection) -> Result<(), SqliteError> {
1342 let admission = WriteAdmission::for_canonical_path(canonical_connection_database_path(conn)?)?;
1343 RawMigrationWriteUnit::new(conn, &admission)?
1344 .run(|conn| finalize_attachment_cutover_with_admission(conn, &admission))
1345}
1346
1347pub fn finalize_attachment_cutover_with_policy(
1349 conn: &mut Connection,
1350 policy: &MigrationWritePolicy,
1351) -> Result<(), SqliteError> {
1352 let admission =
1353 WriteAdmission::for_migration_policy(canonical_connection_database_path(conn)?, policy)?;
1354 RawMigrationWriteUnit::new(conn, &admission)?
1355 .run(|conn| finalize_attachment_cutover_with_admission(conn, &admission))
1356}
1357
1358pub(crate) fn finalize_attachment_cutover_with_admission(
1360 conn: &mut Connection,
1361 admission: &WriteAdmission,
1362) -> Result<(), SqliteError> {
1363 if attachment_cutover_status(conn)? == AttachmentCutoverStatus::Complete {
1364 return Ok(());
1365 }
1366 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?;
1367 if let Err(error) = admission.check() {
1368 let rollback = tx.rollback();
1369 return Err(capacity_refusal_after_rollback(
1370 conn,
1371 rollback,
1372 error,
1373 "attachment cutover finalization",
1374 ));
1375 }
1376 match attachment_cutover_status(&tx)? {
1377 AttachmentCutoverStatus::Complete => return Ok(()),
1378 AttachmentCutoverStatus::Pending => {
1379 return Err(SqliteError::InvalidData(
1380 "attachment cutover must complete stage 1 before finalization".into(),
1381 ))
1382 }
1383 AttachmentCutoverStatus::Incomplete => {}
1384 }
1385 let now = chrono::Utc::now().timestamp_micros();
1386 finalize_attachment_cutover_on_connection(&tx, now)?;
1387 record_attachment_cutover_migration(&tx, now)?;
1388 tx.commit()?;
1389 Ok(())
1390}
1391
1392fn read_applied_migration_ledger(
1394 conn: &Connection,
1395 through_version: u32,
1396) -> Result<Vec<(u32, String)>, SqliteError> {
1397 let mut stmt = conn.prepare(
1398 "SELECT version, name FROM _schema_migrations \
1399 WHERE version <= ?1 ORDER BY version ASC",
1400 )?;
1401 let rows = stmt
1402 .query_map([through_version], |row| {
1403 Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?))
1404 })?
1405 .collect::<Result<Vec<_>, _>>()?;
1406 Ok(rows)
1407}
1408
1409fn validate_applied_migration_versions(
1414 applied: &[(u32, String)],
1415 through_version: u32,
1416) -> Result<(), SqliteError> {
1417 let expected: Vec<&VersionedMigration> = MIGRATIONS
1418 .iter()
1419 .filter(|migration| migration.version <= through_version)
1420 .collect();
1421 let mut applied_index = 0;
1422
1423 for migration in expected {
1424 let Some((version, applied_name)) = applied.get(applied_index) else {
1425 return Err(SqliteError::InvalidData(format!(
1426 "migration history is missing version {} ('{}'); the applied ledger must be \
1427 the exact contiguous canonical sequence through version {through_version}",
1428 migration.version, migration.name,
1429 )));
1430 };
1431 if *version < migration.version {
1432 return Err(SqliteError::InvalidData(format!(
1433 "migration history contains unknown version {version} recorded as \
1434 '{applied_name}'; the applied ledger must contain only canonical versions"
1435 )));
1436 }
1437 if *version > migration.version {
1438 return Err(SqliteError::InvalidData(format!(
1439 "migration history is missing version {} ('{}'); found version {version} \
1440 next instead",
1441 migration.version, migration.name,
1442 )));
1443 }
1444 applied_index += 1;
1445 }
1446
1447 if let Some((version, name)) = applied.get(applied_index) {
1448 return Err(SqliteError::InvalidData(format!(
1449 "migration history contains unknown version {version} recorded as '{name}'; \
1450 the applied ledger must contain only canonical versions"
1451 )));
1452 }
1453
1454 Ok(())
1455}
1456
1457fn validate_applied_migration_names(
1458 applied: &[(u32, String)],
1459 through_version: u32,
1460 allow_known_v19_repairs: bool,
1461) -> Result<(), SqliteError> {
1462 for ((version, applied_name), migration) in applied.iter().zip(
1463 MIGRATIONS
1464 .iter()
1465 .filter(|migration| migration.version <= through_version),
1466 ) {
1467 debug_assert_eq!(*version, migration.version);
1468 if migration.name != applied_name.as_str() {
1469 if allow_known_v19_repairs && matches!(*version, 13 | 14) {
1470 continue;
1471 }
1472 return Err(SqliteError::InvalidData(format!(
1473 "migration version {version} is recorded under name '{applied_name}', \
1474 expected '{expected}'. This database's migration history does not match \
1475 the current binary; recreate it from the current schema or repair the \
1476 specific known divergence via a dedicated migration.",
1477 expected = migration.name,
1478 )));
1479 }
1480 }
1481
1482 Ok(())
1483}
1484
1485fn validate_applied_migration_ledger(
1489 conn: &Connection,
1490 through_version: u32,
1491) -> Result<(), SqliteError> {
1492 let applied = read_applied_migration_ledger(conn, through_version)?;
1493 validate_applied_migration_versions(&applied, through_version)?;
1494 validate_applied_migration_names(&applied, through_version, false)
1495}
1496
1497const MIGRATION_TRACKING_TABLE: &str = include_str!("../sql/schema-migrations-table.sql");
1498
1499const SCHEMA_VERSION_PROBE_SQL: &str = "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations";
1500
1501pub fn schema_version_probe() -> khive_storage::types::SqlStatement {
1503 khive_storage::types::SqlStatement::new(SCHEMA_VERSION_PROBE_SQL, Vec::new())
1504}
1505
1506pub fn read_schema_version(conn: &Connection) -> Result<u32, SqliteError> {
1512 match conn.query_row(SCHEMA_VERSION_PROBE_SQL, [], |row| row.get(0)) {
1513 Ok(version) => Ok(version),
1514 Err(rusqlite::Error::SqliteFailure(_, Some(ref msg)))
1515 if msg.contains("no such table: _schema_migrations") =>
1516 {
1517 Ok(0)
1518 }
1519 Err(e) => Err(e.into()),
1520 }
1521}
1522
1523pub fn inspect_schema_version(path: &std::path::Path) -> Result<u32, SqliteError> {
1528 let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1529 read_schema_version(&conn)
1530}
1531
1532pub fn inspect_schema_is_current(path: &std::path::Path) -> Result<u32, SqliteError> {
1535 let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1536 validate_schema_is_current(&conn)
1537}
1538
1539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1542pub struct PageAccounting {
1543 pub page_size: u64,
1545 pub page_count: u64,
1547 pub freelist_count: u64,
1550}
1551
1552pub fn inspect_page_accounting(path: &std::path::Path) -> Result<PageAccounting, SqliteError> {
1555 let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1556 let read = |pragma: &str| -> Result<u64, SqliteError> {
1557 let value: i64 = conn.query_row(pragma, [], |row| row.get(0))?;
1558 u64::try_from(value)
1559 .map_err(|_| SqliteError::InvalidData(format!("{pragma} returned {value}")))
1560 };
1561 Ok(PageAccounting {
1562 page_size: read("PRAGMA page_size")?,
1563 page_count: read("PRAGMA page_count")?,
1564 freelist_count: read("PRAGMA freelist_count")?,
1565 })
1566}
1567
1568pub fn inspect_integrity(path: &std::path::Path) -> Result<Vec<String>, SqliteError> {
1574 let conn = crate::pool::open_read_only_snapshot_connection(path)?;
1575 let mut statement = conn.prepare(&format!("PRAGMA integrity_check({})", i32::MAX))?;
1576 let rows = statement
1577 .query_map([], |row| row.get::<_, String>(0))?
1578 .collect::<Result<Vec<_>, _>>()?;
1579 Ok(rows)
1580}
1581
1582pub fn validate_schema_is_current(conn: &Connection) -> Result<u32, SqliteError> {
1590 let current_version = read_schema_version(conn)?;
1591 let latest_version = latest_schema_version();
1592
1593 if current_version < latest_version {
1594 return Err(SqliteError::InvalidData(format!(
1595 "read-only database schema version {current_version} is behind the latest known \
1596 migration {latest_version}; migrate a writable copy with this build before opening \
1597 the snapshot read-only"
1598 )));
1599 }
1600 if current_version > latest_version {
1601 return Err(SqliteError::InvalidData(format!(
1602 "read-only database schema version {current_version} is ahead of the latest known \
1603 migration {latest_version}; use a compatible newer build or recreate the snapshot"
1604 )));
1605 }
1606
1607 validate_applied_migration_ledger(conn, current_version)?;
1614 if current_version >= ATTACHMENT_CUTOVER_VERSION
1618 && attachment_cutover_status(conn)? != AttachmentCutoverStatus::Complete
1619 {
1620 return Err(SqliteError::InvalidData(
1621 "read-only database has not completed the V21 attachment cutover".into(),
1622 ));
1623 }
1624
1625 if current_version >= MEMORY_VISIBILITY_CUTOVER_VERSION {
1626 memory_visibility::validate_cutover(conn)?;
1627 }
1628
1629 Ok(current_version)
1630}
1631
1632pub fn validate_memory_visibility_cutover(conn: &Connection) -> Result<(), SqliteError> {
1635 validate_schema_is_current(conn).map(|_| ())
1636}
1637
1638#[cfg(test)]
1639pub(crate) mod test_sync {
1640 use std::sync::atomic::AtomicU32;
1641 use std::sync::{Arc, Barrier, Mutex};
1642
1643 pub(crate) static STALE_READ_BARRIER: Mutex<Option<Arc<Barrier>>> = Mutex::new(None);
1647 pub(crate) static LOCKED_FAST_FORWARDS: AtomicU32 = AtomicU32::new(0);
1649 pub(crate) static BUSY_OBSERVED: std::sync::atomic::AtomicBool =
1653 std::sync::atomic::AtomicBool::new(false);
1654
1655 pub(crate) fn record_busy(_count: i32) -> bool {
1658 BUSY_OBSERVED.store(true, std::sync::atomic::Ordering::SeqCst);
1659 std::thread::sleep(std::time::Duration::from_millis(1));
1660 true
1661 }
1662
1663 pub(crate) static WINNER_COMMITTED: std::sync::atomic::AtomicBool =
1666 std::sync::atomic::AtomicBool::new(false);
1667 pub(crate) static LOSER_SAW_WINNER_COMMIT: std::sync::atomic::AtomicBool =
1672 std::sync::atomic::AtomicBool::new(false);
1673
1674 std::thread_local! {
1675 pub(crate) static PARTICIPATE: std::cell::Cell<bool> =
1678 const { std::cell::Cell::new(false) };
1679 pub(crate) static FIRST_BEGIN_DONE: std::cell::Cell<bool> =
1681 const { std::cell::Cell::new(false) };
1682 }
1683}
1684
1685fn canonical_connection_database_path(conn: &Connection) -> Result<Option<PathBuf>, SqliteError> {
1694 let configured = conn.path().unwrap_or_default();
1695 let raw_path = if configured.is_empty() {
1696 conn.query_row(
1697 "SELECT file FROM pragma_database_list WHERE name = 'main'",
1698 [],
1699 |row| row.get::<_, String>(0),
1700 )?
1701 } else {
1702 configured.to_string()
1703 };
1704
1705 if raw_path.is_empty() {
1706 return Ok(None);
1707 }
1708 let canonical = std::fs::canonicalize(&raw_path).map_err(SqliteError::Io)?;
1709 #[cfg(any(unix, windows))]
1710 crate::pool::opened_sqlite_file_identity(conn, &canonical)?;
1711 Ok(Some(canonical))
1712}
1713
1714fn validate_database_gc_owner(
1715 conn: &Connection,
1716 owner: &DatabaseGcOwnerGuard,
1717) -> Result<(), SqliteError> {
1718 let connection_path = canonical_connection_database_path(conn)?;
1719 if owner.database_path() != connection_path.as_deref() {
1720 return Err(SqliteError::InvalidData(format!(
1721 "database GC owner targets {:?}, but migration connection targets {:?}",
1722 owner.database_path(),
1723 connection_path.as_deref(),
1724 )));
1725 }
1726 Ok(())
1727}
1728
1729pub fn run_migrations(conn: &mut Connection) -> Result<u32, SqliteError> {
1730 let database_path = canonical_connection_database_path(conn)?;
1731 let admission = WriteAdmission::for_canonical_path(database_path.clone())?;
1732 run_raw_migrations_with_admission(conn, database_path, &admission)
1733}
1734
1735pub fn run_migrations_with_policy(
1737 conn: &mut Connection,
1738 policy: &MigrationWritePolicy,
1739) -> Result<u32, SqliteError> {
1740 let database_path = canonical_connection_database_path(conn)?;
1741 let admission = WriteAdmission::for_migration_policy(database_path.clone(), policy)?;
1742 run_raw_migrations_with_admission(conn, database_path, &admission)
1743}
1744
1745fn run_raw_migrations_with_admission(
1746 conn: &mut Connection,
1747 database_path: Option<PathBuf>,
1748 admission: &WriteAdmission,
1749) -> Result<u32, SqliteError> {
1750 if let Some(database_path) = database_path {
1751 let owner = try_acquire_database_gc_owner_for_path(database_path).map_err(|error| {
1756 SqliteError::InvalidData(format!(
1757 "failed to acquire database GC owner before schema migration: {error}"
1758 ))
1759 })?;
1760 return run_migrations_with_database_gc_owner(
1761 &mut RawMigrationTransactions::new(conn, admission),
1762 &owner,
1763 admission,
1764 );
1765 }
1766
1767 run_versioned_migrations(
1770 &mut RawMigrationTransactions::new(conn, admission),
1771 None,
1772 admission,
1773 )
1774}
1775
1776pub(crate) fn run_migrations_with_database_gc_owner(
1777 writes: &mut impl MigrationTransactions,
1778 owner: &DatabaseGcOwnerGuard,
1779 admission: &WriteAdmission,
1780) -> Result<u32, SqliteError> {
1781 run_versioned_migrations(writes, Some(owner), admission)
1782}
1783
1784fn with_migration_busy_timeout<T>(
1790 conn: &mut Connection,
1791 operation: impl FnOnce(&mut Connection) -> Result<T, SqliteError>,
1792) -> Result<T, SqliteError> {
1793 let prior_busy_ms: i64 = conn.query_row("PRAGMA busy_timeout", [], |row| row.get(0))?;
1794 let raised = prior_busy_ms < 5_000;
1795 if raised {
1796 conn.busy_timeout(std::time::Duration::from_secs(5))?;
1797 }
1798 let result = operation(conn);
1799 if raised {
1800 let _ = conn.busy_timeout(std::time::Duration::from_millis(prior_busy_ms.max(0) as u64));
1801 }
1802 result
1803}
1804
1805enum MigrationStep {
1807 Applied,
1809 AppliedBySibling(u32),
1812 Deferred,
1814}
1815
1816fn run_versioned_migrations(
1823 writes: &mut impl MigrationTransactions,
1824 owner: Option<&DatabaseGcOwnerGuard>,
1825 admission: &WriteAdmission,
1826) -> Result<u32, SqliteError> {
1827 let current_version = writes.admitted(|conn| {
1828 if let Some(owner) = owner {
1829 validate_database_gc_owner(conn, owner)?;
1830 }
1831 with_migration_busy_timeout(conn, |conn| bootstrap_migration_ledger(conn, admission))
1832 })?;
1833
1834 #[cfg(test)]
1839 if test_sync::PARTICIPATE.with(|p| p.get()) {
1840 let barrier = test_sync::STALE_READ_BARRIER.lock().unwrap().clone();
1841 if let Some(barrier) = barrier {
1842 barrier.wait();
1843 }
1844 }
1845 let latest_version = latest_schema_version();
1846
1847 let mut applied_version = current_version;
1848 let mut skip_through = current_version;
1852
1853 for migration in MIGRATIONS {
1854 if migration.version <= skip_through {
1855 applied_version = applied_version.max(migration.version);
1856 continue;
1857 }
1858 let step = writes.admitted(|conn| {
1859 with_migration_busy_timeout(conn, |conn| {
1860 apply_versioned_migration(conn, migration, admission, latest_version)
1861 })
1862 })?;
1863 match step {
1864 MigrationStep::Applied => applied_version = migration.version,
1865 MigrationStep::AppliedBySibling(sibling_version) => {
1866 skip_through = sibling_version.min(latest_version);
1867 applied_version = applied_version.max(migration.version);
1868 }
1869 MigrationStep::Deferred => break,
1870 }
1871 }
1872
1873 writes.admitted(|conn| validate_applied_migration_ledger(conn, applied_version))?;
1877
1878 Ok(applied_version)
1879}
1880
1881fn bootstrap_migration_ledger(
1885 conn: &mut Connection,
1886 admission: &WriteAdmission,
1887) -> Result<u32, SqliteError> {
1888 admission.check()?;
1889 conn.execute_batch(MIGRATION_TRACKING_TABLE)?;
1890 require_autocommit(conn, "migration-tracking bootstrap")?;
1891
1892 let current_version: u32 = read_schema_version(conn)?;
1893
1894 let latest_version = latest_schema_version();
1900 if current_version > latest_version {
1901 return Err(SqliteError::InvalidData(format!(
1902 "database schema version {current_version} is ahead of the latest known migration \
1903 {latest_version}. This database predates the consolidated baseline (ADR-015) or was \
1904 written by a newer build. Recreate it from the current schema; in-place downgrade is \
1905 not supported."
1906 )));
1907 }
1908
1909 let applied = read_applied_migration_ledger(conn, current_version)?;
1916 validate_applied_migration_versions(&applied, current_version)?;
1917 validate_applied_migration_names(&applied, current_version, current_version < 19)?;
1918
1919 Ok(current_version)
1920}
1921
1922fn apply_versioned_migration(
1925 conn: &mut Connection,
1926 migration: &VersionedMigration,
1927 admission: &WriteAdmission,
1928 latest_version: u32,
1929) -> Result<MigrationStep, SqliteError> {
1930 #[cfg(test)]
1934 let instrumented_first_begin =
1935 test_sync::PARTICIPATE.with(|p| p.get()) && !test_sync::FIRST_BEGIN_DONE.with(|f| f.get());
1936 #[cfg(test)]
1937 if instrumented_first_begin {
1938 test_sync::FIRST_BEGIN_DONE.with(|f| f.set(true));
1939 }
1940 #[cfg(test)]
1943 if test_sync::PARTICIPATE.with(|p| p.get()) {
1944 conn.busy_handler(Some(test_sync::record_busy))?;
1945 }
1946 let tx = conn
1947 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1948 .map_err(|e| SqliteError::Migration {
1949 version: migration.version,
1950 error: e.to_string(),
1951 })?;
1952 if let Err(error) = admission.check() {
1953 let rollback = tx.rollback();
1954 return Err(capacity_refusal_after_rollback(
1955 conn,
1956 rollback,
1957 error,
1958 "core schema migration",
1959 ));
1960 }
1961
1962 let sibling_version: u32 = tx
1966 .query_row(SCHEMA_VERSION_PROBE_SQL, [], |row| row.get(0))
1967 .map_err(|e| SqliteError::Migration {
1968 version: migration.version,
1969 error: e.to_string(),
1970 })?;
1971 #[cfg(test)]
1972 if instrumented_first_begin {
1973 use std::sync::atomic::Ordering::SeqCst;
1974 if sibling_version == 0 {
1975 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1981 while !test_sync::BUSY_OBSERVED.load(SeqCst) && std::time::Instant::now() < deadline {
1982 std::thread::yield_now();
1983 }
1984 } else {
1985 test_sync::LOSER_SAW_WINNER_COMMIT
1989 .store(test_sync::WINNER_COMMITTED.load(SeqCst), SeqCst);
1990 }
1991 }
1992
1993 if sibling_version > latest_version {
1998 return Err(SqliteError::InvalidData(format!(
1999 "database schema version {sibling_version} is ahead of the latest known \
2000 migration {latest_version} (committed by a concurrent process while this \
2001 one waited for the migration write lock). This build cannot run against \
2002 the newer schema; upgrade the binary or recreate the database."
2003 )));
2004 }
2005
2006 if sibling_version >= migration.version {
2007 #[cfg(test)]
2008 test_sync::LOCKED_FAST_FORWARDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2009 return Ok(MigrationStep::AppliedBySibling(sibling_version));
2010 }
2011
2012 if migration.version == 46 {
2013 memory_visibility::capture_pre_v46(&tx).map_err(|error| SqliteError::Migration {
2014 version: migration.version,
2015 error: error.to_string(),
2016 })?;
2017 #[cfg(test)]
2018 memory_visibility::test_state::stop_at(memory_visibility::test_state::Stop::AfterCapture)?;
2019 }
2020
2021 if migration.version == ATTACHMENT_CUTOVER_VERSION {
2022 let status = attachment_cutover_status(&tx).map_err(|e| SqliteError::Migration {
2023 version: migration.version,
2024 error: e.to_string(),
2025 })?;
2026 let legacy_refs: i64 = tx
2027 .query_row(
2028 "SELECT COUNT(*) FROM entities WHERE content_ref IS NOT NULL",
2029 [],
2030 |row| row.get(0),
2031 )
2032 .map_err(|e| SqliteError::Migration {
2033 version: migration.version,
2034 error: e.to_string(),
2035 })?;
2036
2037 if status == AttachmentCutoverStatus::Incomplete || legacy_refs != 0 {
2041 drop(tx);
2042 return Ok(MigrationStep::Deferred);
2043 }
2044 if status != AttachmentCutoverStatus::Pending {
2045 return Err(SqliteError::Migration {
2046 version: migration.version,
2047 error: format!("unexpected attachment cutover state {status:?}"),
2048 });
2049 }
2050
2051 let now = chrono::Utc::now().timestamp_micros();
2052 stage_attachment_cutover_on_connection(&tx, now).map_err(|e| SqliteError::Migration {
2053 version: migration.version,
2054 error: e.to_string(),
2055 })?;
2056 finalize_attachment_cutover_on_connection(&tx, now).map_err(|e| {
2057 SqliteError::Migration {
2058 version: migration.version,
2059 error: e.to_string(),
2060 }
2061 })?;
2062 } else if migration.version == 36 {
2063 crate::stores::event::ensure_operation_attribution_columns(&tx).map_err(|error| {
2065 SqliteError::Migration {
2066 version: migration.version,
2067 error: error.to_string(),
2068 }
2069 })?;
2070 } else if migration.version == 44 {
2071 migrate_outbound_due_key(&tx).map_err(|error| SqliteError::Migration {
2072 version: migration.version,
2073 error: error.to_string(),
2074 })?;
2075 } else if migration.version == 48 {
2076 migrate_acknowledgement_journal(&tx).map_err(|error| SqliteError::Migration {
2077 version: migration.version,
2078 error: error.to_string(),
2079 })?;
2080 } else if migration.name == SESSION_IDENTITY_MIGRATION_NAME {
2081 tx.execute_batch(migration.up)
2082 .map_err(|error| SqliteError::Migration {
2083 version: migration.version,
2084 error: error.to_string(),
2085 })?;
2086 session_identity_migration::apply(&tx).map_err(|error| SqliteError::Migration {
2087 version: migration.version,
2088 error: error.to_string(),
2089 })?;
2090 } else {
2091 tx.execute_batch(migration.up)
2092 .map_err(|e| SqliteError::Migration {
2093 version: migration.version,
2094 error: e.to_string(),
2095 })?;
2096 }
2097
2098 let visibility_counts = if migration.version == MEMORY_VISIBILITY_CUTOVER_VERSION {
2099 Some(
2100 memory_visibility::cutover_counts(&tx).map_err(|error| SqliteError::Migration {
2101 version: migration.version,
2102 error: error.to_string(),
2103 })?,
2104 )
2105 } else {
2106 None
2107 };
2108
2109 if migration.version == 19 {
2116 tx.execute_batch(
2117 "UPDATE _schema_migrations SET name = 'list_cursor_sequences' WHERE version = 13;\n\
2118 UPDATE _schema_migrations SET name = 'graph_edges_id_unique' WHERE version = 14;",
2119 )
2120 .map_err(|e| SqliteError::Migration {
2121 version: migration.version,
2122 error: e.to_string(),
2123 })?;
2124 }
2125
2126 let now = chrono::Utc::now().timestamp_micros();
2127 tx.execute(
2128 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3) \
2129 ON CONFLICT(version) DO NOTHING",
2130 rusqlite::params![migration.version, migration.name, now],
2131 )
2132 .map_err(|e| SqliteError::Migration {
2133 version: migration.version,
2134 error: e.to_string(),
2135 })?;
2136
2137 #[cfg(test)]
2138 if instrumented_first_begin {
2139 test_sync::WINNER_COMMITTED.store(true, std::sync::atomic::Ordering::SeqCst);
2140 }
2141
2142 #[cfg(test)]
2143 if migration.version == MEMORY_VISIBILITY_CUTOVER_VERSION {
2144 memory_visibility::test_state::stop_at(
2145 memory_visibility::test_state::Stop::BeforeCutoverCommit,
2146 )?;
2147 }
2148 tx.commit().map_err(|e| SqliteError::Migration {
2149 version: migration.version,
2150 error: e.to_string(),
2151 })?;
2152 if let Some(counts) = visibility_counts {
2153 memory_visibility::log_counts(&counts, conn.path().unwrap_or(":memory:"));
2154 }
2155 #[cfg(test)]
2156 if migration.version == 46 {
2157 memory_visibility::test_state::stop_at(
2158 memory_visibility::test_state::Stop::AfterV46Commit,
2159 )?;
2160 }
2161
2162 Ok(MigrationStep::Applied)
2163}
2164
2165#[derive(Debug)]
2166pub struct EmbeddingModelRegistryRecord {
2167 pub engine_name: String,
2169 pub model_id: String,
2171 pub key_version: String,
2173 pub dimensions: u32,
2175 pub status: String,
2177 pub activated_at: Option<i64>,
2179 pub superseded_at: Option<i64>,
2181}
2182
2183pub fn query_embedding_models(
2189 db: Option<&std::path::Path>,
2190 engine_filter: Option<&str>,
2191) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
2192 let path = db.map(std::path::Path::to_path_buf).unwrap_or_else(|| {
2193 std::env::var("HOME")
2194 .map(std::path::PathBuf::from)
2195 .unwrap_or_else(|_| std::path::PathBuf::from("."))
2196 .join(".khive/khive.db")
2197 });
2198 if !path.exists() {
2199 return Ok(Vec::new());
2200 }
2201 let conn = Connection::open_with_flags(
2202 path,
2203 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
2204 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
2205 | rusqlite::OpenFlags::SQLITE_OPEN_URI,
2206 )?;
2207 query_embedding_models_conn(&conn, engine_filter)
2208}
2209
2210pub(crate) fn query_embedding_models_conn(
2214 conn: &Connection,
2215 engine_filter: Option<&str>,
2216) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
2217 let exists: bool = conn.query_row(
2218 "SELECT COUNT(*) > 0 FROM sqlite_master \
2219 WHERE type='table' AND name='_embedding_models'",
2220 [],
2221 |row| row.get(0),
2222 )?;
2223 if !exists {
2224 return Ok(Vec::new());
2225 }
2226
2227 let sql = if engine_filter.is_some() {
2228 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
2229 FROM _embedding_models WHERE engine_name = ?1 \
2230 ORDER BY engine_name, activated_at IS NULL, activated_at"
2231 } else {
2232 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
2233 FROM _embedding_models \
2234 ORDER BY engine_name, activated_at IS NULL, activated_at"
2235 };
2236 let mut stmt = conn.prepare(sql)?;
2237 let map_row = |row: &rusqlite::Row<'_>| {
2238 let dim_raw: i64 = row.get(3)?;
2239 let dimensions = u32::try_from(dim_raw).map_err(|_| {
2240 rusqlite::Error::FromSqlConversionFailure(
2241 3,
2242 rusqlite::types::Type::Integer,
2243 Box::new(std::io::Error::other(format!(
2244 "_embedding_models.dim value {dim_raw} is outside the valid u32 range [0, {}]",
2245 u32::MAX,
2246 ))),
2247 )
2248 })?;
2249 Ok(EmbeddingModelRegistryRecord {
2250 engine_name: row.get(0)?,
2251 model_id: row.get(1)?,
2252 key_version: row.get(2)?,
2253 dimensions,
2254 status: row.get(4)?,
2255 activated_at: row.get(5)?,
2256 superseded_at: row.get(6)?,
2257 })
2258 };
2259
2260 if let Some(engine) = engine_filter {
2261 stmt.query_map([engine], map_row)?
2262 .collect::<Result<Vec<_>, _>>()
2263 .map_err(Into::into)
2264 } else {
2265 stmt.query_map([], map_row)?
2266 .collect::<Result<Vec<_>, _>>()
2267 .map_err(Into::into)
2268 }
2269}
2270
2271#[cfg(test)]
2273pub(crate) fn migration_test_policy() -> MigrationWritePolicy {
2274 MigrationWritePolicy::new(
2275 crate::DiskGuardEnvironment::capture()
2276 .resolve(None, None)
2277 .expect("test disk policy"),
2278 crate::PoolConfig::for_test()
2279 .volume_lock_dir
2280 .expect("test volume-lock directory"),
2281 )
2282 .expect("valid test migration policy")
2283}
2284
2285#[cfg(test)]
2286pub(crate) fn run_migrations_for_test(conn: &mut Connection) -> Result<u32, SqliteError> {
2287 run_migrations_with_policy(conn, &migration_test_policy())
2288}
2289
2290#[cfg(test)]
2291fn apply_schema_plan_for_test(
2292 conn: &mut Connection,
2293 plan: &ServiceSchemaPlan,
2294) -> Result<(), SqliteError> {
2295 apply_schema_plan_with_policy(conn, plan, &migration_test_policy())
2296}
2297
2298#[cfg(test)]
2299fn stage_attachment_cutover_for_test(conn: &mut Connection) -> Result<(), SqliteError> {
2300 stage_attachment_cutover_with_policy(conn, &migration_test_policy())
2301}
2302
2303#[cfg(test)]
2304fn finalize_attachment_cutover_for_test(conn: &mut Connection) -> Result<(), SqliteError> {
2305 finalize_attachment_cutover_with_policy(conn, &migration_test_policy())
2306}
2307
2308#[cfg(test)]
2313#[path = "entity_version_migration_measurement.rs"]
2314mod entity_version_measurement;
2315
2316#[cfg(test)]
2317#[path = "migrations_tests.rs"]
2318mod tests;
2319
2320#[cfg(test)]
2321#[path = "raw_migration_settlement_tests.rs"]
2322mod raw_settlement_tests;
2323
2324#[cfg(test)]
2325#[path = "git_note_index_migration_tests.rs"]
2326mod git_note_indexes;
2327
2328#[cfg(test)]
2329#[path = "entity_list_index_migration_tests.rs"]
2330mod entity_list_indexes;
2331
2332#[cfg(test)]
2333#[path = "schedule_core_index_migration_tests.rs"]
2334mod schedule_core_index_migration_tests;
2335
2336#[cfg(test)]
2337#[path = "comm_core_index_migration_tests.rs"]
2338mod comm_core_index_migration_tests;
2339
2340#[cfg(test)]
2341#[path = "knowledge_cursor_tag_migration_tests.rs"]
2342mod knowledge_cursor_tag_migration_tests;