feat: finalizations for M0-M2

This commit is contained in:
2026-04-05 07:41:33 +02:00
parent e46294a022
commit ee61ad56ea
48 changed files with 1296 additions and 498 deletions

View File

@@ -27,9 +27,9 @@ impl Database {
&self.conn
}
/// Run all pending migrations.
pub fn migrate(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
migrations::run_migrations(&self.conn)
/// Run all pending migrations via refinery.
pub fn migrate(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
migrations::run_migrations(&mut self.conn)
}
}

View File

@@ -408,7 +408,7 @@ mod tests {
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
db
}

View File

@@ -2,15 +2,26 @@ use rust_stemmers::{Algorithm, Stemmer};
use rusqlite::Connection;
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
///
/// Schema follows specs/schema.allium: multi-column FTS5 with separate fields
/// for weighted search. Not content-sync — we manually manage stemmed content.
pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
"CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
post_id UNINDEXED,
content
title,
excerpt,
content,
tags,
categories
);
CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5(
media_id UNINDEXED,
content
title,
alt,
caption,
original_name,
tags
);"
)?;
Ok(())
@@ -52,8 +63,9 @@ pub fn stem_text(text: &str, language: &str) -> String {
.join(" ")
}
/// Index a post in the FTS table. Concatenates title, excerpt, content, tags,
/// categories, and all translation text. Pre-stems before inserting.
/// Index a post in the FTS table with separate columns per spec.
///
/// Concatenates translation text into the content column (stemmed per-language).
pub fn index_post(
conn: &Connection,
post_id: &str,
@@ -68,35 +80,30 @@ pub fn index_post(
// Remove existing entry
remove_post_from_index(conn, post_id)?;
let mut parts: Vec<&str> = vec![title];
if let Some(exc) = excerpt {
parts.push(exc);
}
let stemmed_title = stem_text(title, language);
let stemmed_excerpt = stem_text(excerpt.unwrap_or(""), language);
// Content column: post content + all translation texts
let mut content_parts = Vec::new();
if let Some(cnt) = content {
parts.push(cnt);
content_parts.push(stem_text(cnt, language));
}
let tags_str = tags.join(" ");
parts.push(&tags_str);
let cats_str = categories.join(" ");
parts.push(&cats_str);
let combined = parts.join(" ");
let mut full_text = stem_text(&combined, language);
// Add translation texts stemmed with their own language
for (text, trans_lang) in translations {
full_text.push(' ');
full_text.push_str(&stem_text(text, trans_lang));
content_parts.push(stem_text(text, trans_lang));
}
let stemmed_content = content_parts.join(" ");
let stemmed_tags = stem_text(&tags.join(" "), language);
let stemmed_categories = stem_text(&categories.join(" "), language);
conn.execute(
"INSERT INTO posts_fts (post_id, content) VALUES (?1, ?2)",
rusqlite::params![post_id, full_text],
"INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![post_id, stemmed_title, stemmed_excerpt, stemmed_content, stemmed_tags, stemmed_categories],
)?;
Ok(())
}
/// Index a media item in the FTS table.
/// Index a media item in the FTS table with separate columns per spec.
pub fn index_media(
conn: &Connection,
media_id: &str,
@@ -110,31 +117,25 @@ pub fn index_media(
) -> rusqlite::Result<()> {
remove_media_from_index(conn, media_id)?;
let mut parts: Vec<&str> = Vec::new();
if let Some(t) = title {
parts.push(t);
}
if let Some(a) = alt {
parts.push(a);
}
let stemmed_title = stem_text(title.unwrap_or(""), language);
let stemmed_alt = stem_text(alt.unwrap_or(""), language);
// Caption column: media caption + all translation texts
let mut caption_parts = Vec::new();
if let Some(c) = caption {
parts.push(c);
caption_parts.push(stem_text(c, language));
}
parts.push(original_name);
let tags_str = tags.join(" ");
parts.push(&tags_str);
let combined = parts.join(" ");
let mut full_text = stem_text(&combined, language);
for (text, trans_lang) in translations {
full_text.push(' ');
full_text.push_str(&stem_text(text, trans_lang));
caption_parts.push(stem_text(text, trans_lang));
}
let stemmed_caption = caption_parts.join(" ");
let stemmed_name = stem_text(original_name, language);
let stemmed_tags = stem_text(&tags.join(" "), language);
conn.execute(
"INSERT INTO media_fts (media_id, content) VALUES (?1, ?2)",
rusqlite::params![media_id, full_text],
"INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![media_id, stemmed_title, stemmed_alt, stemmed_caption, stemmed_name, stemmed_tags],
)?;
Ok(())
}
@@ -219,6 +220,18 @@ pub fn search_posts_filtered(
param_idx += 2;
}
if let Some(month) = filters.month {
if let Some(year) = filters.year {
let (end_year, end_month) = if month == 12 { (year + 1, 1) } else { (year, month as i32 + 1) };
let start = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
let end = chrono::NaiveDate::from_ymd_opt(end_year, end_month as u32, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
params.push(Box::new(start));
params.push(Box::new(end));
param_idx += 2;
}
}
let _ = param_idx; // suppress unused warning
sql.push_str("ORDER BY created_at DESC ");
@@ -256,8 +269,8 @@ mod tests {
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
db
}
@@ -281,10 +294,24 @@ mod tests {
ensure_fts_tables(db.conn()).unwrap(); // second call should not error
}
#[test]
fn fts_multi_column_schema() {
let db = setup();
// Verify posts_fts has the expected columns by inserting into each
db.conn().execute(
"INSERT INTO posts_fts (post_id, title, excerpt, content, tags, categories) VALUES ('p1', 'T', 'E', 'C', 'tg', 'ct')",
[],
).unwrap();
// Verify media_fts has the expected columns
db.conn().execute(
"INSERT INTO media_fts (media_id, title, alt, caption, original_name, tags) VALUES ('m1', 'T', 'A', 'C', 'N', 'tg')",
[],
).unwrap();
}
#[test]
fn stem_german() {
let stemmed = stem_text("Programmierung Entwicklung", "de");
// German stemmer should reduce these words
assert!(!stemmed.is_empty());
assert_ne!(stemmed, "Programmierung Entwicklung");
}
@@ -296,6 +323,70 @@ mod tests {
assert!(stemmed.contains("jump"));
}
#[test]
fn stem_french() {
let stemmed = stem_text("programmation développement", "fr");
assert!(!stemmed.is_empty());
assert_ne!(stemmed, "programmation développement");
}
#[test]
fn stem_spanish() {
let stemmed = stem_text("programación desarrollo", "es");
assert!(!stemmed.is_empty());
assert_ne!(stemmed, "programación desarrollo");
}
#[test]
fn stem_italian() {
let stemmed = stem_text("programmazione sviluppo", "it");
assert!(!stemmed.is_empty());
assert_ne!(stemmed, "programmazione sviluppo");
}
#[test]
fn stem_portuguese() {
let stemmed = stem_text("programação desenvolvimento", "pt");
assert!(!stemmed.is_empty());
assert_ne!(stemmed, "programação desenvolvimento");
}
#[test]
fn stem_russian() {
let stemmed = stem_text("программирование разработка", "ru");
assert!(!stemmed.is_empty());
assert_ne!(stemmed, "программирование разработка");
}
#[test]
fn stem_swedish() {
let stemmed = stem_text("utvecklingen datorerna", "sv");
assert!(!stemmed.is_empty());
assert_ne!(stemmed, "utvecklingen datorerna");
}
#[test]
fn stem_dutch() {
let stemmed = stem_text("programmering ontwikkeling", "nl");
assert!(!stemmed.is_empty());
assert_ne!(stemmed, "programmering ontwikkeling");
}
#[test]
fn stem_turkish() {
let stemmed = stem_text("programlama geliştirilmesi", "tr");
assert!(!stemmed.is_empty());
assert_ne!(stemmed, "programlama geliştirilmesi");
}
#[test]
fn stem_fallback_uses_english() {
// Unknown language falls back to English stemmer
let stemmed_unknown = stem_text("running", "xx");
let stemmed_english = stem_text("running", "en");
assert_eq!(stemmed_unknown, stemmed_english);
}
#[test]
fn index_and_search_post() {
let db = setup();
@@ -379,4 +470,119 @@ mod tests {
let results = search_posts(db.conn(), "develop", "en").unwrap();
assert_eq!(results, vec!["p1"]);
}
#[test]
fn search_by_title_field() {
let db = setup();
index_post(db.conn(), "p1", "Unique Title Here", None, Some("body text"), &[], &[], &[], "en").unwrap();
// Search for title content
let results = search_posts(db.conn(), "unique", "en").unwrap();
assert_eq!(results, vec!["p1"]);
}
#[test]
fn search_by_tags() {
let db = setup();
index_post(db.conn(), "p1", "Post", None, None, &["photography".into()], &[], &[], "en").unwrap();
let results = search_posts(db.conn(), "photography", "en").unwrap();
assert_eq!(results, vec!["p1"]);
}
#[test]
fn search_by_categories() {
let db = setup();
index_post(db.conn(), "p1", "Post", None, None, &[], &["article".into()], &[], "en").unwrap();
let results = search_posts(db.conn(), "article", "en").unwrap();
assert_eq!(results, vec!["p1"]);
}
#[test]
fn search_filtered_by_status() {
let db = setup();
// Insert a post in the posts table (needed for the filter query to join)
use crate::db::queries::project::{insert_project, make_test_project};
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db.conn().execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('post1', 'p1', 'Test Post', 'test', 'published', 1700000000000, 1700000000000)",
[],
).unwrap();
db.conn().execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('post2', 'p1', 'Draft Post', 'draft-post', 'draft', 1700000000000, 1700000000000)",
[],
).unwrap();
index_post(db.conn(), "post1", "Test Post", None, None, &[], &[], &[], "en").unwrap();
index_post(db.conn(), "post2", "Draft Post", None, None, &[], &[], &[], "en").unwrap();
let filters = PostSearchFilters {
status: Some("published"),
..Default::default()
};
let results = search_posts_filtered(db.conn(), "post", "en", &filters).unwrap();
assert_eq!(results, vec!["post1"]);
}
#[test]
fn search_filtered_by_year() {
let db = setup();
use crate::db::queries::project::{insert_project, make_test_project};
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
// 2024-06-15 in unix ms
let ts_2024: i64 = 1718409600000;
// 2023-06-15 in unix ms
let ts_2023: i64 = 1686873600000;
db.conn().execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)",
rusqlite::params![ts_2024],
).unwrap();
db.conn().execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)",
rusqlite::params![ts_2023],
).unwrap();
index_post(db.conn(), "p2024", "Year 2024", None, None, &[], &[], &[], "en").unwrap();
index_post(db.conn(), "p2023", "Year 2023", None, None, &[], &[], &[], "en").unwrap();
let filters = PostSearchFilters {
year: Some(2024),
..Default::default()
};
let results = search_posts_filtered(db.conn(), "year", "en", &filters).unwrap();
assert_eq!(results, vec!["p2024"]);
}
#[test]
fn search_filtered_with_limit_and_offset() {
let db = setup();
use crate::db::queries::project::{insert_project, make_test_project};
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
for i in 0..5 {
let id = format!("p{i}");
let slug = format!("post-{i}");
db.conn().execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)",
rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000],
).unwrap();
index_post(db.conn(), &id, "Searchable", None, None, &[], &[], &[], "en").unwrap();
}
let filters = PostSearchFilters {
limit: Some(2),
offset: Some(1),
..Default::default()
};
let results = search_posts_filtered(db.conn(), "searchable", "en", &filters).unwrap();
assert_eq!(results.len(), 2);
}
}

View File

@@ -1,334 +1,16 @@
use rusqlite::Connection;
/// Run all embedded migrations against the given connection.
mod embedded {
use refinery::embed_migrations;
embed_migrations!("migrations");
}
/// Run all embedded migrations against the given connection using refinery.
///
/// Creates the full bDS schema as specified in specs/schema.allium.
/// For M0 this is a single-step migration creating all core tables.
/// In M1 we will switch to refinery for proper versioned migrations.
pub fn run_migrations(conn: &Connection) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
conn.execute_batch(
"
-- ================================================================
-- CORE ENTITIES
-- ================================================================
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
data_path TEXT,
is_active INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
title TEXT NOT NULL,
slug TEXT NOT NULL,
excerpt TEXT,
content TEXT,
status TEXT NOT NULL DEFAULT 'draft',
author TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
file_path TEXT NOT NULL DEFAULT '',
checksum TEXT,
tags TEXT NOT NULL DEFAULT '[]',
categories TEXT NOT NULL DEFAULT '[]',
template_slug TEXT,
language TEXT,
do_not_translate INTEGER NOT NULL DEFAULT 0,
published_title TEXT,
published_content TEXT,
published_tags TEXT,
published_categories TEXT,
published_excerpt TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx
ON posts(project_id, slug);
CREATE TABLE IF NOT EXISTS post_translations (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
translation_for TEXT NOT NULL REFERENCES posts(id),
language TEXT NOT NULL,
title TEXT NOT NULL,
excerpt TEXT,
content TEXT,
status TEXT NOT NULL DEFAULT 'draft',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
file_path TEXT NOT NULL DEFAULT '',
checksum TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS post_translations_translation_language_idx
ON post_translations(translation_for, language);
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
title TEXT,
alt TEXT,
caption TEXT,
author TEXT,
file_path TEXT NOT NULL,
sidecar_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
checksum TEXT,
tags TEXT NOT NULL DEFAULT '[]',
language TEXT
);
CREATE TABLE IF NOT EXISTS media_translations (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
translation_for TEXT NOT NULL REFERENCES media(id),
language TEXT NOT NULL,
title TEXT,
alt TEXT,
caption TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS media_translations_translation_language_idx
ON media_translations(translation_for, language);
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
color TEXT,
post_template_slug TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS tags_project_name_idx
ON tags(project_id, name);
CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
slug TEXT NOT NULL,
title TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'post',
enabled INTEGER NOT NULL DEFAULT 1,
version INTEGER NOT NULL DEFAULT 1,
file_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
content TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS templates_project_slug_idx
ON templates(project_id, slug);
CREATE TABLE IF NOT EXISTS scripts (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
slug TEXT NOT NULL,
title TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'utility',
entrypoint TEXT NOT NULL DEFAULT 'render',
enabled INTEGER NOT NULL DEFAULT 1,
version INTEGER NOT NULL DEFAULT 1,
file_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
content TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS scripts_project_slug_idx
ON scripts(project_id, slug);
-- ================================================================
-- RELATIONSHIP TABLES
-- ================================================================
CREATE TABLE IF NOT EXISTS post_links (
id TEXT PRIMARY KEY,
source_post_id TEXT NOT NULL REFERENCES posts(id),
target_post_id TEXT NOT NULL REFERENCES posts(id),
link_text TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS post_media (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
post_id TEXT NOT NULL REFERENCES posts(id),
media_id TEXT NOT NULL REFERENCES media(id),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS post_media_post_media_idx
ON post_media(post_id, media_id);
-- ================================================================
-- METADATA TABLES
-- ================================================================
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS generated_file_hashes (
project_id TEXT NOT NULL REFERENCES projects(id),
relative_path TEXT NOT NULL,
content_hash TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS generated_file_hashes_project_path_idx
ON generated_file_hashes(project_id, relative_path);
-- ================================================================
-- AI / CHAT TABLES (read-only in Rust core, must not error)
-- ================================================================
CREATE TABLE IF NOT EXISTS chat_conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
model TEXT,
copilot_session_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL REFERENCES chat_conversations(id),
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ai_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
env TEXT,
npm TEXT,
api TEXT,
doc TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ai_models (
provider TEXT NOT NULL REFERENCES ai_providers(id),
model_id TEXT NOT NULL,
name TEXT NOT NULL,
family TEXT,
attachment INTEGER NOT NULL DEFAULT 0,
reasoning INTEGER NOT NULL DEFAULT 0,
tool_call INTEGER NOT NULL DEFAULT 0,
structured_output INTEGER NOT NULL DEFAULT 0,
temperature INTEGER NOT NULL DEFAULT 1,
knowledge TEXT,
release_date TEXT,
last_updated_date TEXT,
open_weights INTEGER NOT NULL DEFAULT 0,
input_price INTEGER,
output_price INTEGER,
cache_read_price INTEGER,
cache_write_price INTEGER,
context_window INTEGER NOT NULL DEFAULT 0,
max_input_tokens INTEGER NOT NULL DEFAULT 0,
max_output_tokens INTEGER NOT NULL DEFAULT 0,
interleaved TEXT,
status TEXT,
provider_npm TEXT,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model_id)
);
CREATE TABLE IF NOT EXISTS ai_model_modalities (
provider TEXT NOT NULL,
model_id TEXT NOT NULL,
direction TEXT NOT NULL,
modality TEXT NOT NULL,
FOREIGN KEY (provider, model_id) REFERENCES ai_models(provider, model_id)
);
CREATE TABLE IF NOT EXISTS ai_catalog_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- ================================================================
-- EMBEDDINGS TABLES (read-only in Rust core, must not error)
-- ================================================================
CREATE TABLE IF NOT EXISTS embedding_keys (
label INTEGER PRIMARY KEY,
post_id TEXT NOT NULL,
project_id TEXT NOT NULL,
content_hash TEXT NOT NULL,
vector TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS dismissed_duplicate_pairs (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
post_id_a TEXT NOT NULL,
post_id_b TEXT NOT NULL,
dismissed_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS dismissed_pairs_idx
ON dismissed_duplicate_pairs(project_id, post_id_a, post_id_b);
-- ================================================================
-- IMPORT TABLES (read-only in Rust core, must not error)
-- ================================================================
CREATE TABLE IF NOT EXISTS import_definitions (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
wxr_file_path TEXT,
uploads_folder_path TEXT,
last_analysis_result TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- ================================================================
-- NOTIFICATION TABLES
-- ================================================================
CREATE TABLE IF NOT EXISTS db_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL,
from_cli INTEGER NOT NULL DEFAULT 0,
seen_at INTEGER,
created_at INTEGER NOT NULL
);
",
)?;
/// Uses refinery for proper versioned migration tracking.
pub fn run_migrations(conn: &mut Connection) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
embedded::migrations::runner().run(conn)?;
Ok(())
}
@@ -337,9 +19,9 @@ mod tests {
use super::*;
fn setup() -> Connection {
let conn = Connection::open_in_memory().unwrap();
let mut conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
run_migrations(&conn).unwrap();
run_migrations(&mut conn).unwrap();
conn
}
@@ -399,6 +81,23 @@ mod tests {
}
}
// ================================================================
// REFINERY TRACKING — verify migration history table exists
// ================================================================
#[test]
fn refinery_schema_history_exists() {
let conn = setup();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM refinery_schema_history",
[],
|row| row.get(0),
)
.unwrap();
assert!(count >= 1, "refinery should track at least one migration");
}
// ================================================================
// UNIQUE INDEX ENFORCEMENT — spec invariant tests
// ================================================================
@@ -434,7 +133,6 @@ mod tests {
insert_project(&conn, "p1", "blog1");
insert_project(&conn, "p2", "blog2");
insert_post(&conn, "post1", "p1", "hello");
// Same slug in a different project should succeed
conn.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('post2', 'p2', 'Other', 'hello', 'draft', 1000, 1000)",
@@ -643,7 +341,6 @@ mod tests {
#[test]
fn roundtrip_published_post_null_content() {
// spec: published posts have content = null in DB
let conn = setup();
insert_project(&conn, "p1", "blog");
conn.execute(
@@ -846,12 +543,6 @@ mod tests {
assert_eq!(hash, "sha256abc");
}
// ================================================================
// AI / CHAT / EMBEDDING / IMPORT / NOTIFICATION tables
// Must not error on read/write — spec says "read-only in Rust core"
// but schema must exist and be accessible.
// ================================================================
#[test]
fn roundtrip_chat_conversation() {
let conn = setup();
@@ -1006,10 +697,10 @@ mod tests {
#[test]
fn migrations_idempotent() {
let conn = Connection::open_in_memory().unwrap();
let mut conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
run_migrations(&conn).unwrap();
run_migrations(&conn).expect("running migrations twice must not fail (CREATE IF NOT EXISTS)");
run_migrations(&mut conn).unwrap();
run_migrations(&mut conn).expect("running migrations twice must not fail");
}
// ================================================================

View File

@@ -149,7 +149,7 @@ mod tests {
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db

View File

@@ -103,7 +103,7 @@ mod tests {
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
insert_media(db.conn(), &make_test_media("m1", "p1")).unwrap();

View File

@@ -206,7 +206,7 @@ mod tests {
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db

View File

@@ -55,7 +55,7 @@ mod tests {
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let c = db.conn();

View File

@@ -70,7 +70,7 @@ mod tests {
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db.conn()

View File

@@ -130,7 +130,7 @@ mod tests {
use crate::model::PostStatus;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
// insert a parent post

View File

@@ -106,7 +106,7 @@ mod tests {
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
db
}

View File

@@ -99,7 +99,7 @@ mod tests {
use crate::model::{ScriptKind, ScriptStatus};
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db

View File

@@ -39,7 +39,7 @@ mod tests {
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
db
}

View File

@@ -77,7 +77,7 @@ mod tests {
use crate::db::Database;
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db

View File

@@ -97,7 +97,7 @@ mod tests {
use crate::model::{TemplateKind, TemplateStatus};
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db

View File

@@ -65,7 +65,7 @@ mod tests {
#[test]
fn calendar_empty_project() {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
let _ = db.migrate();
let tmp = tempfile::tempdir().unwrap();

View File

@@ -650,7 +650,7 @@ mod tests {
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();

View File

@@ -619,7 +619,7 @@ mod tests {
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();

View File

@@ -865,7 +865,7 @@ mod tests {
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();

View File

@@ -89,7 +89,7 @@ mod tests {
use crate::util::sidecar::read_sidecar;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();

View File

@@ -152,7 +152,7 @@ mod tests {
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let dir = TempDir::new().unwrap();
(db, dir)

View File

@@ -138,7 +138,7 @@ mod tests {
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
fts::ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();

View File

@@ -145,7 +145,7 @@ mod tests {
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();

View File

@@ -130,7 +130,7 @@ mod tests {
use crate::engine;
fn setup() -> (Database, String) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
let _ = db.migrate();
ensure_fts_tables(db.conn()).unwrap();

View File

@@ -305,7 +305,7 @@ mod tests {
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();

View File

@@ -144,7 +144,7 @@ mod tests {
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
let mut db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();