Write lenses in the lens DSL
The lens DSL is a declarative way to specify a lens between two schemas. Specs are written in Nickel, JSON, or YAML and compile to the lens combinator algebra.
Prerequisites
A pair of schemas to bridge. The schema CLI or panproto-lens-dsl crate.
The task
Write the spec (Nickel)
# lenses/user-v1-to-v2.ncl
{
id = "user.v1-to-v2",
description = "Rename name fields and add display_name",
steps = [
{ rename_field = { from = "first_name", to = "given_name" } },
{ rename_field = { from = "last_name", to = "family_name" } },
{ add_field = {
name = "display_name",
default = "",
expr = "Concat(record.given_name, \" \", record.family_name)",
} },
],
}
Each step is a single-key object. The key picks the variant; the value carries its parameters. The DSL applies steps left-to-right against the source schema, producing a target schema and a CompiledLens between them. Full step grammar: crates/panproto-lens-dsl/src/document.rs.
Compile a chain
schema lens does not consume .ncl/.json/.yaml lens documents directly through the CLI. The DSL is compiled by the panproto-lens-dsl library; the CLI works on the compiled ProtolensChain it produces. To go from a pair of schemas to a chain via the CLI, use:
schema lens generate \
--protocol atproto \
schemas/user-v1.json \
schemas/user-v2.json \
--save lenses/user-v1-to-v2.json
This auto-derives a chain. To author a chain by hand, write the lens DSL and call panproto_lens_dsl::load and panproto_lens_dsl::compile from Rust (or the equivalent SDK calls).
Apply
schema lens apply --protocol atproto lenses/user-v1-to-v2.json data/users.json
Inspect
schema lens inspect --protocol atproto lenses/user-v1-to-v2.json
Prints the combinator chain. --protocol is required.
Compile from Python
ProtolensChain exposes loaders that consume a lens-DSL document and compile it directly to a chain anchored at the named body vertex of the source schema:
import panproto
chain = panproto.ProtolensChain.from_dsl_path(
"lenses/user-v1-to-v2.ncl",
body_vertex="record:body",
)
# Or from a string:
chain = panproto.ProtolensChain.from_dsl_json(json_source, "record:body")
chain = panproto.ProtolensChain.from_dsl_yaml(yaml_source, "record:body")
chain = panproto.ProtolensChain.from_dsl_nickel(nickel_source, "record:body")
from_dsl_path dispatches on file extension (.ncl / .json / .yaml / .yml). For Nickel, an optional import_paths argument extends the import-resolution lookup so user-defined modules can be referenced.
Verification
# Check the chain is applicable to every schema in a directory.
schema lens check --protocol atproto lenses/user-v1-to-v2.json schemas/
# Verify the lens laws on test data.
schema lens verify --protocol atproto data/users.json schemas/user-v2.json
lens check reports applicability without instantiating; lens verify checks the round-trip laws on actual data.
Common mistakes
- Step ordering. The DSL deliberately exposes ordering; some sequences commute and produce the same result, others do not. When in doubt, run
schema lens checkwith high sample counts. - Forgetting
backwardon afield_transformstep. Without it, the step is half a lens; compilation rejects. - Writing schemas inline. The DSL expects path references; embedding a schema as a literal works for small examples but loses VCS tracking.