1use rusqlite::{Connection, Result};
12
13pub const CURRENT_SCHEMA_VERSION: u32 = 3;
16
17const V1_INITIAL_SQL: &str = include_str!("schema/v1_initial.sql");
19
20const INDEXES_SQL: &str = include_str!("schema/indexes.sql");
22
23const V2_PENDING_ADJUDICATIONS_SQL: &str = include_str!("schema/v2_pending_adjudications.sql");
25
26const V3_DATE_GRANULARITY_SQL: &str = include_str!("schema/v3_date_granularity.sql");
28
29#[derive(Debug, thiserror::Error)]
31pub enum MigrationError {
32 #[error("SQLite error during migration: {0}")]
34 Sqlite(#[from] rusqlite::Error),
35}
36
37pub fn apply_migrations(conn: &Connection) -> Result<(), MigrationError> {
46 let current = user_version(conn)?;
47
48 if current < 1 {
49 apply_v1(conn)?;
50 }
51
52 if current < 2 {
53 apply_v2(conn)?;
54 }
55
56 if current < 3 {
57 apply_v3(conn)?;
58 }
59
60 Ok(())
61}
62
63fn user_version(conn: &Connection) -> Result<u32, MigrationError> {
65 let v: u32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
66 Ok(v)
67}
68
69fn set_user_version(conn: &Connection, version: u32) -> Result<(), MigrationError> {
77 conn.execute_batch(&format!("PRAGMA user_version = {version};"))?;
78 Ok(())
79}
80
81pub(crate) fn apply_v1(conn: &Connection) -> Result<(), MigrationError> {
83 conn.execute_batch(V1_INITIAL_SQL)?;
84 conn.execute_batch(INDEXES_SQL)?;
85 set_user_version(conn, 1)?;
86 Ok(())
87}
88
89pub(crate) fn apply_v2(conn: &Connection) -> Result<(), MigrationError> {
91 conn.execute_batch(V2_PENDING_ADJUDICATIONS_SQL)?;
92 set_user_version(conn, 2)?;
93 Ok(())
94}
95
96pub(crate) fn apply_v3(conn: &Connection) -> Result<(), MigrationError> {
102 conn.execute_batch(V3_DATE_GRANULARITY_SQL)?;
103 set_user_version(conn, 3)?;
104 Ok(())
105}
106
107#[cfg(test)]
110mod tests {
111 use super::*;
112 use rusqlite::Connection;
113
114 fn open_memory() -> Connection {
115 Connection::open_in_memory().expect("in-memory database should open")
116 }
117
118 fn column_names(conn: &Connection, table: &str) -> Vec<String> {
120 let mut stmt = conn
121 .prepare(&format!("PRAGMA table_info({table})"))
122 .unwrap();
123 stmt.query_map([], |row| row.get::<_, String>(1))
124 .unwrap()
125 .map(|r| r.unwrap())
126 .collect()
127 }
128
129 fn index_exists(conn: &Connection, index_name: &str) -> bool {
131 let count: u32 = conn
132 .query_row(
133 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=?1",
134 [index_name],
135 |row| row.get(0),
136 )
137 .unwrap_or(0);
138 count > 0
139 }
140
141 fn table_exists(conn: &Connection, table_name: &str) -> bool {
143 let count: u32 = conn
144 .query_row(
145 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
146 [table_name],
147 |row| row.get(0),
148 )
149 .unwrap_or(0);
150 count > 0
151 }
152
153 #[test]
154 fn all_four_tables_exist_after_migration() {
155 let conn = open_memory();
156 apply_migrations(&conn).expect("migrations should succeed");
157
158 assert!(table_exists(&conn, "claims"), "claims table must exist");
159 assert!(
160 table_exists(&conn, "validity_assertions"),
161 "validity_assertions table must exist"
162 );
163 assert!(
164 table_exists(&conn, "ledger_entries"),
165 "ledger_entries table must exist"
166 );
167 assert!(
168 table_exists(&conn, "claim_edges"),
169 "claim_edges table must exist"
170 );
171 }
172
173 #[test]
174 fn claims_table_has_expected_columns() {
175 let conn = open_memory();
176 apply_migrations(&conn).expect("migrations should succeed");
177
178 let cols = column_names(&conn, "claims");
179 for expected in &[
180 "claim_id",
181 "agent_id",
182 "subject",
183 "predicate",
184 "value",
185 "cardinality",
186 "provenance_label",
187 "nearest_external_anchor_id",
188 "derivation_depth",
189 "tx_time",
190 "valid_time_start",
191 "valid_time_end",
192 "valid_time_confidence",
193 "value_confidence",
194 "criticality",
195 "derived_from",
196 "metadata",
197 "snapshot_schema_version",
198 "embedding_model_id",
199 "valid_time_start_granularity",
201 "valid_time_end_granularity",
202 ] {
203 assert!(
204 cols.contains(&expected.to_string()),
205 "claims table missing column: {expected}"
206 );
207 }
208 }
209
210 #[test]
211 fn validity_assertions_table_has_expected_columns() {
212 let conn = open_memory();
213 apply_migrations(&conn).expect("migrations should succeed");
214
215 let cols = column_names(&conn, "validity_assertions");
216 for expected in &[
217 "assertion_id",
218 "agent_id",
219 "target_claim_id",
220 "assertion_kind",
221 "bound_at",
222 "reopen_at",
223 "provenance_label",
224 "value_confidence",
225 "valid_time_confidence",
226 "asserted_at",
227 ] {
228 assert!(
229 cols.contains(&expected.to_string()),
230 "validity_assertions table missing column: {expected}"
231 );
232 }
233 }
234
235 #[test]
236 fn ledger_entries_table_has_expected_columns() {
237 let conn = open_memory();
238 apply_migrations(&conn).expect("migrations should succeed");
239
240 let cols = column_names(&conn, "ledger_entries");
241 for expected in &[
242 "entry_id",
243 "agent_id",
244 "claim_id",
245 "event_kind",
246 "disposition",
247 "rationale",
248 "recorded_at",
249 ] {
250 assert!(
251 cols.contains(&expected.to_string()),
252 "ledger_entries table missing column: {expected}"
253 );
254 }
255 }
256
257 #[test]
258 fn claim_edges_table_has_expected_columns() {
259 let conn = open_memory();
260 apply_migrations(&conn).expect("migrations should succeed");
261
262 let cols = column_names(&conn, "claim_edges");
263 for expected in &[
264 "edge_id",
265 "agent_id",
266 "from_claim_id",
267 "to_claim_id",
268 "edge_kind",
269 "created_at",
270 ] {
271 assert!(
272 cols.contains(&expected.to_string()),
273 "claim_edges table missing column: {expected}"
274 );
275 }
276 }
277
278 #[test]
279 fn structural_subject_line_index_exists() {
280 let conn = open_memory();
281 apply_migrations(&conn).expect("migrations should succeed");
282
283 assert!(
284 index_exists(&conn, "idx_claims_subject_line"),
285 "primary structural subject-line index must exist"
286 );
287 }
288
289 #[test]
290 fn all_indexes_exist() {
291 let conn = open_memory();
292 apply_migrations(&conn).expect("migrations should succeed");
293
294 let expected_indexes = [
295 "idx_claims_subject_line",
296 "idx_validity_assertions_target",
297 "idx_ledger_agent_time",
298 "idx_edges_from",
299 "idx_edges_to",
300 "idx_claims_provenance",
301 ];
302 for idx in &expected_indexes {
303 assert!(
304 index_exists(&conn, idx),
305 "index missing after migration: {idx}"
306 );
307 }
308 }
309
310 #[test]
311 fn apply_migrations_is_idempotent() {
312 let conn = open_memory();
313 apply_migrations(&conn).expect("first migration should succeed");
314 apply_migrations(&conn).expect("second migration must not error (idempotent)");
315 apply_migrations(&conn).expect("third migration must not error (idempotent)");
316
317 assert!(table_exists(&conn, "claims"));
319 assert!(table_exists(&conn, "claim_edges"));
320 assert!(index_exists(&conn, "idx_claims_subject_line"));
321 }
322
323 #[test]
324 fn reserved_columns_exist_on_claims() {
325 let conn = open_memory();
326 apply_migrations(&conn).expect("migrations should succeed");
327
328 let cols = column_names(&conn, "claims");
329 assert!(
330 cols.contains(&"metadata".to_string()),
331 "reserved column 'metadata' must exist on claims"
332 );
333 assert!(
334 cols.contains(&"snapshot_schema_version".to_string()),
335 "reserved column 'snapshot_schema_version' must exist on claims"
336 );
337 assert!(
338 cols.contains(&"embedding_model_id".to_string()),
339 "reserved column 'embedding_model_id' must exist on claims"
340 );
341 }
342
343 #[test]
344 fn schema_version_is_set_after_migration() {
345 let conn = open_memory();
346 apply_migrations(&conn).expect("migrations should succeed");
347
348 let v = user_version(&conn).expect("user_version should be readable");
349 assert_eq!(
350 v, CURRENT_SCHEMA_VERSION,
351 "user_version PRAGMA must equal CURRENT_SCHEMA_VERSION after migration"
352 );
353 }
354
355 #[test]
356 fn pending_adjudications_table_exists_after_migration() {
357 let conn = open_memory();
358 apply_migrations(&conn).expect("migrations should succeed");
359 assert!(
360 table_exists(&conn, "pending_adjudications"),
361 "pending_adjudications table must exist after v2 migration"
362 );
363 }
364
365 #[test]
366 fn pending_adjudications_table_has_expected_columns() {
367 let conn = open_memory();
368 apply_migrations(&conn).expect("migrations should succeed");
369
370 let cols = column_names(&conn, "pending_adjudications");
371 for expected in &[
372 "handle_id",
373 "agent_id",
374 "subject",
375 "predicate",
376 "challenger_claim_ref",
377 "incumbent_claim_ref",
378 "request_payload",
379 "queued_at",
380 "expires_at",
381 "status",
382 ] {
383 assert!(
384 cols.contains(&expected.to_string()),
385 "pending_adjudications table missing column: {expected}"
386 );
387 }
388 }
389
390 #[test]
391 fn pending_adjudications_indexes_exist_after_migration() {
392 let conn = open_memory();
393 apply_migrations(&conn).expect("migrations should succeed");
394
395 assert!(
397 index_exists(&conn, "idx_pending_adj_agent_id"),
398 "idx_pending_adj_agent_id must exist after v2 migration"
399 );
400 assert!(
402 index_exists(&conn, "idx_pending_adj_expires_at"),
403 "idx_pending_adj_expires_at must exist after v2 migration"
404 );
405 }
406
407 #[test]
408 fn apply_migrations_v2_is_idempotent() {
409 let conn = open_memory();
410 apply_migrations(&conn).expect("first migration should succeed");
411 apply_migrations(&conn).expect("second migration must not error (idempotent)");
412 apply_migrations(&conn).expect("third migration must not error (idempotent)");
413
414 assert!(table_exists(&conn, "pending_adjudications"));
415 assert!(index_exists(&conn, "idx_pending_adj_agent_id"));
416 assert!(index_exists(&conn, "idx_pending_adj_expires_at"));
417 }
418
419 #[test]
423 fn v3_granularity_columns_exist_after_migration() {
424 let conn = open_memory();
425 apply_migrations(&conn).expect("migrations should succeed");
426
427 let cols = column_names(&conn, "claims");
428 assert!(
429 cols.contains(&"valid_time_start_granularity".to_string()),
430 "claims table missing column: valid_time_start_granularity (added in v3)"
431 );
432 assert!(
433 cols.contains(&"valid_time_end_granularity".to_string()),
434 "claims table missing column: valid_time_end_granularity (added in v3)"
435 );
436 }
437
438 #[test]
441 fn v3_upgrade_from_v2_succeeds() {
442 let conn = open_memory();
444 apply_v1(&conn).expect("v1 must succeed");
445 apply_v2(&conn).expect("v2 must succeed");
446 assert_eq!(user_version(&conn).unwrap(), 2, "after v2 version must be 2");
447
448 let cols_before = column_names(&conn, "claims");
450 assert!(
451 !cols_before.contains(&"valid_time_start_granularity".to_string()),
452 "granularity column must not exist before v3"
453 );
454
455 apply_v3(&conn).expect("v3 upgrade must succeed");
457 assert_eq!(user_version(&conn).unwrap(), 3, "after v3 version must be 3");
458
459 let cols_after = column_names(&conn, "claims");
460 assert!(
461 cols_after.contains(&"valid_time_start_granularity".to_string()),
462 "granularity column must exist after v3"
463 );
464 assert!(
465 cols_after.contains(&"valid_time_end_granularity".to_string()),
466 "granularity column must exist after v3"
467 );
468 }
469
470 #[test]
472 fn apply_migrations_upgrades_v2_to_v3() {
473 let conn = open_memory();
474 apply_v1(&conn).expect("v1 must succeed");
475 apply_v2(&conn).expect("v2 must succeed");
476
477 apply_migrations(&conn).expect("apply_migrations must succeed on v2 db");
479
480 let v = user_version(&conn).unwrap();
481 assert_eq!(v, CURRENT_SCHEMA_VERSION, "version must be CURRENT_SCHEMA_VERSION after upgrade");
482
483 let cols = column_names(&conn, "claims");
484 assert!(cols.contains(&"valid_time_start_granularity".to_string()));
485 assert!(cols.contains(&"valid_time_end_granularity".to_string()));
486 }
487}