ticket: own typed schema migrations

This commit is contained in:
2026-08-11 05:00:40 +09:00
parent 1d498d13c3
commit 9dccf99c50
6 changed files with 1171 additions and 158 deletions
+44 -150
View File
@@ -20,8 +20,13 @@ use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use thiserror::Error;
pub mod config;
mod sqlite_schema;
pub mod tool;
pub use sqlite_schema::{
LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema,
};
const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"];
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
@@ -2301,7 +2306,7 @@ impl fmt::Debug for SqliteTicketBackend {
}
impl SqliteTicketBackend {
pub fn new(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
fn configured(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
Self {
db_path: db_path.into(),
workspace_id: workspace_id.into(),
@@ -2311,6 +2316,27 @@ impl SqliteTicketBackend {
}
}
/// Opens a standalone Ticket backend, applying all Ticket-owned migrations once.
pub fn open(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Result<Self> {
let backend = Self::configured(db_path, workspace_id);
let connection = backend.connect()?;
migrate_sqlite_ticket_schema(&connection)?;
Ok(backend)
}
/// Connects to a database whose Ticket schema was composed by its startup owner.
///
/// This performs verification only and never creates or alters schema objects.
pub fn open_verified(
db_path: impl Into<PathBuf>,
workspace_id: impl Into<String>,
) -> Result<Self> {
let backend = Self::configured(db_path, workspace_id);
let connection = backend.connect()?;
verify_sqlite_ticket_schema(&connection)?;
Ok(backend)
}
pub fn with_event_attributes(mut self, attributes: BTreeMap<String, String>) -> Self {
self.event_attributes = attributes;
self
@@ -2338,7 +2364,6 @@ impl SqliteTicketBackend {
pub fn import_from_local_backend(&self, local: &LocalTicketBackend) -> Result<()> {
let conn = self.open_connection()?;
self.ensure_schema(&conn)?;
conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?;
let result = (|| {
for summary in local.list(TicketListQuery::all())? {
@@ -2351,7 +2376,7 @@ impl SqliteTicketBackend {
finish_sqlite_transaction(&conn, result)
}
fn open_connection(&self) -> Result<Connection> {
fn connect(&self) -> Result<Connection> {
if let Some(parent) = self.db_path.parent() {
fs::create_dir_all(parent).map_err(|error| io_err(parent, error))?;
}
@@ -2361,115 +2386,20 @@ impl SqliteTicketBackend {
Ok(conn)
}
fn ensure_schema(&self, conn: &Connection) -> Result<()> {
conn.execute_batch(r#"
CREATE TABLE IF NOT EXISTS typed_tickets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
kind TEXT NOT NULL,
priority TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT,
updated_at TEXT,
assignee TEXT,
readiness TEXT,
workflow_state TEXT NOT NULL,
workflow_state_explicit INTEGER NOT NULL,
queued_by TEXT,
queued_at TEXT,
resolution TEXT,
repository_id TEXT,
ref_selector TEXT,
PRIMARY KEY (workspace_id, ticket_id)
);
CREATE TABLE IF NOT EXISTS typed_ticket_labels (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_risk_flags (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_raw_frontmatter (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_events (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
event_index INTEGER NOT NULL,
kind TEXT NOT NULL,
author TEXT,
at TEXT,
status TEXT,
from_state TEXT,
to_state TEXT,
reason TEXT,
state_field TEXT,
heading TEXT,
body TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_event_references (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_event_attributes (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_relations (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, kind, target),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_orchestration_plans (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
record_id TEXT NOT NULL,
kind TEXT NOT NULL,
related_ticket TEXT,
note TEXT,
accepted_summary TEXT,
accepted_branch TEXT,
accepted_worktree TEXT,
accepted_role_plan TEXT,
author TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, record_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, relative_path),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
"#)
.map_err(sqlite_err)?;
ensure_sqlite_ticket_column(conn, "repository_id", "TEXT")?;
ensure_sqlite_ticket_column(conn, "ref_selector", "TEXT")?;
Ok(())
fn open_connection(&self) -> Result<Connection> {
let connection = self.connect()?;
verify_sqlite_ticket_schema(&connection)?;
Ok(connection)
}
fn with_write<R>(&self, op: impl FnOnce(&Connection) -> Result<R>) -> Result<R> {
let conn = self.open_connection()?;
self.ensure_schema(&conn)?;
conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?;
finish_sqlite_transaction(&conn, op(&conn))
}
fn with_read<R>(&self, op: impl FnOnce(&Connection) -> Result<R>) -> Result<R> {
let conn = self.open_connection()?;
self.ensure_schema(&conn)?;
op(&conn)
}
@@ -2915,30 +2845,6 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
}
}
fn ensure_sqlite_ticket_column(
conn: &rusqlite::Connection,
name: &str,
sql_type: &str,
) -> Result<()> {
let mut statement = conn
.prepare("PRAGMA table_info(typed_tickets)")
.map_err(sqlite_err)?;
let columns = statement
.query_map([], |row| row.get::<_, String>(1))
.map_err(sqlite_err)?;
for column in columns {
if column.map_err(sqlite_err)? == name {
return Ok(());
}
}
conn.execute(
format!("ALTER TABLE typed_tickets ADD COLUMN {name} {sql_type}").as_str(),
[],
)
.map_err(sqlite_err)?;
Ok(())
}
fn finish_sqlite_transaction<R>(conn: &Connection, result: Result<R>) -> Result<R> {
match result {
Ok(output) => {
@@ -6406,28 +6312,11 @@ state: planning
#[test]
fn sqlite_backend_persists_and_edits_ticket_target() {
let tmp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
let backend =
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
assert_ticket_target_edit_semantics(&backend);
}
#[test]
fn sqlite_ticket_target_columns_are_added_to_existing_table() {
let tmp = TempDir::new().unwrap();
let conn = rusqlite::Connection::open(tmp.path().join("workspace.db")).unwrap();
conn.execute_batch("CREATE TABLE typed_tickets (ticket_id TEXT PRIMARY KEY);")
.unwrap();
ensure_sqlite_ticket_column(&conn, "repository_id", "TEXT").unwrap();
ensure_sqlite_ticket_column(&conn, "ref_selector", "TEXT").unwrap();
let mut statement = conn.prepare("PRAGMA table_info(typed_tickets)").unwrap();
let columns = statement
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
assert!(columns.iter().any(|column| column == "repository_id"));
assert!(columns.iter().any(|column| column == "ref_selector"));
}
#[test]
fn local_backend_edit_item_supports_partial_body_replacement() {
let tmp = TempDir::new().unwrap();
@@ -6438,7 +6327,8 @@ state: planning
#[test]
fn sqlite_backend_edit_item_supports_partial_body_replacement() {
let tmp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
let backend =
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
assert_partial_body_replacement_semantics(&backend);
}
@@ -6446,7 +6336,7 @@ state: planning
fn sqlite_mutation_hook_failure_rolls_back_ticket_event() {
let tmp = TempDir::new().unwrap();
let db_path = tmp.path().join("workspace.db");
let backend = SqliteTicketBackend::new(&db_path, "workspace-test");
let backend = SqliteTicketBackend::open(&db_path, "workspace-test").unwrap();
let created = backend.create(NewTicket::new("Atomic mutation")).unwrap();
let before = backend
.show(TicketIdOrSlug::Id(created.id.clone()))
@@ -6480,7 +6370,8 @@ state: planning
#[test]
fn sqlite_backend_persists_core_ticket_operations() {
let tmp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
let backend =
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
let created = backend.create(NewTicket::new("SQLite Ticket")).unwrap();
backend
.add_event(
@@ -6501,7 +6392,9 @@ state: planning
)
.unwrap();
let reopened = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
let reopened =
SqliteTicketBackend::open_verified(tmp.path().join("workspace.db"), "workspace-test")
.unwrap();
let list = reopened.list(TicketListQuery::all()).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, created.id);
@@ -6531,7 +6424,8 @@ state: planning
let tmp = TempDir::new().unwrap();
let local = backend(&tmp);
let created = local.create(NewTicket::new("Legacy Ticket")).unwrap();
let db = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test");
let db =
SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test").unwrap();
db.import_from_local_backend(&local).unwrap();
let ticket = db.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
File diff suppressed because it is too large Load Diff