feat: lens_config module — load JSON lens files, convert to ProtolensChain + FieldTransforms Parses human-readable lens DSL from packages/lenses/*.lens.json: - Schema-level steps → panproto combinators → ProtolensChain - Value-level steps → panproto expression parser → FieldTransforms Single source of truth for both TypeScript view generation and runtime instance transforms. Next: wire into codegen pipeline to replace record_config.rs and db_projection.rs.
Author: Aaron Steven White
Commit
9e088dce242a5193c170f19272043b6efa5ee071Parent: ec50c8ce8d
Structural diff unavailable
These commits were pushed via plain git push, so no pre-parsed
schemas are available. Install git-remote-cospan and re-push via panproto:// to
see scope-level changes, breaking change detection, and semantic diffs.
brew install panproto/tap/git-remote-cospan4 files changed +271 -0
@@ -19,6 +19,7 @@ panproto-mig.workspace = true
1919 panproto-inst.workspace = true 2020 panproto-lens.workspace = true 2121 panproto-expr.workspace = true 22+panproto-expr-parser.workspace = true 2223 panproto-check.workspace = true 2324 panproto-gat.workspace = true 2425
@@ -0,0 +1,268 @@
1+//! Load human-readable lens JSON files and convert to panproto ProtolensChain. 2+//! 3+//! Lens files use a concise DSL: 4+//! { "remove_field": "fieldName" } → combinators::remove_field 5+//! { "rename_field": { "old": "x", "new": "y" } } → combinators::rename_field 6+//! { "add_field": { "name": "x", "kind": "string", "default": "" } } → combinators::add_field 7+//! { "add_field": { ..., "expr": "head(split(...))" } } → add_field + ComputeField 8+//! { "apply_expr": { "field": "x", "expr": "match(...)" } } → ApplyExpr 9+//! { "compute_field": { "target": "x", "expr": "concat(...)" } } → ComputeField 10+//! 11+//! Schema-level steps (remove, rename, add) → ProtolensChain via panproto combinators. 12+//! Value-level steps (expr, compute) → FieldTransforms via panproto expression parser. 13+//! Both come from the same JSON file; they're applied at different levels. 14+ 15+use std::collections::HashMap; 16+use std::path::Path; 17+ 18+use anyhow::{Context, Result}; 19+use panproto_gat::{CoercionClass, Name}; 20+use panproto_inst::value::Value; 21+use panproto_inst::FieldTransform; 22+use panproto_lens::{combinators, ProtolensChain}; 23+use serde::Deserialize; 24+ 25+/// A human-readable lens file. 26+#[derive(Debug, Deserialize)] 27+pub struct LensFile { 28+ #[serde(rename = "$type")] 29+ pub type_id: String, 30+ pub id: String, 31+ #[serde(default)] 32+ pub description: String, 33+ pub source: String, 34+ pub target: String, 35+ pub steps: Vec<LensStep>, 36+ #[serde(default)] 37+ pub table: Option<TableConfig>, 38+} 39+ 40+/// A single step in the lens pipeline. 41+#[derive(Debug, Deserialize)] 42+#[serde(untagged)] 43+pub enum LensStep { 44+ RemoveField { remove_field: String }, 45+ RenameField { rename_field: RenameSpec }, 46+ AddField { add_field: AddFieldSpec }, 47+ ApplyExpr { apply_expr: ApplyExprSpec }, 48+ ComputeField { compute_field: ComputeFieldSpec }, 49+} 50+ 51+#[derive(Debug, Deserialize)] 52+pub struct RenameSpec { 53+ pub old: String, 54+ pub new: String, 55+} 56+ 57+#[derive(Debug, Deserialize)] 58+pub struct AddFieldSpec { 59+ pub name: String, 60+ pub kind: String, 61+ #[serde(default)] 62+ pub default: serde_json::Value, 63+ #[serde(default)] 64+ pub expr: Option<String>, 65+} 66+ 67+#[derive(Debug, Deserialize)] 68+pub struct ApplyExprSpec { 69+ pub field: String, 70+ pub expr: String, 71+} 72+ 73+#[derive(Debug, Deserialize)] 74+pub struct ComputeFieldSpec { 75+ pub target: String, 76+ pub expr: String, 77+} 78+ 79+/// DDL metadata (optional, only for db-projection lenses). 80+#[derive(Debug, Deserialize)] 81+pub struct TableConfig { 82+ pub name: String, 83+ pub row_struct: String, 84+ pub conflict_keys: Vec<String>, 85+ #[serde(default)] 86+ pub has_serial_id: bool, 87+ #[serde(default = "default_true")] 88+ pub include_did: bool, 89+ #[serde(default = "default_true")] 90+ pub include_rkey: bool, 91+ #[serde(default)] 92+ pub indexes: Vec<serde_json::Value>, 93+ #[serde(default)] 94+ pub foreign_keys: Vec<serde_json::Value>, 95+ #[serde(default)] 96+ pub column_defaults: HashMap<String, String>, 97+ #[serde(default)] 98+ pub counter_fields: Vec<String>, 99+} 100+ 101+fn default_true() -> bool { true } 102+ 103+/// Load all lens files from a directory. 104+pub fn load_all_lenses(lenses_dir: &Path) -> Result<Vec<LensFile>> { 105+ let mut lenses = Vec::new(); 106+ if !lenses_dir.exists() { 107+ return Ok(lenses); 108+ } 109+ for entry in std::fs::read_dir(lenses_dir)? { 110+ let path = entry?.path(); 111+ if path.extension().and_then(|e| e.to_str()) == Some("json") { 112+ let json_str = std::fs::read_to_string(&path) 113+ .with_context(|| format!("reading {}", path.display()))?; 114+ let lens: LensFile = serde_json::from_str(&json_str) 115+ .with_context(|| format!("parsing {}", path.display()))?; 116+ lenses.push(lens); 117+ } 118+ } 119+ Ok(lenses) 120+} 121+ 122+/// Convert schema-level steps to a ProtolensChain using panproto combinators. 123+pub fn steps_to_protolens_chain(steps: &[LensStep], body_id: &str) -> ProtolensChain { 124+ let mut chains: Vec<ProtolensChain> = Vec::new(); 125+ 126+ for step in steps { 127+ match step { 128+ LensStep::RemoveField { remove_field } => { 129+ let vertex_id = format!("{body_id}.{remove_field}"); 130+ chains.push(combinators::remove_field(vertex_id)); 131+ } 132+ LensStep::RenameField { rename_field } => { 133+ let field_vertex = format!("{body_id}.{}", rename_field.old); 134+ chains.push(combinators::rename_field( 135+ body_id, 136+ &*field_vertex, 137+ &*rename_field.old, 138+ &*rename_field.new, 139+ )); 140+ } 141+ LensStep::AddField { add_field } => { 142+ let vertex_id = format!("{body_id}.{}", add_field.name); 143+ let default = json_to_value(&add_field.default, &add_field.kind); 144+ chains.push(combinators::add_field( 145+ body_id, 146+ &*vertex_id, 147+ &*add_field.kind, 148+ default, 149+ )); 150+ } 151+ // Expression steps are value-level, not schema-level — handled by 152+ // steps_to_value_transforms, not by protolens combinators. 153+ LensStep::ApplyExpr { .. } | LensStep::ComputeField { .. } => {} 154+ } 155+ } 156+ 157+ combinators::pipeline(chains) 158+} 159+ 160+/// Extract value-level transforms (expressions) from lens steps. 161+/// 162+/// These are injected into the CompiledMigration's field_transforms after 163+/// the schema-level chain is instantiated. Uses panproto_expr_parser to 164+/// parse expression strings from the lens JSON. 165+pub fn steps_to_value_transforms( 166+ steps: &[LensStep], 167+ body_vertex: &str, 168+) -> HashMap<Name, Vec<FieldTransform>> { 169+ let mut transforms: HashMap<Name, Vec<FieldTransform>> = HashMap::new(); 170+ let key = Name::from(body_vertex); 171+ 172+ for step in steps { 173+ match step { 174+ LensStep::AddField { add_field } if add_field.expr.is_some() => { 175+ let expr_str = add_field.expr.as_ref().unwrap(); 176+ if let Some(expr) = parse_expr(expr_str) { 177+ transforms.entry(key.clone()).or_default().push( 178+ FieldTransform::ComputeField { 179+ target_key: add_field.name.clone(), 180+ expr, 181+ inverse: None, 182+ coercion_class: CoercionClass::Projection, 183+ }, 184+ ); 185+ } 186+ } 187+ LensStep::ApplyExpr { apply_expr } => { 188+ if let Some(expr) = parse_expr(&apply_expr.expr) { 189+ transforms.entry(key.clone()).or_default().push( 190+ FieldTransform::ApplyExpr { 191+ key: apply_expr.field.clone(), 192+ expr, 193+ inverse: None, 194+ coercion_class: CoercionClass::Projection, 195+ }, 196+ ); 197+ } 198+ } 199+ LensStep::ComputeField { compute_field } => { 200+ if let Some(expr) = parse_expr(&compute_field.expr) { 201+ transforms.entry(key.clone()).or_default().push( 202+ FieldTransform::ComputeField { 203+ target_key: compute_field.target.clone(), 204+ expr, 205+ inverse: None, 206+ coercion_class: CoercionClass::Projection, 207+ }, 208+ ); 209+ } 210+ } 211+ _ => {} 212+ } 213+ } 214+ 215+ transforms 216+} 217+ 218+/// Parse a panproto expression string using panproto_expr_parser. 219+fn parse_expr(expr_str: &str) -> Option<panproto_expr::Expr> { 220+ let tokens = match panproto_expr_parser::tokenize(expr_str) { 221+ Ok(t) => t, 222+ Err(e) => { 223+ eprintln!(" warn: failed to tokenize expression '{expr_str}': {e}"); 224+ return None; 225+ } 226+ }; 227+ match panproto_expr_parser::parse(&tokens) { 228+ Ok(expr) => Some(expr), 229+ Err(errors) => { 230+ for e in &errors { 231+ eprintln!(" warn: parse error in expression '{expr_str}': {e}"); 232+ } 233+ None 234+ } 235+ } 236+} 237+ 238+/// Convert a JSON default value to a panproto Value. 239+fn json_to_value(json: &serde_json::Value, kind: &str) -> Value { 240+ match json { 241+ serde_json::Value::Null => Value::Null, 242+ serde_json::Value::String(s) => Value::Str(s.clone()), 243+ serde_json::Value::Number(n) => { 244+ if let Some(i) = n.as_i64() { Value::Int(i) } 245+ else if let Some(f) = n.as_f64() { Value::Float(f) } 246+ else { Value::Int(0) } 247+ } 248+ serde_json::Value::Bool(b) => Value::Bool(*b), 249+ _ => match kind { 250+ "integer" => Value::Int(0), 251+ "number" | "float" => Value::Float(0.0), 252+ "boolean" => Value::Bool(false), 253+ _ => Value::Str(String::new()), 254+ }, 255+ } 256+} 257+ 258+pub fn db_projection_lenses(lenses: &[LensFile]) -> Vec<&LensFile> { 259+ lenses.iter().filter(|l| l.id.ends_with(".db-projection")).collect() 260+} 261+ 262+pub fn interop_lenses(lenses: &[LensFile]) -> Vec<&LensFile> { 263+ lenses.iter().filter(|l| l.id.ends_with(".interop")).collect() 264+} 265+ 266+pub fn find_by_source<'a>(lenses: &'a [LensFile], source_nsid: &str) -> Option<&'a LensFile> { 267+ lenses.iter().find(|l| l.source == source_nsid) 268+}
@@ -2,6 +2,7 @@ pub mod db_projection;
22 pub mod emit_rows; 33 pub mod emit_sql; 44 pub mod emit_typescript_views; 5+pub mod lens_config; 56 pub mod emit_xrpc; 67 pub mod morphism; 78 pub mod record_config;
@@ -14,6 +14,7 @@ mod emit_rows;
1414 mod emit_sql; 1515 mod emit_typescript_views; 1616 mod emit_xrpc; 17+mod lens_config; 1718 mod morphism; 1819 mod record_config; 1920 mod tangled_interop;