feat: finalizations for M0-M2
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user