Files
Hare da378fc902 fix: complete Ticket post integration and expose targets
Preserve ordered targets in Workspace ShowTicket and native reads. Complete authenticated post-edit transport, shared dispatch, generated contracts and regression fixtures. Keep canonical content-edit delivery inputs transactional and separate from discussion posts.
2026-10-14 14:49:20 +09:00

855 lines
36 KiB
Rust

//! Adopted compatibility contract: canonical standalone schema7 six-state snapshots
//! compose into schema10, preserving original evidence and routing handoff.
use rusqlite::{Connection, params};
use tempfile::TempDir;
use ticket::*;
fn schema7(connection: &Connection) {
connection
.execute_batch(include_str!("fixtures/schema7.sql"))
.unwrap();
connection.execute_batch("CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL);
INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'legacy');").unwrap();
}
#[test]
fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_without_success_inference()
{
let temp = TempDir::new().unwrap();
let path = temp.path().join("tickets.db");
let connection = Connection::open(&path).unwrap();
schema7(&connection);
connection
.execute_batch("CREATE TABLE legacy_worker_status (ticket_id TEXT, status TEXT);")
.unwrap();
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit, assignee, queued_by, queued_at, resolution) VALUES ('workspace', ?1, ?1, ?1, 'open', 'task', 'P2', 'criteria', ?1, 1, 'retained-assignee', 'legacy-router', 'legacy-queue-time', 'Original decision text')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, kind, from_state, to_state, reason, author, body) VALUES ('workspace', ?1, 0, 'state_changed', 'planning', ?1, 'original reason', 'legacy-actor', 'legacy body')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'operation_key', 'legacy-receipt')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'request_fingerprint', 'legacy-fingerprint')", params![state]).unwrap();
// Actual pre-content receipt spelling: preserve it as historical input,
// never promote an old counter fingerprint into a current content CAS.
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'expected_content_digest', 'old-input')", params![state]).unwrap();
connection.execute(
"UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'",
[state],
).unwrap();
connection.execute("INSERT INTO typed_ticket_event_references VALUES ('workspace', ?1, 0, 0, 'merge_request', 'MR-existing')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_targets VALUES ('workspace', ?1, 0, 'main', 'develop', 'read_write')", params![state]).unwrap();
connection
.execute(
"INSERT INTO legacy_worker_status VALUES (?1, 'released')",
params![state],
)
.unwrap();
}
// Snapshot evidence before migration, including untyped `done` and existing MR references.
let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
let old_attributes: Vec<(String, String, String)> = connection
.prepare(
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
)
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.unwrap()
.collect::<std::result::Result<_, _>>()
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
let preserved_attributes: Vec<(String, String, String)> = connection
.prepare(
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
)
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.unwrap()
.collect::<std::result::Result<_, _>>()
.unwrap();
assert_eq!(old_attributes, preserved_attributes);
verify_sqlite_ticket_schema(&connection).unwrap();
let marker: i64 = connection
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(marker, 10);
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
assert_eq!(after_events, historical_events);
let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
let ticket = store.show(state.into()).unwrap();
let expected_state = if matches!(state, "done" | "closed") {
TicketState::Closed
} else {
TicketState::Open
};
assert_eq!(ticket.meta.workflow_state, expected_state);
let old_key_request = TicketCaseUpdate {
expected_content_digest: ticket_content_digest(&ticket),
expected_state,
expected_closure_operation_key: ticket.closure_operation_key.clone(),
operation_key: "legacy-receipt".into(),
action: if expected_state == TicketState::Closed {
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::Unknown,
}
} else {
TicketCaseAction::Close {
reason: TicketClosureReason::Unknown,
}
},
explanation: "Cannot reinterpret a frozen operation receipt".into(),
references: vec![],
author: Some("human".into()),
};
assert!(matches!(
store.update_case(state, old_key_request),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(store.show(state.into()).unwrap(), ticket);
let legacy = ticket.legacy_workflow_snapshot.as_ref().unwrap();
assert_eq!(legacy.workflow_state, state);
assert!(legacy.workflow_state_explicit);
assert_eq!(legacy.status, "open");
assert_eq!(legacy.queued_by.as_deref(), Some("legacy-router"));
assert_eq!(legacy.queued_at.as_deref(), Some("legacy-queue-time"));
assert_eq!(legacy.resolution.as_deref(), Some("Original decision text"));
assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee"));
assert_eq!(ticket.meta.targets[0].repository_key, "main");
assert_eq!(ticket.events[1].from.as_deref(), Some("planning"));
assert_eq!(ticket.events[1].to.as_deref(), Some(state));
assert_eq!(ticket.events[1].references[0].target, "MR-existing");
assert_eq!(
ticket.events[1].attributes["request_fingerprint"],
"legacy-fingerprint"
);
if expected_state == TicketState::Closed {
let judgment = ticket.current_closure.as_ref().unwrap();
assert_eq!(
judgment.reason,
TicketClosureReason::Unknown,
"old done must never mean successful completion"
);
assert_eq!(judgment.explanation, "Original decision text");
let reopened = store
.update_case(
state,
TicketCaseUpdate {
expected_content_digest: ticket_content_digest(&ticket),
expected_state,
expected_closure_operation_key: ticket.closure_operation_key.clone(),
operation_key: "reopen-migrated".into(),
action: TicketCaseAction::Reopen,
explanation: "Reconsider the case".into(),
references: Vec::new(),
author: Some("human".into()),
},
)
.unwrap();
assert!(reopened.current_closure.is_none());
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
assert_eq!(
reopened.legacy_workflow_snapshot,
ticket.legacy_workflow_snapshot
);
} else {
assert!(ticket.current_closure.is_none());
}
}
let released: i64 = connection
.query_row(
"SELECT COUNT(*) FROM legacy_worker_status WHERE status = 'released'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(released, 6);
assert!(
connection
.execute(
"UPDATE typed_ticket_legacy_workflow_snapshot SET snapshot_json = '{}'",
[]
)
.is_err()
);
migrate_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM typed_ticket_legacy_workflow_snapshot",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
6
);
}
#[test]
fn unknown_legacy_state_rolls_back_the_independent_case_migration() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) VALUES ('workspace', 'unknown', 'unknown', 'unknown', 'open', 'task', 'P2', 'body', 'future-state', 1)", []).unwrap();
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
assert_eq!(
connection
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
7
);
assert_eq!(
connection
.query_row("SELECT workflow_state FROM typed_tickets", [], |row| row
.get::<_, String>(
0
))
.unwrap(),
"future-state"
);
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'typed_ticket_case_history'",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
0
);
}
#[test]
fn falsely_numbered_schema8_without_tags_is_rejected_without_writes() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
connection
.execute("UPDATE ticket_schema_migrations SET version = 8", [])
.unwrap();
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
assert_eq!(
connection
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
8
);
}
// Read actual SQLite values, not merely counts: receipt JSON, original context,
// name snapshots and event/reference/attribute rows must survive byte-for-byte.
fn rows(connection: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> {
// Compare the retained v7-v11 payload, excluding the new initial post and metadata.
let modern: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('typed_tickets') WHERE name='created_by')", [], |r| r.get(0)).unwrap();
let projection = match table {
"typed_tickets" if modern => {
"workspace_id,ticket_id,slug,title,status,kind,priority,(SELECT body FROM typed_ticket_events e WHERE e.workspace_id=typed_tickets.workspace_id AND e.ticket_id=typed_tickets.ticket_id AND e.event_index=-1),created_at,updated_at,assignee,readiness,workflow_state,workflow_state_explicit,queued_by,queued_at,resolution"
}
"typed_ticket_events" => {
"workspace_id,ticket_id,event_index,kind,author,at,status,from_state,to_state,reason,state_field,heading,body"
}
_ => "*",
};
let filter = if table.starts_with("typed_ticket_event") {
"WHERE event_index >= 0"
} else {
""
};
let mut stmt = connection
.prepare(&format!(
"SELECT {projection} FROM {table} {filter} ORDER BY rowid"
))
.unwrap();
let count = stmt.column_count();
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
.unwrap()
.collect::<std::result::Result<_, _>>()
.unwrap()
}
fn copy_tables(connection: &Connection, source: &std::path::Path, tables: &[&str]) {
connection
.execute(
"ATTACH DATABASE ?1 AS fixture_source",
[source.to_str().unwrap()],
)
.unwrap();
for table in tables {
let columns = connection
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap()
.query_map([], |r| r.get::<_, String>(1))
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
let selection = columns.iter().map(|column| {
if *table == "typed_tickets" && column == "body" {
"(SELECT body FROM fixture_source.typed_ticket_events e WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id AND e.event_index=-1)".to_owned()
} else { format!("t.{column}") }
}).collect::<Vec<_>>().join(",");
let filter = if table.starts_with("typed_ticket_event") {
"WHERE t.event_index >= 0"
} else {
""
};
connection.execute_batch(&format!("INSERT INTO {table} ({}) SELECT {selection} FROM fixture_source.{table} t {filter}", columns.join(","))).unwrap();
}
connection
.execute_batch("DETACH DATABASE fixture_source")
.unwrap();
}
fn mutate(backend: &SqliteTicketBackend, key: &str, action: WorkspaceTagAction) -> WorkspaceTag {
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: key.into(),
action,
})
.unwrap()
}
fn case_request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate {
TicketCaseUpdate {
expected_content_digest: ticket_content_digest(ticket),
expected_state: ticket.meta.workflow_state,
expected_closure_operation_key: ticket.closure_operation_key.clone(),
operation_key: key.into(),
action,
explanation: "Retain this judgment".into(),
references: vec![TicketReference {
kind: "result".into(),
target: "retained-result".into(),
}],
author: Some("authenticated-user".into()),
}
}
#[test]
fn tagged_schema8_composes_cases_preserving_tombstones_membership_context_receipts_and_thread() {
let temp = TempDir::new().unwrap();
let source_path = temp.path().join("tag-operations.db");
let source = SqliteTicketBackend::open(&source_path, "workspace")
.unwrap()
.with_event_attributes(std::collections::BTreeMap::from([
("source_account_id".into(), "account".into()),
("source_user_id".into(), "user".into()),
("source_actor_role".into(), "human".into()),
]));
let id = source
.create(NewTicket::new("historical tagged ticket"))
.unwrap()
.id;
// Generate real durable T-740 receipts/audit/event snapshots through its
// unchanged classification boundary, then seed only those rows into frozen8.
let create = WorkspaceTagMutation {
operation_key: "create".into(),
action: WorkspaceTagAction::Create {
name: "open".into(),
},
};
let original = source.mutate_workspace_tag(create.clone()).unwrap();
let attach = TicketTagSetRequest {
operation_key: "attach".into(),
expected_tag_ids: vec![],
tag_ids: vec![original.id.clone()],
};
let attach_result = source
.set_ticket_tags(id.clone().into(), attach.clone())
.unwrap();
let renamed = mutate(
&source,
"rename",
WorkspaceTagAction::Rename {
tag_id: original.id.clone(),
expected_last_operation_key: "create".into(),
name: "closed".into(),
},
);
let deleted = mutate(
&source,
"delete",
WorkspaceTagAction::Delete {
tag_id: renamed.id.clone(),
expected_last_operation_key: "rename".into(),
},
);
let recreated = mutate(
&source,
"recreate",
WorkspaceTagAction::Create {
name: "closed".into(),
},
);
assert_ne!(recreated.id, deleted.id);
source
.set_ticket_tags(
id.clone().into(),
TicketTagSetRequest {
operation_key: "attach-recreated".into(),
expected_tag_ids: vec![],
tag_ids: vec![recreated.id.clone()],
},
)
.unwrap();
let path = temp.path().join("retained8.db");
let connection = Connection::open(&path).unwrap();
connection
.pragma_update(None, "foreign_keys", "ON")
.unwrap();
schema7(&connection);
connection
.execute_batch(include_str!("fixtures/schema8_tags.sql"))
.unwrap();
connection
.execute("UPDATE ticket_schema_migrations SET version=8", [])
.unwrap();
let preserved = [
"workspace_tags",
"typed_ticket_tags",
"workspace_tag_operations",
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
];
copy_tables(&connection, &source_path, &["typed_tickets"]);
copy_tables(&connection, &source_path, &preserved);
// Six-state retained data, not a current10 DB with its number changed.
connection.execute("UPDATE typed_tickets SET workflow_state='done', status='done', resolution='Historical explanation', queued_by='old-router',queued_at='old-time'", []).unwrap();
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,body) VALUES ('workspace',?1,99,'close','old-user','Original close')", [&id]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'operation_key','old-close-receipt')", [&id]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'request_fingerprint','old-close-fingerprint')", [&id]).unwrap();
let before: Vec<_> = preserved.iter().map(|t| rows(&connection, t)).collect();
verify_legacy_sqlite_ticket_schema(&connection).unwrap();
assert!(verify_sqlite_ticket_schema(&connection).is_err());
assert!(verify_pre_classification_ticket_schema(&connection).is_err());
migrate_sqlite_ticket_schema(&connection).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(
rows(&connection, "ticket_schema_migrations")[0][0],
rusqlite::types::Value::Integer(10)
);
for (table, expected) in preserved.iter().zip(&before) {
assert_eq!(&rows(&connection, table), expected, "changed {table}");
}
let backend = SqliteTicketBackend::open_verified(&path, "workspace")
.unwrap()
.with_event_attributes(std::collections::BTreeMap::from([
("source_account_id".into(), "account".into()),
("source_user_id".into(), "user".into()),
("source_actor_role".into(), "human".into()),
]));
let ticket = backend.show(id.clone().into()).unwrap();
assert_eq!(ticket.tags, vec![recreated]);
assert_eq!(
ticket_content_digest(&ticket),
ticket_content_digest(&source.show(id.clone().into()).unwrap())
);
assert!(matches!(
backend.update_case(
&id,
case_request(&ticket, "old-close-receipt", TicketCaseAction::Reopen)
),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(
ticket.current_closure.as_ref().unwrap().reason,
TicketClosureReason::Unknown
);
assert_eq!(
ticket.current_closure.as_ref().unwrap().explanation,
"Historical explanation"
);
assert_eq!(
ticket
.legacy_workflow_snapshot
.as_ref()
.unwrap()
.workflow_state,
"done"
);
assert_eq!(
backend.mutate_workspace_tag(create.clone()).unwrap(),
original
);
assert_eq!(
backend
.set_ticket_tags(id.clone().into(), attach.clone())
.unwrap(),
attach_result
);
let other = backend
.clone()
.with_event_attributes(std::collections::BTreeMap::from([
("source_account_id".into(), "other-account".into()),
("source_user_id".into(), "user".into()),
]));
assert!(matches!(
other.mutate_workspace_tag(create),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert!(matches!(
other.set_ticket_tags(id.clone().into(), attach),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert!(
backend
.set_ticket_tags(
id.clone().into(),
TicketTagSetRequest {
operation_key: "attach-deleted".into(),
expected_tag_ids: ticket.tags.iter().map(|t| t.id.clone()).collect(),
tag_ids: vec![deleted.id.clone()],
}
)
.is_err()
);
assert!(
connection
.execute(
"INSERT INTO typed_ticket_tags VALUES ('foreign',?1,?2)",
params![id, deleted.id]
)
.is_err()
);
let judgments = rows(&connection, "typed_ticket_case_history");
migrate_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(rows(&connection, "typed_ticket_case_history"), judgments);
for (table, expected) in preserved.iter().zip(&before) {
assert_eq!(
&rows(&connection, table),
expected,
"replay changed {table}"
);
}
let reopened = backend
.update_case(
&id,
case_request(&ticket, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
assert_eq!(reopened.tags, ticket.tags);
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
assert_eq!(
ticket_content_digest(&reopened),
ticket_content_digest(&ticket)
);
assert!(reopened.current_closure.is_none());
let fk_failures: i64 = connection
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(fk_failures, 0);
}
#[test]
fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_receipts() {
let temp = TempDir::new().unwrap();
let source_path = temp.path().join("case-operations.db");
let source = SqliteTicketBackend::open(&source_path, "workspace")
.unwrap()
.with_event_attributes(std::collections::BTreeMap::from([
("source_runtime_id".into(), "runtime".into()),
("source_worker_id".into(), "worker".into()),
]));
let id = source.create(NewTicket::new("existing case")).unwrap().id;
let before = source.show(id.clone().into()).unwrap();
let close = case_request(
&before,
"close",
TicketCaseAction::Close {
reason: TicketClosureReason::Duplicate,
},
);
let closed = source.update_case(&id, close.clone()).unwrap();
let corrected = source
.update_case(
&id,
case_request(
&closed,
"correct",
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::NotPlanned,
},
),
)
.unwrap();
let mut reopened = source
.update_case(
&id,
case_request(&corrected, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
reopened.events[0]
.attributes
.retain(|key, _| matches!(key.as_str(), "event_id" | "event_sequence"));
// The frozen SQLite loader published ticket_id:event_index identities.
// These remain identical after migration, without replacing them with display ordinals.
let path = temp.path().join("retained9.db");
let connection = Connection::open(&path).unwrap();
schema7(&connection);
connection
.execute_batch(include_str!("fixtures/schema9_cases.sql"))
.unwrap();
connection
.execute("UPDATE ticket_schema_migrations SET version=9", [])
.unwrap();
let tables = [
"typed_tickets",
"typed_ticket_case_metadata",
"typed_ticket_case_history",
"typed_ticket_legacy_workflow_snapshot",
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
"workspace_resource_keys",
"workspace_resource_key_counters",
];
copy_tables(&connection, &source_path, &tables);
// A migrated closed case already judged Unknown by standalone9 must remain
// exactly that judgment, with its original migration actor/key and handoff.
connection.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,resolution) VALUES ('workspace','old9','old9','Old migrated case','closed','task','P2','criteria','closed',1,'Original legacy close')", []).unwrap();
let snapshot = TicketLegacyWorkflowSnapshot {
workflow_state: "done".into(),
workflow_state_explicit: true,
status: "done".into(),
queued_by: Some("original-router".into()),
queued_at: Some("original-time".into()),
resolution: Some("Original legacy close".into()),
};
let judgment = TicketCaseHistoryEntry {
operation_key: "migration-v9:old9".into(),
previous_closure_operation_key: None,
action: TicketCaseAction::Close {
reason: TicketClosureReason::Unknown,
},
explanation: "Original legacy close".into(),
references: vec![],
author: "ticket-schema-migration-v9".into(),
at: "original-migration-time".into(),
};
connection
.execute(
"INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES ('workspace','old9',?1)",
[serde_json::to_string(&snapshot).unwrap()],
)
.unwrap();
connection.execute("INSERT INTO typed_ticket_case_history VALUES ('workspace','old9',0,'migration-v9:old9','migration-v9',?1)", [serde_json::to_string(&judgment).unwrap()]).unwrap();
connection.execute("INSERT INTO typed_ticket_case_metadata VALUES ('workspace','old9','migration-v9:old9')", []).unwrap();
let original: Vec<_> = tables.iter().map(|t| rows(&connection, t)).collect();
migrate_sqlite_ticket_schema(&connection).unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
for (table, expected) in tables.iter().zip(&original) {
assert_eq!(
&rows(&connection, table),
expected,
"changed independent9 {table}"
);
}
assert!(rows(&connection, "workspace_tags").is_empty());
let backend = SqliteTicketBackend::open_verified(&path, "workspace")
.unwrap()
.with_event_attributes(std::collections::BTreeMap::from([
("source_runtime_id".into(), "runtime".into()),
("source_worker_id".into(), "worker".into()),
]));
let old9 = backend.show("old9".into()).unwrap();
assert_eq!(old9.case_history, vec![judgment]);
assert_eq!(old9.legacy_workflow_snapshot, Some(snapshot));
assert_eq!(
old9.current_closure.unwrap().reason,
TicketClosureReason::Unknown
);
assert_eq!(backend.show(id.clone().into()).unwrap(), reopened);
assert_eq!(backend.update_case(&id, close.clone()).unwrap(), reopened);
let other = backend
.clone()
.with_event_attributes(std::collections::BTreeMap::from([
("source_runtime_id".into(), "runtime".into()),
("source_worker_id".into(), "other-worker".into()),
]));
assert!(matches!(
other.update_case(&id, close),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(rows(&connection, "typed_ticket_case_history"), original[2]);
}
#[test]
fn migration_inside_host_transaction_is_resumable_and_never_commits_host_writes() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
verify_pre_classification_ticket_schema(&connection).unwrap();
assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err());
connection.execute_batch("CREATE TABLE host_work (value TEXT); BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('pending');").unwrap();
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
assert!(!connection.is_autocommit());
connection.execute_batch("ROLLBACK").unwrap();
verify_pre_classification_ticket_schema(&connection).unwrap();
assert!(rows(&connection, "host_work").is_empty());
connection.execute_batch("BEGIN IMMEDIATE").unwrap();
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
connection.execute_batch("COMMIT").unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
}
#[test]
fn falsely_numbered_schema9_and_weakened_immutable_triggers_are_rejected_without_migration() {
for drift in [
"UPDATE ticket_schema_migrations SET version=9",
"UPDATE ticket_schema_migrations SET version=9; DROP TRIGGER typed_ticket_case_history_no_update; CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history BEGIN SELECT 1; END;",
] {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
if drift.contains("DROP TRIGGER") {
connection
.execute_batch(include_str!("fixtures/schema9_cases.sql"))
.unwrap();
}
connection.execute_batch(drift).unwrap();
let marker = rows(&connection, "ticket_schema_migrations");
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
assert_eq!(rows(&connection, "ticket_schema_migrations"), marker);
let tags: i64 = connection
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(tags, 0);
}
}
#[test]
fn failed_component_conversion_rolls_back_its_ddl_and_keeps_host_transaction_retryable() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
connection.execute_batch("CREATE TABLE host_work (value TEXT);
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES ('workspace','ticket','ticket','Case','open','task','P2','criteria','ready',1);
CREATE TRIGGER host_abort_case_update BEFORE UPDATE OF workflow_state ON typed_tickets
BEGIN SELECT RAISE(ABORT,'host failure'); END;
BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('retained host work');").unwrap();
assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err());
assert!(!connection.is_autocommit());
verify_pre_classification_ticket_schema(&connection).unwrap();
assert_eq!(rows(&connection, "host_work").len(), 1);
let tags: i64 = connection
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(tags, 0, "DDL before failed state conversion must roll back");
connection
.execute_batch("DROP TRIGGER host_abort_case_update")
.unwrap();
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
connection.execute_batch("COMMIT").unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(rows(&connection, "host_work").len(), 1);
}
#[test]
fn retained_workspace_foreign_keys_survive_composition_without_rebuilding_ticket_or_tags() {
let connection = Connection::open_in_memory().unwrap();
connection
.pragma_update(None, "foreign_keys", "ON")
.unwrap();
connection.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); INSERT INTO workspaces VALUES ('workspace');").unwrap();
// Ticket accepts stricter host-owned Workspace references and must preserve
// them, not rebuild parents against its weaker standalone authority schema.
let schema = include_str!("fixtures/schema7.sql");
let (before, ticket) = schema.split_once("CREATE TABLE typed_tickets (").unwrap();
let (ticket, after) = ticket
.split_once("CREATE TABLE typed_ticket_targets (")
.unwrap();
let ticket = ticket.replace("PRIMARY KEY (workspace_id, ticket_id)",
"PRIMARY KEY (workspace_id, ticket_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE");
connection
.execute_batch(&format!(
"{before}CREATE TABLE typed_tickets ({ticket}CREATE TABLE typed_ticket_targets ({after}"
))
.unwrap();
let tags = include_str!("fixtures/schema8_tags.sql")
.replace("PRIMARY KEY (workspace_id, tag_id)", "PRIMARY KEY (workspace_id, tag_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE")
.replace("PRIMARY KEY (workspace_id, operation_key)", "PRIMARY KEY (workspace_id, operation_key), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE");
connection.execute_batch(&tags).unwrap();
connection.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL);
INSERT INTO ticket_schema_migrations VALUES(8,'ticket schema baseline','retained');
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES('workspace','ticket','ticket','Case','done','task','P2','criteria','done',1);
INSERT INTO workspace_tags VALUES('workspace','tag','closed','closed','create',0);
INSERT INTO typed_ticket_tags VALUES('workspace','ticket','tag');").unwrap();
let signature = |table| {
let mut stmt = connection
.prepare(&format!(
"SELECT * FROM pragma_foreign_key_list('{table}') ORDER BY id,seq"
))
.unwrap();
let count = stmt.column_count();
stmt.query_map([], |row| {
(0..count)
.map(|i| row.get::<_, rusqlite::types::Value>(i))
.collect::<rusqlite::Result<Vec<_>>>()
})
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
let tables = [
"typed_tickets",
"workspace_tags",
"workspace_tag_operations",
"typed_ticket_tags",
];
let before: Vec<_> = tables.iter().map(|t| signature(t)).collect();
verify_legacy_sqlite_ticket_schema(&connection).unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
for (table, expected) in tables.iter().zip(before) {
assert_eq!(signature(table), expected, "lost host FK on {table}");
}
assert!(
connection
.execute(
"INSERT INTO workspace_tags VALUES('foreign','tag','open','open','create',0)",
[]
)
.is_err()
);
assert!(
connection
.execute(
"INSERT INTO typed_ticket_tags VALUES('foreign','ticket','tag')",
[]
)
.is_err()
);
let failures: i64 = connection
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(failures, 0);
}