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 9e088dce242a5193c170f19272043b6efa5ee071
Parent: 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-cospan
4 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;
cospan · schematic version control on atproto built on AT Protocol