diff --git a/migrations/015_block_schema_cache.sql b/migrations/015_block_schema_cache.sql new file mode 100644 index 0000000..a219aa3 --- /dev/null +++ b/migrations/015_block_schema_cache.sql @@ -0,0 +1,13 @@ +-- Inferred response shapes per (file_path, alias), written by the +-- executor path on every successful run. Read-only consumers (the +-- language server) resolve `{{alias.path}}` fields against the shape. +-- Versioned: readers treat rows with a different cache_schema_version +-- as a cache miss (rebuild happens on the next run, never in place). +CREATE TABLE IF NOT EXISTS block_schema_cache ( + file_path TEXT NOT NULL, + alias TEXT NOT NULL, + shape TEXT NOT NULL, + cache_schema_version INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (file_path, alias) +); diff --git a/src/block_results.rs b/src/block_results.rs index befe5cf..92aaf17 100644 --- a/src/block_results.rs +++ b/src/block_results.rs @@ -192,6 +192,15 @@ pub async fn save_block_result_with_alias( .execute(pool) .await?; + // Successful runs also refresh the inferred shape so ref resolution + // (language server) sees the fields of the latest response. + // Best-effort: a non-JSON response simply has no shape. + if let (Some(alias), "success") = (alias, status) { + if let Ok(json) = serde_json::from_str::(response) { + let _ = crate::block_schema::upsert_block_schema(pool, file_path, alias, &json).await; + } + } + Ok(()) } diff --git a/src/block_schema.rs b/src/block_schema.rs new file mode 100644 index 0000000..97208a3 --- /dev/null +++ b/src/block_schema.rs @@ -0,0 +1,115 @@ +//! Inferred response shapes for `{{alias.path}}` resolution. The shape +//! is the structural skeleton of a concrete response value (no data, +//! only types): objects keep their keys, arrays keep one sampled +//! element, scalars become their type name. Written on every successful +//! run; read-only consumers (the language server) resolve ref paths and +//! field typos against it. + +use sqlx::SqlitePool; + +/// Readers must treat rows with a different version as a cache miss; +/// rebuild happens on the next run, never by migrating rows in place. +pub const CACHE_SCHEMA_VERSION: i64 = 1; + +/// Structural skeleton of a JSON value: `{"id": 1}` -> `{"id": "number"}`. +pub fn json_shape(value: &serde_json::Value) -> serde_json::Value { + use serde_json::Value; + match value { + Value::Null => Value::String("null".into()), + Value::Bool(_) => Value::String("boolean".into()), + Value::Number(_) => Value::String("number".into()), + Value::String(_) => Value::String("string".into()), + Value::Array(items) => match items.first() { + Some(first) => Value::Array(vec![json_shape(first)]), + None => Value::Array(vec![]), + }, + Value::Object(map) => Value::Object( + map.iter() + .map(|(k, v)| (k.clone(), json_shape(v))) + .collect(), + ), + } +} + +pub async fn upsert_block_schema( + pool: &SqlitePool, + file_path: &str, + alias: &str, + response: &serde_json::Value, +) -> Result<(), sqlx::Error> { + let shape = json_shape(response).to_string(); + sqlx::query( + "INSERT INTO block_schema_cache (file_path, alias, shape, cache_schema_version, updated_at) + VALUES (?1, ?2, ?3, ?4, datetime('now')) + ON CONFLICT(file_path, alias) DO UPDATE SET + shape = excluded.shape, + cache_schema_version = excluded.cache_schema_version, + updated_at = datetime('now')", + ) + .bind(file_path) + .bind(alias) + .bind(shape) + .bind(CACHE_SCHEMA_VERSION) + .execute(pool) + .await?; + Ok(()) +} + +/// Latest shape for `(file_path, alias)`, or `None` when absent or +/// written by a different cache version. +pub async fn get_block_schema( + pool: &SqlitePool, + file_path: &str, + alias: &str, +) -> Result, sqlx::Error> { + let row: Option<(String,)> = sqlx::query_as( + "SELECT shape FROM block_schema_cache + WHERE file_path = ?1 AND alias = ?2 AND cache_schema_version = ?3", + ) + .bind(file_path) + .bind(alias) + .bind(CACHE_SCHEMA_VERSION) + .fetch_optional(pool) + .await?; + Ok(row.map(|(shape,)| shape)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn shape_of_scalars_and_nesting() { + let v = json!({"id": 7, "name": "x", "ok": true, "meta": null, + "items": [{"sku": "a", "qty": 2}], "empty": []}); + assert_eq!( + json_shape(&v), + json!({"id": "number", "name": "string", "ok": "boolean", + "meta": "null", + "items": [{"sku": "string", "qty": "number"}], + "empty": []}) + ); + } + + #[tokio::test] + async fn upsert_and_read_roundtrip() { + let tmp = tempfile::TempDir::new().unwrap(); + let pool = crate::db::init_db(tmp.path()).await.unwrap(); + let resp = json!({"body": {"url": "https://x", "n": 1}}); + upsert_block_schema(&pool, "a.md", "req1", &resp) + .await + .unwrap(); + // overwrite with a new shape — latest wins + let resp2 = json!({"body": {"url": "https://x"}}); + upsert_block_schema(&pool, "a.md", "req1", &resp2) + .await + .unwrap(); + let shape = get_block_schema(&pool, "a.md", "req1").await.unwrap(); + assert_eq!(shape.as_deref(), Some(r#"{"body":{"url":"string"}}"#)); + assert_eq!( + get_block_schema(&pool, "a.md", "ghost").await.unwrap(), + None + ); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index b5a15c5..70cecff 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -38,6 +38,7 @@ const MIGRATION_011_SQL: &str = include_str!("../../migrations/011_block_example const MIGRATION_012_SQL: &str = include_str!("../../migrations/012_block_run_history_plan.sql"); const MIGRATION_013_SQL: &str = include_str!("../../migrations/013_schema_cache_drop_fk.sql"); const MIGRATION_014_SQL: &str = include_str!("../../migrations/014_block_results_alias.sql"); +const MIGRATION_015_SQL: &str = include_str!("../../migrations/015_block_schema_cache.sql"); pub async fn init_db(app_data_dir: &Path) -> Result { std::fs::create_dir_all(app_data_dir).ok(); @@ -45,9 +46,12 @@ pub async fn init_db(app_data_dir: &Path) -> Result { let db_path = app_data_dir.join("notes.db"); let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); + // WAL so external read-only consumers (the language server reads the + // schema/env tables) are never blocked by the app's writes. let options = SqliteConnectOptions::from_str(&db_url)? .create_if_missing(true) - .foreign_keys(true); + .foreign_keys(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); let pool = SqlitePoolOptions::new() .max_connections(5) @@ -259,6 +263,13 @@ async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { } } + for statement in MIGRATION_015_SQL.split(';') { + let trimmed = statement.trim(); + if !trimmed.is_empty() { + sqlx::query(trimmed).execute(pool).await?; + } + } + Ok(()) } @@ -267,6 +278,17 @@ mod tests { use super::*; use tempfile::TempDir; + #[tokio::test] + async fn test_init_db_enables_wal() { + let tmp = TempDir::new().unwrap(); + let pool = init_db(tmp.path()).await.unwrap(); + let row: (String,) = sqlx::query_as("PRAGMA journal_mode") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row.0.to_lowercase(), "wal"); + } + #[tokio::test] async fn test_init_db_creates_file_and_runs_migrations() { let tmp = TempDir::new().unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 958d7c6..fdd18f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod block_examples; pub mod block_history; pub mod block_results; +pub mod block_schema; pub mod block_settings; pub mod blocks; pub mod captures_cache;