cerulea/lexicon
define atproto schemas in TypeScript
git clone https://git.t4t.associates/cerulea/lexicon
62f5f17
main
1import type { Schema } from "../schema.ts" ; 2import { isObject } from "./data.ts" ; 3import { unwrap } from "./fields.ts" ; 4 5type Path = readonly ( string | number )[]; 6type Expand = ( value :unknown , path :Path ) => unknown ; 7 8const expanders = new WeakMap < Schema , Expand | undefined >(); 9 10function fail ( path :Path , message :string ) :never { 11throw new Error ( ` ${ message } at ${[ ... path , "$type" ]. join ( "." )} ` ); 12} 13 14function children ( schema :Schema ) :readonly Schema [] { 15switch ( schema . type ) { 16case "array" : 17return [ schema . items ]; 18case "object" : 19return Object . values ( schema . properties ). map (( field ) => unwrap ( field ). schema ); 20case "ref" : 21return [ schema . target ]; 22case "record" : 23return [ schema . record ]; 24case "union" : 25return Object . values ( schema . variants ); 26default : 27return []; 28} 29} 30 31// Every schema reachable from the root, and those that rewrite values or contain one that 32// does. Anything else is shared with the input rather than copied. 33function survey ( root :Schema ) :{ visited :Set < Schema >; required :Set < Schema > } { 34const parents = new Map < Schema , Set < Schema >>([[ root , new Set ()]]); 35const required = new Set < Schema >(); 36for ( const schema of parents . keys ()) { 37if ( expanders . has ( schema )) { 38if ( expanders . get ( schema )) required . add ( schema ); 39continue ; 40} 41if ( schema . type === "record" || schema . type === "union" ) required . add ( schema ); 42for ( const child of children ( schema )) { 43const known = parents . get ( child ); 44if ( known ) known . add ( schema ); 45else parents . set ( child , new Set ([ schema ])); 46} 47} 48// Propagate through the whole cycle before deciding which branches can be shared. 49for ( const schema of required ) { 50for ( const parent of parents . get ( schema ) ?? []) required . add ( parent ); 51} 52return { visited :new Set ( parents . keys ()), required}; 53} 54 55function expanderFor ( schema :Schema , build :( schema :Schema ) => Expand | undefined ) :Expand { 56const passthrough :Expand = ( value ) => value ; 57switch ( schema . type ) { 58case "array" :{ 59const item = build ( schema . items ) ?? passthrough ; 60return ( value , path ) => 61Array . isArray ( value ) ?value . map (( element , i ) => item ( element , [ ...path , i ])) :value ; 62} 63case "object" :{ 64const fields = new Map < string , Expand >(); 65for ( const [ key , field ] of Object . entries ( schema . properties )) { 66const expand = build ( unwrap ( field ). schema ); 67if ( expand ) fields . set ( key , expand ); 68} 69return ( value , path ) => { 70if ( ! isObject ( value )) return value ; 71return Object . fromEntries ( 72Object . entries ( value ). map (([ key , item ]) => { 73const field = fields . get ( key ); 74return [ key , field ?field ( item , [ ...path , key ]) :item ]; 75}), 76); 77}; 78} 79case "ref" : 80return build ( schema . target ) ?? passthrough ; 81case "record" :{ 82const body = build ( schema . record ) ?? passthrough ; 83return ( value , path ) => { 84if ( ! isObject ( value )) return value ; 85if ( Object . hasOwn ( value , "$type" ) && value . $type !== schema . id ) { 86fail ( path , `Expected ${ schema . id } ` ); 87} 88return { ...( body ( value , path ) as object ), $type :schema . id }; 89}; 90} 91case "union" :{ 92const { namespace} = schema ; 93const variants = new Map ( 94Object . entries ( schema . variants ). map (([ id , variant ]) => [ id , build ( variant )]), 95); 96return ( value , path ) => { 97if ( ! isObject ( value ) || typeof value . $type !== "string" ) return value ; 98let $type = value . $type ; 99if ( $type . startsWith ( "#" )) { 100if ( namespace === undefined ) fail ( path , "Only namespaced unions accept short variants" ); 101$type = namespace + $type ; 102if ( ! variants . has ( $type )) fail ( path , `Unknown union variant ${ value . $type } ` ); 103} 104const resolved = $type === value . $type ?value :{ ...value , $type}; 105const variant = variants . get ( $type ); 106return variant ?variant ( resolved , path ) :resolved ; 107}; 108} 109default : 110return passthrough ; 111} 112} 113 114// A value with its $type discriminators filled in and expanded, as far as the schema needs. 115export function expand ( schema :Schema , value :unknown ) :unknown { 116return expander ( schema ) ?.( value , []) ?? value ; 117} 118 119export function expander ( root :Schema ) :Expand | undefined { 120if ( expanders . has ( root )) return expanders . get ( root ); 121const { visited, required} = survey ( root ); 122const compiled = new Map < Schema , Expand | undefined >(); 123// Recursive schemas get a forwarder before their own expander exists. 124function build ( schema :Schema ) :Expand | undefined { 125if ( expanders . has ( schema )) return expanders . get ( schema ); 126if ( compiled . has ( schema )) return compiled . get ( schema ); 127if ( ! required . has ( schema )) return undefined ; 128let expand :Expand ; 129compiled . set ( schema , ( value , path ) => expand ( value , path )); 130return expand = expanderFor ( schema , build ); 131} 132const result = build ( root ); 133for ( const schema of visited ) { 134if ( ! expanders . has ( schema )) expanders . set ( schema , compiled . get ( schema )); 135} 136return result ; 137}