Land release blocker fixes and workspace cleanup
This commit is contained in:
@@ -215,6 +215,125 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
ON transcripts(profile_id);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
9,
|
||||
"transcript_profile_fk",
|
||||
r#"
|
||||
INSERT OR IGNORE INTO profiles (id, name, initial_prompt, created_at)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', '', datetime('now'));
|
||||
|
||||
DROP TRIGGER IF EXISTS transcripts_ai;
|
||||
DROP TRIGGER IF EXISTS transcripts_ad;
|
||||
DROP TRIGGER IF EXISTS transcripts_au;
|
||||
DROP TABLE IF EXISTS transcripts_fts;
|
||||
DROP INDEX IF EXISTS idx_segments_transcript;
|
||||
DROP INDEX IF EXISTS idx_transcripts_created;
|
||||
DROP INDEX IF EXISTS idx_transcripts_profile_id;
|
||||
|
||||
ALTER TABLE segments RENAME TO segments_old;
|
||||
ALTER TABLE transcripts RENAME TO transcripts_old;
|
||||
|
||||
CREATE TABLE transcripts (
|
||||
id TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'microphone',
|
||||
title TEXT,
|
||||
audio_path TEXT,
|
||||
duration REAL NOT NULL DEFAULT 0.0,
|
||||
engine TEXT,
|
||||
model_id TEXT,
|
||||
inference_ms INTEGER,
|
||||
sample_rate INTEGER,
|
||||
audio_channels INTEGER,
|
||||
format_mode TEXT,
|
||||
remove_fillers INTEGER NOT NULL DEFAULT 0,
|
||||
british_english INTEGER NOT NULL DEFAULT 0,
|
||||
anti_hallucination INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
starred INTEGER NOT NULL DEFAULT 0,
|
||||
manual_tags TEXT NOT NULL DEFAULT '',
|
||||
template TEXT NOT NULL DEFAULT '',
|
||||
language TEXT NOT NULL DEFAULT '',
|
||||
segments_json TEXT NOT NULL DEFAULT '',
|
||||
profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'
|
||||
REFERENCES profiles(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transcripts_created
|
||||
ON transcripts(created_at);
|
||||
CREATE INDEX idx_transcripts_profile_id
|
||||
ON transcripts(profile_id);
|
||||
|
||||
INSERT INTO transcripts (
|
||||
id, text, source, title, audio_path, duration, engine, model_id,
|
||||
inference_ms, sample_rate, audio_channels, format_mode,
|
||||
remove_fillers, british_english, anti_hallucination, created_at,
|
||||
starred, manual_tags, template, language, segments_json, profile_id
|
||||
)
|
||||
SELECT
|
||||
id, text, source, title, audio_path, duration, engine, model_id,
|
||||
inference_ms, sample_rate, audio_channels, format_mode,
|
||||
remove_fillers, british_english, anti_hallucination, created_at,
|
||||
starred, manual_tags, template, language, segments_json,
|
||||
CASE
|
||||
WHEN profile_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE id = transcripts_old.profile_id
|
||||
)
|
||||
THEN profile_id
|
||||
ELSE '00000000-0000-0000-0000-000000000001'
|
||||
END
|
||||
FROM transcripts_old;
|
||||
|
||||
CREATE TABLE segments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transcript_id TEXT NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE,
|
||||
start_time REAL NOT NULL,
|
||||
end_time REAL NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX idx_segments_transcript
|
||||
ON segments(transcript_id);
|
||||
|
||||
INSERT INTO segments (id, transcript_id, start_time, end_time, text)
|
||||
SELECT id, transcript_id, start_time, end_time, text
|
||||
FROM segments_old;
|
||||
|
||||
DROP TABLE segments_old;
|
||||
DROP TABLE transcripts_old;
|
||||
|
||||
CREATE VIRTUAL TABLE transcripts_fts USING fts5(
|
||||
text,
|
||||
title,
|
||||
content='transcripts',
|
||||
content_rowid='rowid',
|
||||
tokenize='porter unicode61 remove_diacritics 2'
|
||||
);
|
||||
|
||||
CREATE TRIGGER transcripts_ai AFTER INSERT ON transcripts BEGIN
|
||||
INSERT INTO transcripts_fts(rowid, text, title)
|
||||
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
|
||||
END;
|
||||
|
||||
CREATE TRIGGER transcripts_ad AFTER DELETE ON transcripts BEGIN
|
||||
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
|
||||
VALUES ('delete', old.rowid, old.text, COALESCE(old.title, ''));
|
||||
END;
|
||||
|
||||
CREATE TRIGGER transcripts_au AFTER UPDATE ON transcripts BEGIN
|
||||
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
|
||||
VALUES ('delete', old.rowid, old.text, COALESCE(old.title, ''));
|
||||
INSERT INTO transcripts_fts(rowid, text, title)
|
||||
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
|
||||
END;
|
||||
|
||||
INSERT INTO transcripts_fts(rowid, text, title)
|
||||
SELECT rowid, text, COALESCE(title, '')
|
||||
FROM transcripts;
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -281,10 +400,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
/// implicitly commits (`VACUUM`, `REINDEX`, `ATTACH`), it must be split
|
||||
/// out into its own non-transactional migration — reviewer's job to
|
||||
/// flag.
|
||||
async fn run_migrations_slice(
|
||||
pool: &SqlitePool,
|
||||
migrations: &[(i64, &str, &str)],
|
||||
) -> Result<()> {
|
||||
async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)]) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
@@ -344,12 +460,22 @@ mod tests {
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::Row;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_run_on_empty_db() {
|
||||
async fn fk_test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
.expect("pool");
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("enable foreign keys");
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_run_on_empty_db() {
|
||||
let pool = fk_test_pool().await;
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
|
||||
@@ -357,7 +483,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 8);
|
||||
assert_eq!(count, 9);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -367,10 +493,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_idempotent() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
let pool = fk_test_pool().await;
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
run_migrations(&pool).await.unwrap();
|
||||
@@ -379,7 +502,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 8);
|
||||
assert_eq!(count, 9);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -440,11 +563,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_transcript_profile_provenance_adds_profile_id() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("pool");
|
||||
let pool = fk_test_pool().await;
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
let info = sqlx::query("PRAGMA table_info(transcripts)")
|
||||
@@ -459,11 +578,108 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parent_task_id_cascade_delete() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
async fn migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk() {
|
||||
let pool = fk_test_pool().await;
|
||||
run_migrations_up_to(&pool, 8).await.expect("migrate to v8");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, name, initial_prompt, created_at)
|
||||
VALUES ('profile-valid', 'Valid', '', datetime('now'))",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed valid profile");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO transcripts (
|
||||
id, text, source, title, audio_path, duration, engine, model_id,
|
||||
inference_ms, sample_rate, audio_channels, format_mode,
|
||||
remove_fillers, british_english, anti_hallucination, created_at,
|
||||
starred, manual_tags, template, language, segments_json, profile_id
|
||||
) VALUES (
|
||||
't-orphan', 'orphan body', 'microphone', 'Orphan', NULL, 0.0, NULL, NULL,
|
||||
NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'),
|
||||
0, '', '', '', '', 'profile-missing'
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed orphan transcript");
|
||||
sqlx::query(
|
||||
"INSERT INTO transcripts (
|
||||
id, text, source, title, audio_path, duration, engine, model_id,
|
||||
inference_ms, sample_rate, audio_channels, format_mode,
|
||||
remove_fillers, british_english, anti_hallucination, created_at,
|
||||
starred, manual_tags, template, language, segments_json, profile_id
|
||||
) VALUES (
|
||||
't-valid', 'valid body', 'microphone', 'Valid', NULL, 0.0, NULL, NULL,
|
||||
NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'),
|
||||
0, '', '', '', '', 'profile-valid'
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed valid transcript");
|
||||
sqlx::query(
|
||||
"INSERT INTO segments (transcript_id, start_time, end_time, text)
|
||||
VALUES ('t-orphan', 0.0, 1.0, 'segment')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed segment");
|
||||
|
||||
run_migrations(&pool).await.expect("migrate to v9");
|
||||
|
||||
let orphan_profile: String =
|
||||
sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-orphan'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read healed orphan");
|
||||
assert_eq!(orphan_profile, crate::DEFAULT_PROFILE_ID);
|
||||
|
||||
let valid_profile: String =
|
||||
sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-valid'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read preserved profile");
|
||||
assert_eq!(valid_profile, "profile-valid");
|
||||
|
||||
let segment_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM segments WHERE transcript_id = 't-orphan'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read migrated segments");
|
||||
assert_eq!(segment_count, 1, "segments must survive transcript rebuild");
|
||||
|
||||
let fk_rows = sqlx::query("PRAGMA foreign_key_list(transcripts)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
.expect("read transcript foreign keys");
|
||||
assert!(
|
||||
fk_rows.iter().any(|row| {
|
||||
row.get::<String, _>("table") == "profiles"
|
||||
&& row.get::<String, _>("from") == "profile_id"
|
||||
&& row.get::<String, _>("to") == "id"
|
||||
&& row.get::<String, _>("on_delete") == "RESTRICT"
|
||||
}),
|
||||
"transcripts.profile_id must reference profiles(id) with ON DELETE RESTRICT"
|
||||
);
|
||||
|
||||
let fts_hits: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM transcripts_fts WHERE transcripts_fts MATCH 'orphan'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("query rebuilt fts");
|
||||
assert_eq!(
|
||||
fts_hits, 1,
|
||||
"fts index must be rebuilt for existing transcripts"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parent_task_id_cascade_delete() {
|
||||
let pool = fk_test_pool().await;
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
|
||||
@@ -656,7 +872,7 @@ mod tests {
|
||||
run_migrations(&pool).await.expect("baseline migrate");
|
||||
|
||||
const POISON: &[(i64, &str, &str)] = &[(
|
||||
9,
|
||||
10,
|
||||
"rb-02 atomicity poison",
|
||||
r#"
|
||||
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
|
||||
@@ -680,15 +896,14 @@ mod tests {
|
||||
"poison_marker must not exist; got: {marker:?}"
|
||||
);
|
||||
|
||||
// `schema_version` must not include v9 — version insert is part
|
||||
// `schema_version` must not include v10 — version insert is part
|
||||
// of the same transaction that rolled back.
|
||||
let max: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read schema_version");
|
||||
let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read schema_version");
|
||||
assert_eq!(
|
||||
max, 8,
|
||||
max, 9,
|
||||
"schema_version must not advance past the failed migration"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user