From ca5eb4e1acc498470a027881f0743f6d947c483b Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 18 Jul 2026 17:00:32 +0200 Subject: [PATCH] Replace literal SQL with Diesel abstractions --- BUILD.md | 4 +- Cargo.lock | 272 ++-- Cargo.toml | 5 +- RUST_EXECUTION_BACKLOG.md | 6 +- RUST_PLAN_CORE.md | 14 +- SPECIFICATION_INDEX.md | 4 +- crates/bds-core/Cargo.toml | 5 +- crates/bds-core/build.rs | 3 + crates/bds-core/migrations/.diesel_lock | 0 .../20260718000000_initial_schema/down.sql | 22 + .../up.sql} | 81 +- .../V2__fix_defaults_and_collation.sql | 58 - .../migrations/V3__align_ai_schema.sql | 5 - crates/bds-core/src/db/connection.rs | 93 +- crates/bds-core/src/db/from_row.rs | 1318 +++++++++-------- crates/bds-core/src/db/fts.rs | 440 +++--- crates/bds-core/src/db/migrations.rs | 1038 ++----------- crates/bds-core/src/db/mod.rs | 4 +- .../src/db/queries/generated_file_hash.rs | 94 +- crates/bds-core/src/db/queries/media.rs | 304 ++-- .../src/db/queries/media_translation.rs | 132 +- crates/bds-core/src/db/queries/post.rs | 613 ++++---- crates/bds-core/src/db/queries/post_link.rs | 105 +- crates/bds-core/src/db/queries/post_media.rs | 116 +- .../src/db/queries/post_translation.rs | 164 +- crates/bds-core/src/db/queries/project.rs | 140 +- crates/bds-core/src/db/queries/script.rs | 126 +- crates/bds-core/src/db/queries/setting.rs | 62 +- crates/bds-core/src/db/queries/tag.rs | 110 +- crates/bds-core/src/db/queries/template.rs | 123 +- crates/bds-core/src/db/schema.patch | 91 ++ crates/bds-core/src/db/schema.rs | 355 +++++ crates/bds-core/src/engine/ai.rs | 6 +- crates/bds-core/src/engine/calendar.rs | 4 +- crates/bds-core/src/engine/context.rs | 2 +- crates/bds-core/src/engine/error.rs | 24 +- crates/bds-core/src/engine/generation.rs | 2 +- crates/bds-core/src/engine/media.rs | 4 +- crates/bds-core/src/engine/metadata_diff.rs | 4 +- crates/bds-core/src/engine/post.rs | 69 +- crates/bds-core/src/engine/post_media.rs | 24 +- crates/bds-core/src/engine/preview.rs | 2 +- crates/bds-core/src/engine/project.rs | 8 +- crates/bds-core/src/engine/rebuild.rs | 4 +- crates/bds-core/src/engine/script.rs | 4 +- crates/bds-core/src/engine/script_rebuild.rs | 4 +- crates/bds-core/src/engine/search.rs | 7 +- crates/bds-core/src/engine/site_assets.rs | 2 +- crates/bds-core/src/engine/tag.rs | 4 +- crates/bds-core/src/engine/template.rs | 46 +- .../bds-core/src/engine/template_rebuild.rs | 4 +- crates/bds-core/src/engine/validate_media.rs | 2 +- crates/bds-core/src/engine/validate_site.rs | 2 +- .../src/engine/validate_translations.rs | 2 +- crates/bds-core/src/render/generation.rs | 2 +- crates/bds-core/src/render/site.rs | 2 +- crates/bds-core/tests/fixture_readability.rs | 735 +++------ crates/bds-core/tests/m1_validation.rs | 2 +- crates/bds-core/tests/m4_generation_engine.rs | 2 +- .../tests/m4_generation_primitives.rs | 2 +- crates/bds-core/tests/orm_boundary.rs | 60 + crates/bds-core/tests/spec_claims.rs | 814 ++++------ crates/bds-ui/Cargo.toml | 1 - crates/bds-ui/src/app.rs | 17 +- crates/bds-ui/tests/project_flow.rs | 2 +- diesel.toml | 8 + docs/ICED_ARCHITECTURE_PATTERNS.md | 2 +- fixtures/create_fixture.sh | 1 + specs/schema.allium | 6 +- 69 files changed, 3508 insertions(+), 4285 deletions(-) create mode 100644 crates/bds-core/build.rs create mode 100644 crates/bds-core/migrations/.diesel_lock create mode 100644 crates/bds-core/migrations/20260718000000_initial_schema/down.sql rename crates/bds-core/migrations/{V1__initial_schema.sql => 20260718000000_initial_schema/up.sql} (79%) delete mode 100644 crates/bds-core/migrations/V2__fix_defaults_and_collation.sql delete mode 100644 crates/bds-core/migrations/V3__align_ai_schema.sql create mode 100644 crates/bds-core/src/db/schema.patch create mode 100644 crates/bds-core/src/db/schema.rs create mode 100644 crates/bds-core/tests/orm_boundary.rs create mode 100644 diesel.toml diff --git a/BUILD.md b/BUILD.md index a1df697..b67d33f 100644 --- a/BUILD.md +++ b/BUILD.md @@ -55,8 +55,8 @@ rustup default stable No additional system packages beyond the above. Key crates use bundled/vendored native code: -- `rusqlite` with `bundled` feature compiles SQLite from source -- `refinery` manages SQL migrations against rusqlite +- `diesel` provides the typed SQLite query layer +- `diesel_migrations` embeds migrations; `libsqlite3-sys` bundles SQLite - `iced` uses wgpu which links to Metal (macOS) or Vulkan (Linux/Windows) at runtime - `muda` uses native platform menu APIs (NSMenu on macOS, GTK on Linux, Win32 on Windows) - `rfd` uses native platform dialog APIs (NSOpenPanel on macOS, GTK on Linux, Win32 on Windows) diff --git a/Cargo.lock b/Cargo.lock index 19c8e4c..bc34d93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -799,18 +799,19 @@ dependencies = [ "axum", "chrono", "deunicode", + "diesel", + "diesel_migrations", "fluent-bundle", "image 0.25.10", "keyring", + "libsqlite3-sys", "liquid", "liquid-core", "pagefind", "pulldown-cmark", "quick-xml 0.41.0", "rayon", - "refinery", "reqwest", - "rusqlite", "rust-stemmers", "serde", "serde_json", @@ -852,7 +853,6 @@ dependencies = [ "objc2-foundation 0.3.2", "open", "rfd", - "rusqlite", "serde_json", "syn 2.0.117", "tempfile", @@ -1679,6 +1679,41 @@ dependencies = [ "zbus 4.4.0", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1756,6 +1791,52 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +[[package]] +name = "diesel" +version = "2.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e54d1f576cd3a3460f212a4615fd12ce1b6303c095b79a44449ffbe627753dc1" +dependencies = [ + "diesel_derives", + "downcast-rs 2.0.2", + "libsqlite3-sys", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -1893,6 +1974,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dpi" version = "0.1.2" @@ -1939,6 +2026,20 @@ dependencies = [ "linux-raw-sys 0.9.4", ] +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -2123,18 +2224,6 @@ dependencies = [ "zune-inflate", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fast-srgb8" version = "1.0.0" @@ -2931,15 +3020,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "hassle-rs" version = "0.11.0" @@ -3423,6 +3503,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -4007,9 +4093,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.31.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -4266,6 +4352,27 @@ dependencies = [ "paste", ] +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + [[package]] name = "mime" version = "0.3.17" @@ -5996,48 +6103,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "refinery" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2783724569d96af53464d0711dff635cab7a4934df5e22e9fbc9e181523b83e" -dependencies = [ - "refinery-core", - "refinery-macros", -] - -[[package]] -name = "refinery-core" -version = "0.8.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a83581f18c1a4c3a6ebd7a174bdc665f17f618d79f7edccb6a0ac67e660b319" -dependencies = [ - "async-trait", - "cfg-if", - "log", - "regex", - "rusqlite", - "siphasher", - "thiserror 1.0.69", - "time", - "url", - "walkdir", -] - -[[package]] -name = "refinery-macros" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "096eb40b781fbe15734615c557f47ebffb50afb9f25154fdf50fc03c6ee97adb" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "refinery-core", - "regex", - "syn 2.0.117", -] - [[package]] name = "regex" version = "1.12.3" @@ -6199,17 +6264,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] -name = "rusqlite" -version = "0.33.0" +name = "rsqlite-vfs" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ - "bitflags 2.11.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", + "hashbrown 0.16.1", + "thiserror 2.0.18", ] [[package]] @@ -6558,6 +6619,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -6889,6 +6959,18 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7361,11 +7443,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.3", "toml_edit 0.20.2", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -7375,6 +7470,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -7403,7 +7507,7 @@ checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ "indexmap 2.13.0", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -7417,7 +7521,7 @@ dependencies = [ "indexmap 2.13.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.1", + "winnow 1.0.4", ] [[package]] @@ -7426,7 +7530,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow 1.0.4", ] [[package]] @@ -8003,7 +8107,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", - "downcast-rs", + "downcast-rs 1.2.1", "rustix 1.1.4", "scoped-tls", "smallvec", @@ -8961,9 +9065,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index b670800..e1d7961 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,6 @@ license = "MIT" [workspace.dependencies] # Foundation base64 = "0.22" -rusqlite = { version = "0.33", features = ["bundled", "vtab"] } -refinery = { version = "0.8", features = ["rusqlite"] } uuid = { version = "1", features = ["v4", "serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -43,6 +41,9 @@ rayon = "1.10" pagefind = "1.5.2" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } +diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] } +diesel_migrations = "2.3.2" +libsqlite3-sys = { version = "0.37.0", features = ["bundled"] } # UI framework iced = { version = "0.13", features = ["wgpu", "advanced", "image", "svg", "tokio"] } diff --git a/RUST_EXECUTION_BACKLOG.md b/RUST_EXECUTION_BACKLOG.md index 5e211e3..f205ca1 100644 --- a/RUST_EXECUTION_BACKLOG.md +++ b/RUST_EXECUTION_BACKLOG.md @@ -24,8 +24,8 @@ Rules: ### `bds-core` - ~~create workspace and crate boundaries (bds-core, bds-editor, bds-ui, bds-cli)~~ **DONE** -- ~~add SQLite connection management via `rusqlite` (bundled, vtab features)~~ **DONE** -- ~~add migration loader via `refinery`~~ **DONE** (refinery `embed_migrations!` with V1__initial_schema.sql) +- ~~add typed SQLite connection and query layer via `diesel`~~ **DONE** +- ~~add embedded migration loader via `diesel_migrations`~~ **DONE** - ~~define initial shared model modules with `serde` derives~~ **DONE** - ~~add checksum (`sha2`) and slug (`deunicode`) utilities~~ **DONE** - ~~establish error handling conventions: `thiserror` for bds-core, `anyhow` for bds-ui/bds-cli~~ **DONE** @@ -274,7 +274,7 @@ Rules: ## Milestone M6: bDS2 Spec Parity -Code-vs-spec gaps from the 2026-07-18 spec sync. Each task names its authoritative spec. Schema changes go through proper refinery migrations, and every new metadata field must be wired into publishing, metadata-diff, and rebuild-from-filesystem together. +Code-vs-spec gaps from the 2026-07-18 spec sync. Each task names its authoritative spec. Schema changes go through generated Diesel migrations, and every new metadata field must be wired into publishing, metadata-diff, and rebuild-from-filesystem together. ### `bds-core/util` diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index df04a8c..0e67421 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -97,7 +97,7 @@ These map to extension Buckets G (CLI + events) and K/L (server, TUI) in RUST_PL - **rfd for file dialogs**: cross-platform native open/save/folder dialogs. NSOpenPanel/NSSavePanel on macOS, equivalents elsewhere. - **objc2 for macOS lifecycle shim** (cfg-gated): thin bridge for `application:openFile:`, `application:openURLs:`, and other `NSApplicationDelegate` hooks that muda/rfd do not cover. ~50 lines of platform code. - **Custom editor widget (bds-editor crate)**: syntax-highlighting markdown/Liquid/Lua editor built on ropey (rope buffer), syntect (syntax highlighting), and cosmic-text (font shaping and text layout). This is the highest-risk custom component and gets a proof-of-concept in Wave 0. -- **rusqlite + embedded SQL migrations**: no ORM. SQL stays explicit. Migrations managed via `refinery`. +- **Diesel + embedded migrations**: typed SQLite queries and generated schema, with migrations managed by `diesel_migrations`. Backend-only SQL is confined to connection setup and FTS5 operations. - **tokio as the async runtime**: preview server (axum), SSH publishing, file watching, and rfd async dialogs all require an async executor. tokio is the standard choice and is used workspace-wide. Synchronous engine code in bds-core does not use tokio directly — it remains callable from both async (bds-ui) and sync (bds-cli) contexts. - **Plain-text markdown editor first**: no WYSIWYG in core. Live preview is required. This is an intentional regression from the baseline app's rich editor. A rich editor can be added as an extension bucket after core ships — and the bds-editor widget provides a natural foundation for it. - **Lua for user-authored scripting**: `mlua` with Lua 5.4. Only user-authored macros, transforms, and utility scripts run through Lua. Built-in macros are native Rust — see the macro architecture section below. @@ -311,7 +311,7 @@ The Rust app uses `deunicode` for Unicode-to-ASCII conversion in slugs; it must The app has two independent search systems — do not conflate them: -1. **FTS5 (in-app search)**: SQLite FTS5 virtual tables (`posts_fts`, `media_fts`) power the desktop app's search UI. These require custom Snowball tokenizers registered via `rusqlite`'s `fts5_tokenizer` API (using the `rust-stemmers` crate) for 24-language stemmed search. Both indexing and query-time stemming must match the content language. This is Wave 1 scope. +1. **FTS5 (in-app search)**: SQLite FTS5 virtual tables (`posts_fts`, `media_fts`) power the desktop app's search UI. Text is Snowball-stemmed in application code before indexing and querying. FTS5 virtual-table and `MATCH` operations form the explicitly isolated raw-SQL backend boundary; ordinary filtering uses Diesel's typed query builder. This is Wave 1 scope. 2. **Pagefind (generated site search)**: a client-side search index bundled with the generated static site. Pagefind indexes the generated HTML files and produces JavaScript/WASM artifacts that power search on the published website. This is Wave 4 scope, added to the generation pipeline via the `pagefind` crate's Rust library API. @@ -333,8 +333,9 @@ All crate choices for core scope, organized by subsystem. This prevents ad-hoc t | Crate | Purpose | Notes | |---|---|---| -| `rusqlite` (bundled, vtab) | SQLite database access | Bundled compiles SQLite from source, vtab enables FTS5 | -| `refinery` | SQL migration management | Replaces hand-rolled migration loader | +| `diesel` (sqlite) | Typed SQLite database access | Query builder and generated schema | +| `diesel_migrations` | Embedded migration management | Runs generated Diesel migrations at startup | +| `libsqlite3-sys` (bundled) | SQLite native library | Compiles SQLite from source with FTS5 | | `uuid` (v4) | Entity identifiers | | | `serde` + `serde_json` | Serialization/deserialization | Used everywhere | | `serde_yaml` | YAML frontmatter parsing/writing | Posts, translations, media sidecars | @@ -504,8 +505,9 @@ Core release happens only after M5. ```toml # Core -rusqlite = { version = "0.33", features = ["bundled", "vtab"] } -refinery = { version = "0.8", features = ["rusqlite"] } +diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] } +diesel_migrations = "2.3" +libsqlite3-sys = { version = "0.37", features = ["bundled"] } uuid = { version = "1", features = ["v4"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/SPECIFICATION_INDEX.md b/SPECIFICATION_INDEX.md index 82c7b48..fc84e7a 100644 --- a/SPECIFICATION_INDEX.md +++ b/SPECIFICATION_INDEX.md @@ -247,8 +247,8 @@ Alle kritischen Lücken geschlossen: ### Wave 0 (Foundation) 1. Cargo-Workspace aufsetzen (bds-core, bds-editor, bds-ui, bds-cli) -2. SQLite-Connection mit rusqlite (bundled, vtab) -3. Refinery-Migration-Loader +2. SQLite-Connection mit Diesel (gebündeltes SQLite) +3. Eingebettete Diesel-Migrationen 4. bds-editor PoC (ropey + syntect + cosmic-text) 5. Iced App Shell mit muda-Menüs 6. Slug-Compatibility-Tests (deunicode vs transliteration) diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index 415c2a7..36699bb 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -5,8 +5,6 @@ version.workspace = true license.workspace = true [dependencies] -rusqlite = { workspace = true } -refinery = { workspace = true } uuid = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -30,6 +28,9 @@ reqwest = { workspace = true } keyring = { workspace = true } tokio = { workspace = true } axum = { workspace = true } +diesel = { workspace = true } +diesel_migrations = { workspace = true } +libsqlite3-sys = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/bds-core/build.rs b/crates/bds-core/build.rs new file mode 100644 index 0000000..3a8149e --- /dev/null +++ b/crates/bds-core/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/crates/bds-core/migrations/.diesel_lock b/crates/bds-core/migrations/.diesel_lock new file mode 100644 index 0000000..e69de29 diff --git a/crates/bds-core/migrations/20260718000000_initial_schema/down.sql b/crates/bds-core/migrations/20260718000000_initial_schema/down.sql new file mode 100644 index 0000000..1a4ab04 --- /dev/null +++ b/crates/bds-core/migrations/20260718000000_initial_schema/down.sql @@ -0,0 +1,22 @@ +DROP TABLE IF EXISTS db_notifications; +DROP TABLE IF EXISTS import_definitions; +DROP TABLE IF EXISTS dismissed_duplicate_pairs; +DROP TABLE IF EXISTS embedding_keys; +DROP TABLE IF EXISTS ai_catalog_meta; +DROP TABLE IF EXISTS ai_model_modalities; +DROP TABLE IF EXISTS ai_models; +DROP TABLE IF EXISTS ai_providers; +DROP TABLE IF EXISTS chat_messages; +DROP TABLE IF EXISTS chat_conversations; +DROP TABLE IF EXISTS generated_file_hashes; +DROP TABLE IF EXISTS settings; +DROP TABLE IF EXISTS post_media; +DROP TABLE IF EXISTS post_links; +DROP TABLE IF EXISTS scripts; +DROP TABLE IF EXISTS templates; +DROP TABLE IF EXISTS tags; +DROP TABLE IF EXISTS media_translations; +DROP TABLE IF EXISTS media; +DROP TABLE IF EXISTS post_translations; +DROP TABLE IF EXISTS posts; +DROP TABLE IF EXISTS projects; diff --git a/crates/bds-core/migrations/V1__initial_schema.sql b/crates/bds-core/migrations/20260718000000_initial_schema/up.sql similarity index 79% rename from crates/bds-core/migrations/V1__initial_schema.sql rename to crates/bds-core/migrations/20260718000000_initial_schema/up.sql index 3549704..f6075be 100644 --- a/crates/bds-core/migrations/V1__initial_schema.sql +++ b/crates/bds-core/migrations/20260718000000_initial_schema/up.sql @@ -1,9 +1,8 @@ --- ================================================================ --- CORE ENTITIES --- ================================================================ +-- Generated with Diesel CLI, then completed with the constraints and defaults +-- that SQLite's schema introspection cannot reproduce. CREATE TABLE IF NOT EXISTS projects ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, description TEXT, @@ -14,7 +13,7 @@ CREATE TABLE IF NOT EXISTS projects ( ); CREATE TABLE IF NOT EXISTS posts ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), title TEXT NOT NULL, slug TEXT NOT NULL, @@ -43,7 +42,7 @@ 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, + id TEXT NOT NULL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), translation_for TEXT NOT NULL REFERENCES posts(id), language TEXT NOT NULL, @@ -62,7 +61,7 @@ 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, + id TEXT NOT NULL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), filename TEXT NOT NULL, original_name TEXT NOT NULL, @@ -84,7 +83,7 @@ CREATE TABLE IF NOT EXISTS media ( ); CREATE TABLE IF NOT EXISTS media_translations ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), translation_for TEXT NOT NULL REFERENCES media(id), language TEXT NOT NULL, @@ -99,7 +98,7 @@ 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, + id TEXT NOT NULL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), name TEXT NOT NULL, color TEXT, @@ -109,10 +108,10 @@ CREATE TABLE IF NOT EXISTS tags ( ); CREATE UNIQUE INDEX IF NOT EXISTS tags_project_name_idx - ON tags(project_id, name); + ON tags(project_id, name COLLATE NOCASE); CREATE TABLE IF NOT EXISTS templates ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), slug TEXT NOT NULL, title TEXT NOT NULL, @@ -120,7 +119,7 @@ CREATE TABLE IF NOT EXISTS templates ( enabled INTEGER NOT NULL DEFAULT 1, version INTEGER NOT NULL DEFAULT 1, file_path TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'published', + status TEXT NOT NULL DEFAULT 'draft', content TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL @@ -130,7 +129,7 @@ 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, + id TEXT NOT NULL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), slug TEXT NOT NULL, title TEXT NOT NULL, @@ -139,7 +138,7 @@ CREATE TABLE IF NOT EXISTS scripts ( enabled INTEGER NOT NULL DEFAULT 1, version INTEGER NOT NULL DEFAULT 1, file_path TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'published', + status TEXT NOT NULL DEFAULT 'draft', content TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL @@ -148,12 +147,8 @@ CREATE TABLE IF NOT EXISTS scripts ( 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, + id TEXT NOT NULL PRIMARY KEY, source_post_id TEXT NOT NULL REFERENCES posts(id), target_post_id TEXT NOT NULL REFERENCES posts(id), link_text TEXT, @@ -161,7 +156,7 @@ CREATE TABLE IF NOT EXISTS post_links ( ); CREATE TABLE IF NOT EXISTS post_media ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL 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), @@ -172,12 +167,8 @@ CREATE TABLE IF NOT EXISTS post_media ( 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, + key TEXT NOT NULL PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL ); @@ -192,12 +183,8 @@ CREATE TABLE IF NOT EXISTS generated_file_hashes ( 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, + id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, model TEXT, copilot_session_id TEXT, @@ -206,20 +193,22 @@ CREATE TABLE IF NOT EXISTS chat_conversations ( ); CREATE TABLE IF NOT EXISTS chat_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id INTEGER NOT NULL 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 + created_at INTEGER NOT NULL, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER ); CREATE TABLE IF NOT EXISTS ai_providers ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, env TEXT, - npm TEXT, + package_ref TEXT, api TEXT, doc TEXT, updated_at INTEGER NOT NULL @@ -248,7 +237,7 @@ CREATE TABLE IF NOT EXISTS ai_models ( max_output_tokens INTEGER NOT NULL DEFAULT 0, interleaved TEXT, status TEXT, - provider_npm TEXT, + provider_package_ref TEXT, updated_at INTEGER NOT NULL, PRIMARY KEY (provider, model_id) ); @@ -262,16 +251,12 @@ CREATE TABLE IF NOT EXISTS ai_model_modalities ( ); CREATE TABLE IF NOT EXISTS ai_catalog_meta ( - key TEXT PRIMARY KEY, + key TEXT NOT NULL 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, + label INTEGER NOT NULL PRIMARY KEY, post_id TEXT NOT NULL, project_id TEXT NOT NULL, content_hash TEXT NOT NULL, @@ -279,7 +264,7 @@ CREATE TABLE IF NOT EXISTS embedding_keys ( ); CREATE TABLE IF NOT EXISTS dismissed_duplicate_pairs ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), post_id_a TEXT NOT NULL, post_id_b TEXT NOT NULL, @@ -289,12 +274,8 @@ CREATE TABLE IF NOT EXISTS dismissed_duplicate_pairs ( 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, + id TEXT NOT NULL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), name TEXT NOT NULL, wxr_file_path TEXT, @@ -304,12 +285,8 @@ CREATE TABLE IF NOT EXISTS import_definitions ( updated_at INTEGER NOT NULL ); --- ================================================================ --- NOTIFICATION TABLES --- ================================================================ - CREATE TABLE IF NOT EXISTS db_notifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, action TEXT NOT NULL, diff --git a/crates/bds-core/migrations/V2__fix_defaults_and_collation.sql b/crates/bds-core/migrations/V2__fix_defaults_and_collation.sql deleted file mode 100644 index 5d23b2e..0000000 --- a/crates/bds-core/migrations/V2__fix_defaults_and_collation.sql +++ /dev/null @@ -1,58 +0,0 @@ --- Fix script and template default status from 'published' to 'draft' --- and make tag name uniqueness case-insensitive. --- --- SQLite cannot ALTER COLUMN defaults, so we recreate the affected tables. - --- ── Scripts: default status 'published' → 'draft' ── - -CREATE TABLE scripts_new ( - 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 'draft', - content TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL -); - -INSERT INTO scripts_new SELECT * FROM scripts; -DROP TABLE scripts; -ALTER TABLE scripts_new RENAME TO scripts; - -CREATE UNIQUE INDEX IF NOT EXISTS scripts_project_slug_idx - ON scripts(project_id, slug); - --- ── Templates: default status 'published' → 'draft' ── - -CREATE TABLE templates_new ( - 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 'draft', - content TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL -); - -INSERT INTO templates_new SELECT * FROM templates; -DROP TABLE templates; -ALTER TABLE templates_new RENAME TO templates; - -CREATE UNIQUE INDEX IF NOT EXISTS templates_project_slug_idx - ON templates(project_id, slug); - --- ── Tags: case-insensitive unique index ── - -DROP INDEX IF EXISTS tags_project_name_idx; -CREATE UNIQUE INDEX tags_project_name_idx ON tags(project_id, name COLLATE NOCASE); diff --git a/crates/bds-core/migrations/V3__align_ai_schema.sql b/crates/bds-core/migrations/V3__align_ai_schema.sql deleted file mode 100644 index 9c20d48..0000000 --- a/crates/bds-core/migrations/V3__align_ai_schema.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE chat_messages ADD COLUMN cache_read_tokens INTEGER; -ALTER TABLE chat_messages ADD COLUMN cache_write_tokens INTEGER; - -ALTER TABLE ai_providers RENAME COLUMN npm TO package_ref; -ALTER TABLE ai_models RENAME COLUMN provider_npm TO provider_package_ref; diff --git a/crates/bds-core/src/db/connection.rs b/crates/bds-core/src/db/connection.rs index 5a11da0..1764fd0 100644 --- a/crates/bds-core/src/db/connection.rs +++ b/crates/bds-core/src/db/connection.rs @@ -1,37 +1,88 @@ -use crate::db::migrations; -use rusqlite::Connection; +use std::cell::RefCell; use std::path::Path; +use diesel::connection::SimpleConnection; +use diesel::prelude::*; + +use crate::db::migrations; + +#[derive(Debug, thiserror::Error)] +pub enum DatabaseError { + #[error("{0}")] + Connection(#[from] diesel::ConnectionError), + #[error("{0}")] + Query(#[from] diesel::result::Error), +} + +/// Shared synchronous Diesel connection used by the engine query API. +pub struct DbConnection(RefCell); + +impl DbConnection { + pub fn with( + &self, + operation: impl FnOnce(&mut SqliteConnection) -> diesel::QueryResult, + ) -> diesel::QueryResult { + operation(&mut self.0.borrow_mut()) + } + + pub(crate) fn with_migrations( + &self, + operation: impl FnOnce(&mut SqliteConnection) -> T, + ) -> T { + operation(&mut self.0.borrow_mut()) + } + + pub(crate) fn begin_savepoint(&self) -> diesel::QueryResult<()> { + self.0.borrow_mut().batch_execute("SAVEPOINT bds_operation") + } + + pub(crate) fn release_savepoint(&self) -> diesel::QueryResult<()> { + self.0.borrow_mut().batch_execute("RELEASE bds_operation") + } + + pub(crate) fn rollback_savepoint(&self) -> diesel::QueryResult<()> { + self.0 + .borrow_mut() + .batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation") + } +} + /// Database wrapper managing a SQLite connection. pub struct Database { - conn: Connection, + conn: DbConnection, } impl Database { /// Open an existing bDS project database. - pub fn open(path: &Path) -> Result { - let conn = Connection::open(path)?; - conn.execute_batch( - "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;", - )?; - Ok(Self { conn }) + pub fn open(path: &Path) -> Result { + Self::establish(path.to_string_lossy().as_ref(), true) } /// Open an in-memory database (for tests). - pub fn open_in_memory() -> Result { - let conn = Connection::open_in_memory()?; - conn.execute_batch("PRAGMA foreign_keys=ON;")?; - Ok(Self { conn }) + pub fn open_in_memory() -> Result { + Self::establish(":memory:", false) } - /// Get a reference to the underlying connection. - pub fn conn(&self) -> &Connection { + fn establish(database_url: &str, wal: bool) -> Result { + let mut conn = SqliteConnection::establish(database_url)?; + // SQLite connection configuration is backend-specific and not expressible in Diesel's DSL. + conn.batch_execute(if wal { + "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;" + } else { + "PRAGMA foreign_keys=ON;" + })?; + Ok(Self { + conn: DbConnection(RefCell::new(conn)), + }) + } + + pub fn conn(&self) -> &DbConnection { &self.conn } - /// Run all pending migrations via refinery. - pub fn migrate(&mut self) -> Result<(), Box> { - migrations::run_migrations(&mut self.conn) + /// Run all pending embedded Diesel migrations. + pub fn migrate(&self) -> Result<(), Box> { + migrations::run_migrations(&self.conn) } } @@ -42,9 +93,11 @@ mod tests { #[test] fn open_in_memory() { let db = Database::open_in_memory().expect("should open in-memory db"); - let result: i64 = db + let result = db .conn() - .query_row("SELECT 1", [], |row| row.get(0)) + .with(|conn| { + diesel::select(1.into_sql::()).get_result::(conn) + }) .unwrap(); assert_eq!(result, 1); } diff --git a/crates/bds-core/src/db/from_row.rs b/crates/bds-core/src/db/from_row.rs index e3cec6b..c55f32c 100644 --- a/crates/bds-core/src/db/from_row.rs +++ b/crates/bds-core/src/db/from_row.rs @@ -1,104 +1,42 @@ -use rusqlite::Row; +use diesel::prelude::*; +use diesel::sqlite::Sqlite; +use crate::db::schema; use crate::model::{ DbNotification, GeneratedFileHash, Media, MediaTranslation, NotificationAction, NotificationEntity, Post, PostLink, PostMedia, PostStatus, PostTranslation, Project, Script, ScriptKind, ScriptStatus, Setting, Tag, Template, TemplateKind, TemplateStatus, }; -// ── helpers ────────────────────────────────────────────────────────── +type ConversionError = Box; -fn conversion_err(msg: String) -> rusqlite::Error { - rusqlite::Error::FromSqlConversionFailure( - 0, - rusqlite::types::Type::Text, - Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, msg)), - ) +fn invalid_value(kind: &str, value: &str) -> ConversionError { + format!("invalid {kind}: {value}").into() } -fn parse_post_status(s: &str) -> rusqlite::Result { - match s { - "draft" => Ok(PostStatus::Draft), - "published" => Ok(PostStatus::Published), - "archived" => Ok(PostStatus::Archived), - _ => Err(conversion_err(format!("invalid PostStatus: {s}"))), - } +fn json_strings(value: &str) -> Result, ConversionError> { + Ok(serde_json::from_str(value)?) } -fn parse_template_kind(s: &str) -> rusqlite::Result { - match s { - "post" => Ok(TemplateKind::Post), - "list" => Ok(TemplateKind::List), - "not_found" => Ok(TemplateKind::NotFound), - "partial" => Ok(TemplateKind::Partial), - _ => Err(conversion_err(format!("invalid TemplateKind: {s}"))), - } -} - -fn parse_template_status(s: &str) -> rusqlite::Result { - match s { - "draft" => Ok(TemplateStatus::Draft), - "published" => Ok(TemplateStatus::Published), - _ => Err(conversion_err(format!("invalid TemplateStatus: {s}"))), - } -} - -fn parse_script_kind(s: &str) -> rusqlite::Result { - match s { - "macro" => Ok(ScriptKind::Macro), - "utility" => Ok(ScriptKind::Utility), - "transform" => Ok(ScriptKind::Transform), - _ => Err(conversion_err(format!("invalid ScriptKind: {s}"))), - } -} - -fn parse_script_status(s: &str) -> rusqlite::Result { - match s { - "draft" => Ok(ScriptStatus::Draft), - "published" => Ok(ScriptStatus::Published), - _ => Err(conversion_err(format!("invalid ScriptStatus: {s}"))), - } -} - -fn parse_notification_entity(s: &str) -> rusqlite::Result { - match s { - "post" => Ok(NotificationEntity::Post), - "media" => Ok(NotificationEntity::Media), - "script" => Ok(NotificationEntity::Script), - "template" => Ok(NotificationEntity::Template), - _ => Err(conversion_err(format!("invalid NotificationEntity: {s}"))), - } -} - -fn parse_notification_action(s: &str) -> rusqlite::Result { - match s { - "created" => Ok(NotificationAction::Created), - "updated" => Ok(NotificationAction::Updated), - "deleted" => Ok(NotificationAction::Deleted), - _ => Err(conversion_err(format!("invalid NotificationAction: {s}"))), - } -} - -fn json_to_vec_string(s: &str) -> rusqlite::Result> { - serde_json::from_str(s).map_err(|e| conversion_err(format!("JSON parse error: {e}"))) -} - -fn bool_from_i64(v: i64) -> bool { - v != 0 -} - -// ── enum → DB string (for INSERT / UPDATE) ─────────────────────────── - -pub fn post_status_to_str(s: &PostStatus) -> &'static str { - match s { +pub fn post_status_to_str(value: &PostStatus) -> &'static str { + match value { PostStatus::Draft => "draft", PostStatus::Published => "published", PostStatus::Archived => "archived", } } -pub fn template_kind_to_str(k: &TemplateKind) -> &'static str { - match k { +fn post_status(value: &str) -> Result { + match value { + "draft" => Ok(PostStatus::Draft), + "published" => Ok(PostStatus::Published), + "archived" => Ok(PostStatus::Archived), + _ => Err(invalid_value("PostStatus", value)), + } +} + +pub fn template_kind_to_str(value: &TemplateKind) -> &'static str { + match value { TemplateKind::Post => "post", TemplateKind::List => "list", TemplateKind::NotFound => "not_found", @@ -106,30 +44,65 @@ pub fn template_kind_to_str(k: &TemplateKind) -> &'static str { } } -pub fn template_status_to_str(s: &TemplateStatus) -> &'static str { - match s { +fn template_kind(value: &str) -> Result { + match value { + "post" => Ok(TemplateKind::Post), + "list" => Ok(TemplateKind::List), + "not_found" => Ok(TemplateKind::NotFound), + "partial" => Ok(TemplateKind::Partial), + _ => Err(invalid_value("TemplateKind", value)), + } +} + +pub fn template_status_to_str(value: &TemplateStatus) -> &'static str { + match value { TemplateStatus::Draft => "draft", TemplateStatus::Published => "published", } } -pub fn script_kind_to_str(k: &ScriptKind) -> &'static str { - match k { +fn template_status(value: &str) -> Result { + match value { + "draft" => Ok(TemplateStatus::Draft), + "published" => Ok(TemplateStatus::Published), + _ => Err(invalid_value("TemplateStatus", value)), + } +} + +pub fn script_kind_to_str(value: &ScriptKind) -> &'static str { + match value { ScriptKind::Macro => "macro", ScriptKind::Utility => "utility", ScriptKind::Transform => "transform", } } -pub fn script_status_to_str(s: &ScriptStatus) -> &'static str { - match s { +fn script_kind(value: &str) -> Result { + match value { + "macro" => Ok(ScriptKind::Macro), + "utility" => Ok(ScriptKind::Utility), + "transform" => Ok(ScriptKind::Transform), + _ => Err(invalid_value("ScriptKind", value)), + } +} + +pub fn script_status_to_str(value: &ScriptStatus) -> &'static str { + match value { ScriptStatus::Draft => "draft", ScriptStatus::Published => "published", } } -pub fn notification_entity_to_str(e: &NotificationEntity) -> &'static str { - match e { +fn script_status(value: &str) -> Result { + match value { + "draft" => Ok(ScriptStatus::Draft), + "published" => Ok(ScriptStatus::Published), + _ => Err(invalid_value("ScriptStatus", value)), + } +} + +pub fn notification_entity_to_str(value: &NotificationEntity) -> &'static str { + match value { NotificationEntity::Post => "post", NotificationEntity::Media => "media", NotificationEntity::Script => "script", @@ -137,535 +110,650 @@ pub fn notification_entity_to_str(e: &NotificationEntity) -> &'static str { } } -pub fn notification_action_to_str(a: &NotificationAction) -> &'static str { - match a { +fn notification_entity(value: &str) -> Result { + match value { + "post" => Ok(NotificationEntity::Post), + "media" => Ok(NotificationEntity::Media), + "script" => Ok(NotificationEntity::Script), + "template" => Ok(NotificationEntity::Template), + _ => Err(invalid_value("NotificationEntity", value)), + } +} + +pub fn notification_action_to_str(value: &NotificationAction) -> &'static str { + match value { NotificationAction::Created => "created", NotificationAction::Updated => "updated", NotificationAction::Deleted => "deleted", } } -// ── column lists (keep in sync with from_row functions) ────────────── - -pub const PROJECT_COLUMNS: &str = - "id, name, slug, description, data_path, is_active, created_at, updated_at"; - -pub const POST_COLUMNS: &str = "\ - id, project_id, title, slug, excerpt, content, status, author, \ - language, do_not_translate, template_slug, file_path, checksum, \ - tags, categories, \ - published_title, published_content, published_tags, \ - published_categories, published_excerpt, \ - created_at, updated_at, published_at"; - -pub const POST_TRANSLATION_COLUMNS: &str = "\ - id, project_id, translation_for, language, title, excerpt, content, \ - status, file_path, checksum, created_at, updated_at, published_at"; - -pub const POST_LINK_COLUMNS: &str = "id, source_post_id, target_post_id, link_text, created_at"; - -pub const POST_MEDIA_COLUMNS: &str = "id, project_id, post_id, media_id, sort_order, created_at"; - -pub const MEDIA_COLUMNS: &str = "\ - id, project_id, filename, original_name, mime_type, size, \ - width, height, title, alt, caption, author, language, \ - file_path, sidecar_path, checksum, tags, created_at, updated_at"; - -pub const MEDIA_TRANSLATION_COLUMNS: &str = - "id, project_id, translation_for, language, title, alt, caption, created_at, updated_at"; - -pub const TAG_COLUMNS: &str = - "id, project_id, name, color, post_template_slug, created_at, updated_at"; - -pub const TEMPLATE_COLUMNS: &str = "\ - id, project_id, slug, title, kind, enabled, version, \ - file_path, status, content, created_at, updated_at"; - -pub const SCRIPT_COLUMNS: &str = "\ - id, project_id, slug, title, kind, entrypoint, enabled, version, \ - file_path, status, content, created_at, updated_at"; - -pub const SETTING_COLUMNS: &str = "key, value, updated_at"; - -pub const GENERATED_FILE_HASH_COLUMNS: &str = "project_id, relative_path, content_hash, updated_at"; - -pub const DB_NOTIFICATION_COLUMNS: &str = - "id, entity_type, entity_id, action, from_cli, seen_at, created_at"; - -// ── from_row functions ─────────────────────────────────────────────── - -pub fn project_from_row(row: &Row) -> rusqlite::Result { - Ok(Project { - id: row.get(0)?, - name: row.get(1)?, - slug: row.get(2)?, - description: row.get(3)?, - data_path: row.get(4)?, - is_active: bool_from_i64(row.get(5)?), - created_at: row.get(6)?, - updated_at: row.get(7)?, - }) -} - -pub fn post_from_row(row: &Row) -> rusqlite::Result { - let status_str: String = row.get(6)?; - let tags_json: String = row.get(13)?; - let categories_json: String = row.get(14)?; - Ok(Post { - id: row.get(0)?, - project_id: row.get(1)?, - title: row.get(2)?, - slug: row.get(3)?, - excerpt: row.get(4)?, - content: row.get(5)?, - status: parse_post_status(&status_str)?, - author: row.get(7)?, - language: row.get(8)?, - do_not_translate: bool_from_i64(row.get(9)?), - template_slug: row.get(10)?, - file_path: row.get(11)?, - checksum: row.get(12)?, - tags: json_to_vec_string(&tags_json)?, - categories: json_to_vec_string(&categories_json)?, - published_title: row.get(15)?, - published_content: row.get(16)?, - published_tags: row.get(17)?, - published_categories: row.get(18)?, - published_excerpt: row.get(19)?, - created_at: row.get(20)?, - updated_at: row.get(21)?, - published_at: row.get(22)?, - }) -} - -pub fn post_translation_from_row(row: &Row) -> rusqlite::Result { - let status_str: String = row.get(7)?; - Ok(PostTranslation { - id: row.get(0)?, - project_id: row.get(1)?, - translation_for: row.get(2)?, - language: row.get(3)?, - title: row.get(4)?, - excerpt: row.get(5)?, - content: row.get(6)?, - status: parse_post_status(&status_str)?, - file_path: row.get(8)?, - checksum: row.get(9)?, - created_at: row.get(10)?, - updated_at: row.get(11)?, - published_at: row.get(12)?, - }) -} - -pub fn post_link_from_row(row: &Row) -> rusqlite::Result { - Ok(PostLink { - id: row.get(0)?, - source_post_id: row.get(1)?, - target_post_id: row.get(2)?, - link_text: row.get(3)?, - created_at: row.get(4)?, - }) -} - -pub fn post_media_from_row(row: &Row) -> rusqlite::Result { - Ok(PostMedia { - id: row.get(0)?, - project_id: row.get(1)?, - post_id: row.get(2)?, - media_id: row.get(3)?, - sort_order: row.get(4)?, - created_at: row.get(5)?, - }) -} - -pub fn media_from_row(row: &Row) -> rusqlite::Result { - let tags_json: String = row.get(16)?; - Ok(Media { - id: row.get(0)?, - project_id: row.get(1)?, - filename: row.get(2)?, - original_name: row.get(3)?, - mime_type: row.get(4)?, - size: row.get(5)?, - width: row.get(6)?, - height: row.get(7)?, - title: row.get(8)?, - alt: row.get(9)?, - caption: row.get(10)?, - author: row.get(11)?, - language: row.get(12)?, - file_path: row.get(13)?, - sidecar_path: row.get(14)?, - checksum: row.get(15)?, - tags: json_to_vec_string(&tags_json)?, - created_at: row.get(17)?, - updated_at: row.get(18)?, - }) -} - -pub fn media_translation_from_row(row: &Row) -> rusqlite::Result { - Ok(MediaTranslation { - id: row.get(0)?, - project_id: row.get(1)?, - translation_for: row.get(2)?, - language: row.get(3)?, - title: row.get(4)?, - alt: row.get(5)?, - caption: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - }) -} - -pub fn tag_from_row(row: &Row) -> rusqlite::Result { - Ok(Tag { - id: row.get(0)?, - project_id: row.get(1)?, - name: row.get(2)?, - color: row.get(3)?, - post_template_slug: row.get(4)?, - created_at: row.get(5)?, - updated_at: row.get(6)?, - }) -} - -pub fn template_from_row(row: &Row) -> rusqlite::Result