Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions migrations/015_block_schema_cache.sql
Original file line number Diff line number Diff line change
@@ -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)
);
9 changes: 9 additions & 0 deletions src/block_results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Value>(response) {
let _ = crate::block_schema::upsert_block_schema(pool, file_path, alias, &json).await;
}
}

Ok(())
}

Expand Down
115 changes: 115 additions & 0 deletions src/block_schema.rs
Original file line number Diff line number Diff line change
@@ -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<Option<String>, 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
);
}
}
24 changes: 23 additions & 1 deletion src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,20 @@ 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<SqlitePool, sqlx::Error> {
std::fs::create_dir_all(app_data_dir).ok();

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)
Expand Down Expand Up @@ -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(())
}

Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading