feat: adopt Workspace resource keys
This commit is contained in:
+49
-38
@@ -27,7 +27,9 @@ mod sqlite_schema;
|
||||
pub mod tool;
|
||||
|
||||
pub use sqlite_schema::{
|
||||
LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema,
|
||||
LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_resource_key_schema_in_transaction,
|
||||
migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_through,
|
||||
verify_sqlite_ticket_schema,
|
||||
};
|
||||
|
||||
const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"];
|
||||
@@ -124,7 +126,7 @@ fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TicketSu
|
||||
let workflow_state = row.get::<_, String>(7)?;
|
||||
Ok(TicketSummary {
|
||||
id: row.get(0)?,
|
||||
human_key: None,
|
||||
resource_key: None,
|
||||
slug: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
|
||||
@@ -928,7 +930,7 @@ impl TicketListQuery {
|
||||
pub struct TicketRef {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub human_key: Option<String>,
|
||||
pub resource_key: Option<String>,
|
||||
pub slug: String,
|
||||
pub status: TicketStatus,
|
||||
}
|
||||
@@ -1544,7 +1546,7 @@ pub struct OrchestrationPlanRecord {
|
||||
pub struct TicketMeta {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub human_key: Option<String>,
|
||||
pub resource_key: Option<String>,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub status: ExtensibleTicketStatus,
|
||||
@@ -1569,7 +1571,7 @@ pub struct TicketMeta {
|
||||
pub struct TicketSummary {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub human_key: Option<String>,
|
||||
pub resource_key: Option<String>,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub status: ExtensibleTicketStatus,
|
||||
@@ -2677,7 +2679,7 @@ impl SqliteTicketBackend {
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?;
|
||||
for summary in &mut summaries {
|
||||
summary.human_key = Self::human_key_for(conn, &self.workspace_id, &summary.id)?;
|
||||
summary.resource_key = Self::resource_key_for(conn, &self.workspace_id, &summary.id)?;
|
||||
}
|
||||
let has_more = summaries.len() > query.limit;
|
||||
summaries.truncate(query.limit);
|
||||
@@ -2762,7 +2764,7 @@ impl SqliteTicketBackend {
|
||||
updated_at,
|
||||
) = row.map_err(sqlite_err)?;
|
||||
summaries.push(TicketSummary {
|
||||
human_key: Self::human_key_for(conn, &self.workspace_id, &id)?,
|
||||
resource_key: Self::resource_key_for(conn, &self.workspace_id, &id)?,
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
@@ -2944,8 +2946,8 @@ impl SqliteTicketBackend {
|
||||
"SELECT ticket_id FROM typed_tickets
|
||||
WHERE workspace_id = ?1 AND (ticket_id = ?2 OR slug = ?2)
|
||||
UNION
|
||||
SELECT resource_id FROM workspace_resource_human_keys
|
||||
WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND human_key = ?2
|
||||
SELECT resource_id FROM workspace_resource_keys
|
||||
WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND resource_key = ?2
|
||||
ORDER BY 1",
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
@@ -2967,13 +2969,13 @@ impl SqliteTicketBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn human_key_for(
|
||||
fn resource_key_for(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<String>> {
|
||||
conn.query_row(
|
||||
"SELECT human_key FROM workspace_resource_human_keys
|
||||
"SELECT resource_key FROM workspace_resource_keys
|
||||
WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND resource_id = ?2",
|
||||
params![workspace_id, ticket_id],
|
||||
|row| row.get(0),
|
||||
@@ -2982,44 +2984,50 @@ impl SqliteTicketBackend {
|
||||
.map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn allocate_human_key(
|
||||
fn allocate_resource_key(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
allocated_at: &str,
|
||||
) -> Result<String> {
|
||||
if let Some(existing) = Self::human_key_for(conn, workspace_id, ticket_id)? {
|
||||
if let Some(existing) = Self::resource_key_for(conn, workspace_id, ticket_id)? {
|
||||
return Ok(existing);
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO workspace_resource_human_key_counters
|
||||
"INSERT OR IGNORE INTO workspace_resource_key_counters
|
||||
(workspace_id, resource_kind, next_sequence) VALUES (?1, 'ticket', 1)",
|
||||
params![workspace_id],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let sequence: i64 = conn
|
||||
.query_row(
|
||||
"SELECT next_sequence FROM workspace_resource_human_key_counters
|
||||
"SELECT next_sequence FROM workspace_resource_key_counters
|
||||
WHERE workspace_id = ?1 AND resource_kind = 'ticket'",
|
||||
params![workspace_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
conn.execute(
|
||||
"UPDATE workspace_resource_human_key_counters SET next_sequence = ?3
|
||||
"UPDATE workspace_resource_key_counters SET next_sequence = ?3
|
||||
WHERE workspace_id = ?1 AND resource_kind = 'ticket' AND next_sequence = ?2",
|
||||
params![workspace_id, sequence, sequence + 1],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let human_key = format!("T-{sequence}");
|
||||
let resource_key = format!("T-{sequence}");
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_resource_human_keys
|
||||
(workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at)
|
||||
"INSERT INTO workspace_resource_keys
|
||||
(workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at)
|
||||
VALUES (?1, 'ticket', ?2, ?3, ?4, ?5)",
|
||||
params![workspace_id, ticket_id, sequence, human_key, allocated_at],
|
||||
params![
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
sequence,
|
||||
resource_key,
|
||||
allocated_at
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
Ok(human_key)
|
||||
Ok(resource_key)
|
||||
}
|
||||
|
||||
fn ticket_exists(&self, conn: &Connection, id: &str) -> Result<bool> {
|
||||
@@ -3165,7 +3173,7 @@ impl SqliteTicketBackend {
|
||||
let state_raw: String = row.get(12)?;
|
||||
Ok(TicketMeta {
|
||||
id: row.get(0)?,
|
||||
human_key: None,
|
||||
resource_key: None,
|
||||
slug: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()),
|
||||
@@ -3193,7 +3201,7 @@ impl SqliteTicketBackend {
|
||||
self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst);
|
||||
let (mut meta, body, resolution): (TicketMeta, String, Option<String>) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#,
|
||||
params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?;
|
||||
meta.human_key = Self::human_key_for(conn, &self.workspace_id, ticket_id)?;
|
||||
meta.resource_key = Self::resource_key_for(conn, &self.workspace_id, ticket_id)?;
|
||||
meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?;
|
||||
meta.risk_flags =
|
||||
self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?;
|
||||
@@ -3365,7 +3373,7 @@ impl SqliteTicketBackend {
|
||||
let mut summaries = Vec::new();
|
||||
for row in rows {
|
||||
let mut meta = row.map_err(sqlite_err)?;
|
||||
meta.human_key = Self::human_key_for(conn, &self.workspace_id, &meta.id)?;
|
||||
meta.resource_key = Self::resource_key_for(conn, &self.workspace_id, &meta.id)?;
|
||||
if !filter.matches_state(meta.workflow_state) {
|
||||
continue;
|
||||
}
|
||||
@@ -3515,7 +3523,7 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
};
|
||||
let meta = TicketMeta {
|
||||
id: id.clone(),
|
||||
human_key: None,
|
||||
resource_key: None,
|
||||
slug: input.slug.clone().unwrap_or_else(|| id.clone()),
|
||||
title: input.title,
|
||||
status,
|
||||
@@ -3567,11 +3575,11 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
relations: TicketRelationView::default(),
|
||||
resolution: None,
|
||||
};
|
||||
let human_key = Self::allocate_human_key(conn, &self.workspace_id, &id, &now)?;
|
||||
let resource_key = Self::allocate_resource_key(conn, &self.workspace_id, &id, &now)?;
|
||||
self.insert_ticket(conn, &ticket)?;
|
||||
Ok(TicketRef {
|
||||
id: id.clone(),
|
||||
human_key: Some(human_key),
|
||||
resource_key: Some(resource_key),
|
||||
slug: id,
|
||||
status: TicketStatus::Open,
|
||||
})
|
||||
@@ -4218,7 +4226,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||
atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
|
||||
Ok(TicketRef {
|
||||
id: id.clone(),
|
||||
human_key: None,
|
||||
resource_key: None,
|
||||
slug: id,
|
||||
status: TicketStatus::Open,
|
||||
})
|
||||
@@ -5256,7 +5264,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
|
||||
};
|
||||
TicketMeta {
|
||||
id: id.clone(),
|
||||
human_key: None,
|
||||
resource_key: None,
|
||||
slug: id,
|
||||
title: frontmatter.title.unwrap_or_default(),
|
||||
status,
|
||||
@@ -5281,7 +5289,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
|
||||
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
|
||||
TicketSummary {
|
||||
id: meta.id,
|
||||
human_key: meta.human_key,
|
||||
resource_key: meta.resource_key,
|
||||
slug: meta.slug,
|
||||
title: meta.title,
|
||||
status: meta.status,
|
||||
@@ -6890,7 +6898,7 @@ mod tests {
|
||||
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
|
||||
TicketSummary {
|
||||
id: "000TEST".to_string(),
|
||||
human_key: Some("T-1".to_string()),
|
||||
resource_key: Some("T-1".to_string()),
|
||||
slug: "000TEST".to_string(),
|
||||
title: "Test Ticket".to_string(),
|
||||
status: ExtensibleTicketStatus::Open,
|
||||
@@ -7296,14 +7304,14 @@ state: planning
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_human_keys_are_workspace_scoped_monotonic_and_resolvable() {
|
||||
fn sqlite_resource_keys_are_workspace_scoped_monotonic_and_resolvable() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("workspace.db");
|
||||
let backend = SqliteTicketBackend::open(&db_path, "workspace-a").unwrap();
|
||||
let first = backend.create(NewTicket::new("First")).unwrap();
|
||||
let second = backend.create(NewTicket::new("Second")).unwrap();
|
||||
assert_eq!(first.human_key.as_deref(), Some("T-1"));
|
||||
assert_eq!(second.human_key.as_deref(), Some("T-2"));
|
||||
assert_eq!(first.resource_key.as_deref(), Some("T-1"));
|
||||
assert_eq!(second.resource_key.as_deref(), Some("T-2"));
|
||||
assert_eq!(backend.show("T-1".into()).unwrap().meta.id, first.id);
|
||||
let projection = backend.list_workspace_projection(100).unwrap();
|
||||
let projected_second = projection
|
||||
@@ -7311,16 +7319,19 @@ state: planning
|
||||
.iter()
|
||||
.find(|item| item.summary.id == second.id)
|
||||
.unwrap();
|
||||
assert_eq!(projected_second.summary.human_key.as_deref(), Some("T-2"));
|
||||
assert_eq!(
|
||||
projected_second.summary.resource_key.as_deref(),
|
||||
Some("T-2")
|
||||
);
|
||||
|
||||
let other = SqliteTicketBackend::open(&db_path, "workspace-b").unwrap();
|
||||
let other_first = other.create(NewTicket::new("Other")).unwrap();
|
||||
assert_eq!(other_first.human_key.as_deref(), Some("T-1"));
|
||||
assert_eq!(other_first.resource_key.as_deref(), Some("T-1"));
|
||||
assert_eq!(other.show("T-1".into()).unwrap().meta.id, other_first.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_human_key_allocation_is_concurrency_safe() {
|
||||
fn sqlite_resource_key_allocation_is_concurrency_safe() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("workspace.db");
|
||||
SqliteTicketBackend::open(&db_path, "workspace-a").unwrap();
|
||||
@@ -7335,7 +7346,7 @@ state: planning
|
||||
backend
|
||||
.create(NewTicket::new(format!("Ticket {index}")))
|
||||
.unwrap()
|
||||
.human_key
|
||||
.resource_key
|
||||
.unwrap()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err};
|
||||
|
||||
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
||||
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
||||
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 5;
|
||||
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Migration {
|
||||
@@ -42,6 +42,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "add_workspace_human_keys",
|
||||
apply: add_workspace_human_keys,
|
||||
},
|
||||
Migration {
|
||||
version: 6,
|
||||
name: "rename_workspace_resource_keys",
|
||||
apply: rename_workspace_resource_keys,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -243,6 +248,24 @@ const fn column(
|
||||
/// use [`verify_sqlite_ticket_schema`] instead, so request paths never acquire DDL
|
||||
/// authority.
|
||||
pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
migrate_sqlite_ticket_schema_through(connection, LATEST_SQLITE_TICKET_SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
/// Applies Ticket migrations only through `target_version`.
|
||||
///
|
||||
/// This exists for the Workspace Server's ordered migration bridge: older Server
|
||||
/// migrations must materialize the Ticket schema shape they were written against
|
||||
/// before the current Ticket migration is applied at the matching Server version.
|
||||
#[doc(hidden)]
|
||||
pub fn migrate_sqlite_ticket_schema_through(
|
||||
connection: &Connection,
|
||||
target_version: i64,
|
||||
) -> Result<()> {
|
||||
if !(1..=LATEST_SQLITE_TICKET_SCHEMA_VERSION).contains(&target_version) {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"unsupported Ticket schema migration target {target_version}"
|
||||
)));
|
||||
}
|
||||
connection
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(sqlite_err)?;
|
||||
@@ -265,7 +288,20 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
validate_applied_migrations(&applied)?;
|
||||
|
||||
for migration in MIGRATIONS {
|
||||
if let Some(version) = applied
|
||||
.keys()
|
||||
.copied()
|
||||
.find(|version| *version > target_version)
|
||||
{
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket schema version {version} is newer than requested migration target {target_version}"
|
||||
)));
|
||||
}
|
||||
|
||||
for migration in MIGRATIONS
|
||||
.iter()
|
||||
.filter(|migration| migration.version <= target_version)
|
||||
{
|
||||
if applied.contains_key(&migration.version) {
|
||||
continue;
|
||||
}
|
||||
@@ -283,7 +319,22 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
.map_err(sqlite_err)?;
|
||||
}
|
||||
|
||||
verify_sqlite_ticket_schema(connection)
|
||||
if target_version == LATEST_SQLITE_TICKET_SCHEMA_VERSION {
|
||||
verify_sqlite_ticket_schema(connection)
|
||||
} else {
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
let expected = MIGRATIONS
|
||||
.iter()
|
||||
.filter(|migration| migration.version <= target_version)
|
||||
.map(|migration| (migration.version, migration.name.to_string()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
if applied != expected {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket schema migration history does not match target version {target_version}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
})();
|
||||
|
||||
match result {
|
||||
@@ -295,6 +346,47 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the resource-key Ticket migration inside a transaction owned by the
|
||||
/// Workspace Server. The caller must provide an active transaction; this function
|
||||
/// deliberately does not begin or commit one so the Ticket and Server migration
|
||||
/// markers can be persisted atomically.
|
||||
#[doc(hidden)]
|
||||
pub fn migrate_sqlite_ticket_resource_key_schema_in_transaction(
|
||||
connection: &Connection,
|
||||
) -> Result<()> {
|
||||
connection
|
||||
.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS ticket_schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
validate_applied_migrations(&applied)?;
|
||||
if applied.contains_key(&LATEST_SQLITE_TICKET_SCHEMA_VERSION) {
|
||||
return verify_sqlite_ticket_schema(connection);
|
||||
}
|
||||
let expected_previous = LATEST_SQLITE_TICKET_SCHEMA_VERSION - 1;
|
||||
if applied.len() != expected_previous as usize || !applied.contains_key(&expected_previous) {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket schema must be at version {expected_previous} before the resource-key migration"
|
||||
)));
|
||||
}
|
||||
let migration = MIGRATIONS
|
||||
.last()
|
||||
.ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".to_string()))?;
|
||||
(migration.apply)(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO ticket_schema_migrations (version, name, applied_at) VALUES (?1, ?2, datetime('now'))",
|
||||
params![migration.version, migration.name],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
verify_sqlite_ticket_schema(connection)
|
||||
}
|
||||
|
||||
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
|
||||
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
let mut diagnostics = Vec::new();
|
||||
@@ -591,6 +683,21 @@ fn add_workspace_human_keys(connection: &Connection) -> Result<()> {
|
||||
.map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn rename_workspace_resource_keys(connection: &Connection) -> Result<()> {
|
||||
connection
|
||||
.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE workspace_resource_human_keys RENAME TO workspace_resource_keys;
|
||||
ALTER TABLE workspace_resource_keys RENAME COLUMN human_key TO resource_key;
|
||||
ALTER TABLE workspace_resource_human_key_counters RENAME TO workspace_resource_key_counters;
|
||||
DROP INDEX IF EXISTS idx_workspace_resource_human_keys_reverse;
|
||||
CREATE INDEX idx_workspace_resource_keys_reverse
|
||||
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
|
||||
"#,
|
||||
)
|
||||
.map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn add_column_if_missing(
|
||||
connection: &Connection,
|
||||
table: &str,
|
||||
@@ -923,10 +1030,10 @@ mod tests {
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
|
||||
let versions = load_applied_migrations(&connection).unwrap();
|
||||
assert_eq!(versions.len(), 5);
|
||||
assert_eq!(versions.len(), 6);
|
||||
assert_eq!(
|
||||
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION),
|
||||
Some(&"add_workspace_human_keys".to_string())
|
||||
Some(&"rename_workspace_resource_keys".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1014,14 +1121,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v5_backfills_ticket_keys_by_creation_order_and_advances_counter() {
|
||||
fn v5_backfills_ticket_keys_and_v6_preserves_them_under_resource_key_schema() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
migrate_sqlite_ticket_schema_through(&connection, 4).unwrap();
|
||||
connection.execute_batch(
|
||||
"DROP TABLE workspace_resource_human_key_counters;
|
||||
DROP TABLE workspace_resource_human_keys;
|
||||
DELETE FROM ticket_schema_migrations WHERE version = 5;
|
||||
INSERT INTO typed_tickets (
|
||||
"INSERT INTO typed_tickets (
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||
workflow_state, workflow_state_explicit, created_at, updated_at
|
||||
) VALUES
|
||||
@@ -1029,12 +1133,12 @@ mod tests {
|
||||
('workspace-1', 'earlier', 'earlier', 'Earlier', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');"
|
||||
).unwrap();
|
||||
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
let keys = connection
|
||||
migrate_sqlite_ticket_schema_through(&connection, 5).unwrap();
|
||||
let legacy_keys = connection
|
||||
.prepare(
|
||||
"SELECT resource_id, human_key FROM workspace_resource_human_keys
|
||||
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'
|
||||
ORDER BY sequence",
|
||||
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'
|
||||
ORDER BY sequence",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| {
|
||||
@@ -1044,7 +1148,7 @@ mod tests {
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
keys,
|
||||
legacy_keys,
|
||||
vec![
|
||||
("earlier".into(), "T-1".into()),
|
||||
("later".into(), "T-2".into())
|
||||
@@ -1053,12 +1157,56 @@ mod tests {
|
||||
let next: i64 = connection
|
||||
.query_row(
|
||||
"SELECT next_sequence FROM workspace_resource_human_key_counters
|
||||
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'",
|
||||
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(next, 3);
|
||||
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
let resource_keys = connection
|
||||
.prepare(
|
||||
"SELECT resource_id, resource_key FROM workspace_resource_keys
|
||||
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'
|
||||
ORDER BY sequence",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(resource_keys, legacy_keys);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT next_sequence FROM workspace_resource_key_counters
|
||||
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
for legacy_table in [
|
||||
"workspace_resource_human_keys",
|
||||
"workspace_resource_human_key_counters",
|
||||
] {
|
||||
assert!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
|
||||
[legacy_table],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"{legacy_table} still exists"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1119,7 +1267,7 @@ mod tests {
|
||||
.to_string()
|
||||
.contains("unsupported Ticket schema migration version 99")
|
||||
);
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 6);
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1220,7 +1368,11 @@ mod tests {
|
||||
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap();
|
||||
connection
|
||||
.execute("DELETE FROM ticket_schema_migrations WHERE version>=3", [])
|
||||
.execute_batch(
|
||||
"DROP TABLE workspace_resource_key_counters;
|
||||
DROP TABLE workspace_resource_keys;
|
||||
DELETE FROM ticket_schema_migrations WHERE version >= 3;",
|
||||
)
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
let (kind,status,heading,body):(String,Option<String>,Option<String>,Option<String>)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap();
|
||||
@@ -1259,6 +1411,6 @@ mod tests {
|
||||
|
||||
let connection = Connection::open(database).unwrap();
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 5);
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 6);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user