cerulea/lexicon
define atproto schemas in TypeScript
git clone https://git.t4t.associates/cerulea/lexicon
aceb06a
main
1import type { ParamsSchema , Rpc } from "./rpc.ts" ; 2import { definitionId , type Schema } from "./schema.ts" ; 3import { unwrap } from "./util/fields.ts" ; 4 5export type Json = null | boolean | number | string | readonly Json [] | { 6readonly [ key :string ] :Json ; 7}; 8export type LexiconDefinition = { readonly type :string ; readonly [ key :string ] :Json }; 9export type LexiconDoc = { 10readonly lexicon :1 ; 11readonly id :string ; 12readonly defs :Readonly < Record < string , LexiconDefinition >>; 13}; 14 15type Context = "definition" | "property" | "item" | "body" ; 16type Emit = ( schema :Schema | ParamsSchema | Rpc , context :Context ) => LexiconDefinition ; 17 18function byName ([ a ] :readonly [ string , unknown ], [ b ] :readonly [ string , unknown ]) :number { 19return a < b ?- 1 :a > b ?1 :0 ; 20} 21 22// Canonical field order makes collision checks and generated files stable. 23function canonical ( definition :LexiconDefinition ) :LexiconDefinition { 24return Object . fromEntries ( Object . entries ( definition ). sort ( byName )) as LexiconDefinition ; 25} 26 27function schemaFields < S extends Schema > ( schema :S ) :Omit < S , "validate" > { 28const { validate :_validate , ...fields } = schema ; 29return fields ; 30} 31 32const body = ( schema :Schema , emit :Emit ) => ({ 33encoding :"application/json" , 34schema :emit ( schema , "body" ), 35}); 36 37function definition ( 38schema :Schema | ParamsSchema | Rpc , 39context :Context , 40emit :Emit , 41register :( id :string , schema :Schema | Rpc ) => void , 42) :LexiconDefinition { 43switch ( schema . type ) { 44case "query" : 45case "procedure" : 46if ( context !== "definition" ) throw new Error ( "RPCs cannot be used as field schemas" ); 47return { 48type :schema . type , 49 ...( schema . parameters && { parameters :emit ( schema . parameters , "definition" ) }), 50 ...( schema . output && { output :body ( schema . output , emit ) }), 51 ...( schema . type === "procedure" && schema . input && { input :body ( schema . input , emit ) }), 52 ...( schema . errors && { errors :schema . errors . map (( error ) => ({ ...error })) }), 53}; 54case "record" : 55if ( context === "definition" ) { 56return { type :"record" , key :schema . key , record :emit ( schema . record , "definition" ) }; 57} 58register ( schema . id , schema ); 59return { type :"ref" , ref :schema . id }; 60case "ref" : 61register ( schema . id , schema . target ); 62return { type :"ref" , ref :schema . id }; 63case "union" :{ 64const refs = Object . entries ( schema . variants ). sort ( byName ). map (([ id , variant ]) => { 65register ( id , variant ); 66return id ; 67}); 68return { type :"union" , refs, closed :schema . closed }; 69} 70case "params" : 71case "object" :{ 72if ( context !== "definition" && context !== "body" ) { 73throw new Error ( "Nested objects must be named with named(id, object(...))" ); 74} 75const properties :Record < string , Json > = Object . create ( null ); 76const required :string [] = [], nullable :string [] = []; 77for ( const [ key , field ] of Object . entries ( schema . properties ). sort ( byName )) { 78const inner = unwrap ( field ); 79if ( ! inner . optional ) required . push ( key ); 80if ( inner . nullable ) nullable . push ( key ); 81properties [ key ] = emit ( inner . schema , "property" ); 82} 83return { 84type :schema . type , 85 properties, 86 ...( required . length > 0 && { required}), 87 ...( nullable . length > 0 && { nullable}), 88}; 89} 90case "array" : 91if ( context === "item" ) { 92throw new Error ( "Nested arrays must be named with named(id, array(...))" ); 93} 94return { ...schemaFields ( schema ), items :emit ( schema . items , "item" ) }; 95default : 96return schemaFields ( schema ); 97} 98} 99 100export function toLexicons ( ...roots :readonly ( Schema | Rpc )[]) :readonly LexiconDoc [] { 101const registered = new Map < string , Set < Schema | Rpc >>(); 102const pending :[ string , Schema | Rpc ][] = []; 103function register ( id :string , schema :Schema | Rpc ) :void { 104const schemas = registered . get ( id ) ?? new Set (); 105registered . set ( id , schemas ); 106if ( schemas . has ( schema )) return ; 107schemas . add ( schema ); 108pending . push ([ id , schema ]); 109} 110const emit :Emit = ( schema , context ) => { 111const result = definition ( schema , context , emit , register ); 112const { description} = schema ; 113return canonical ( description === undefined ?result :{ ...result , description}); 114}; 115 116for ( const root of roots ) { 117if ( root . type === "record" || root . type === "query" || root . type === "procedure" ) { 118register ( root . id , root ); 119} else if ( root . type === "ref" || root . type === "union" ) emit ( root , "property" ); 120else throw new Error ( "Lexicon roots must be records, named definitions, named unions, or RPCs" ); 121} 122const docs = new Map < string , Map < string , LexiconDefinition >>(); 123for ( const [ id , schema ] of pending ) { 124const { nsid, name} = definitionId ( id ); 125const def = emit ( schema , "definition" ); 126const defs = docs . get ( nsid ) ?? new Map < string , LexiconDefinition >(); 127docs . set ( nsid , defs ); 128const existing = defs . get ( name ); 129if ( existing && JSON . stringify ( existing ) !== JSON . stringify ( def )) { 130throw new Error ( `Conflicting definition: ${ id } ` ); 131} 132defs . set ( name , def ); 133} 134return [ ...docs ]. sort ( byName ). map (([ id , defs ]) => ({ 135lexicon :1 , 136 id, 137defs :Object . fromEntries ([ ...defs ]. sort ( byName )), 138})); 139}