Apply a lens

A lens is a structure-preserving translation between two schemas. On the wire it is a dev.panproto.schema.lens record (re-exported by idiolect-records as PanprotoLens); at runtime it is a panproto_lens::Lens instantiated against a Schema graph. idiolect-lens bridges the two: it resolves a lens record by at-uri, loads both schemas, and runs the lens.

What a lens does

A lens has a forward direction and a backward direction:

get translates a source record A into a target view B plus a complement (the data that the projection discarded). put reconstructs A from a (possibly modified) B and the complement. The two directions obey the GetPut and PutGet laws covered in Lens semantics and laws.

Wire it up

idiolect-lens is publish = false; depend on it via git (or a path, when working inside the workspace):

# in Cargo.toml
idiolect-lens   = { git = "https://github.com/idiolect-dev/idiolect", tag = "v0.10.0", features = ["pds-reqwest"] }
panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.39.0" }
tokio           = { version = "1", features = ["full"] }

The lens runtime needs three pieces: a Resolver (fetches the lens record by at-uri), a SchemaLoader (turns the dev.panproto.schema.schema at-uris on the lens record into typed panproto_schema::Schema values), and a Protocol. v0.9 ships PdsSchemaLoader to pair with PdsResolver; both share a ReqwestPdsClient.

src/main.rs:

use idiolect_lens::{
    apply_lens, ApplyLensInput, AtUri, PdsResolver, PdsSchemaLoader,
    ReqwestPdsClient,
};
use panproto_schema::Protocol;

const PDS:  &str = "https://jellybaby.us-east.host.bsky.network";
const LENS: &str = "at://did:plc:wdl4nnvxxdy4mc5vddxlm6f3/dev.panproto.schema.lens/tutorial-rename-sort-string-to-text";

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client   = ReqwestPdsClient::with_service_url(PDS);
    let resolver = PdsResolver::new(client.clone());
    let loader   = PdsSchemaLoader::new(client);
    let protocol = Protocol::default();

    let lens_uri = AtUri::parse(LENS)?;
    let source_record: serde_json::Value =
        serde_json::from_str(r#"{ "text": "hello, world" }"#)?;

    let out = apply_lens(&resolver, &loader, &protocol, ApplyLensInput {
        lens_uri, source_record, source_root_vertex: None,
    }).await?;

    println!("{}", serde_json::to_string_pretty(&out.target_record)?);
    Ok(())
}

Run it:

cargo run
{
  "text": "hello, world"
}

The lens referenced above is real. The project DID has the three records the runtime needs to resolve it published on its PDS:

  • dev.panproto.schema.schema/tutorial-post-body-v1 — a single-field "post:body" record with a string text child.
  • dev.panproto.schema.schema/tutorial-post-body-v2 — the same shape with the kind relabelled to text.
  • dev.panproto.schema.lens/tutorial-rename-sort-string-to-text — a single-step rename_sort chain. The optic class is Iso; round-trip is byte-equal.

apply_lens is one async call. It does five things in order:

  1. Resolve the lens record from the PDS via PdsResolver.
  2. Load the source and target schemas from the schema loader.
  3. Instantiate the protolens (or protolens chain) against the source schema under the given protocol.
  4. Parse source_record into a panproto w-type instance, project it through get, and serialize the view back to JSON under the target schema.
  5. Return the target record together with the complement.

The complement is a typed panproto_lens::Complement. Treat it as an opaque token: store it next to the target view, hand it back to apply_lens_put when you want to run the reverse direction, do not edit it.

Reverse the direction

#![allow(unused)]
fn main() {
use idiolect_lens::{apply_lens_put, ApplyLensPutInput};

let back = apply_lens_put(
    &resolver,
    &loader,
    &protocol,
    ApplyLensPutInput {
        lens_uri: lens_uri.clone(),
        target_record: out.target_record,
        complement: out.complement,
        target_root_vertex: None,
    },
)
.await?;

assert_eq!(back.source_record, source_record);
}

If the lens is an isomorphism, put(get(a)) returns the original a byte-for-byte. If it is a projection (information was dropped on the way through), put reconstructs a from the target plus the complement. If you modify the target between the calls, put applies the modification on top of the original source.

What can go wrong

SymptomCause
LensError::NotFoundThe lens at-uri did not resolve. Check the DID and the rkey.
LensError::LexiconParseThe schema loader returned bytes that were not a valid panproto schema.
LensError::TranslateThe source record did not parse as an instance of the source schema.
Output complement is hugeThe lens is closer to a projection than you thought. See Lens semantics.

The runtime is Send-clean. You can hold an Arc<dyn Resolver> and call apply_lens from inside an #[async_trait] handler in an HTTP server.

The next chapter takes that lens and runs a verification against it.