cerulea/lexicon
define atproto schemas in TypeScript
git clone https://git.t4t.associates/cerulea/lexicon
62f5f17
main
1import { expand } from "./util/build.ts" ; 2import { compile , type ValidationResult } from "./validation.ts" ; 3import { isNsid } from "./util/syntax.ts" ; 4 5export declare const output :unique symbol ; 6export declare const input :unique symbol ; 7 8interface Typed < T , I = T > { 9readonly [ output ] ?:readonly [ T ]; 10readonly [ input ] ?:readonly [ I ]; 11} 12 13// deno-lint-ignore ban-types -- the intersection forces editor expansion 14type Simplify < T > = { [ K in keyof T ] :T [ K ] } & {}; 15 16type Channel = typeof output | typeof input ; 17type Read < S , C extends Channel > = S extends { readonly [ K in C ] ?:readonly [ inferT ] } ?T :never ; 18export type Infer < S > = Read < S , typeof output >; 19/** What builders accept for a schema, where union and record discriminators may be implied. */ 20export type Input < S > = Read < S , typeof input >; 21type Validated < T , I = T > = Typed < T , I > & { validate ( value :unknown ) :ValidationResult < T > }; 22export type Schema < T = unknown , I = T > = Node & Validated < T , I >; 23 24export type StringFormat = 25| "did" 26| "handle" 27| "at-identifier" 28| "at-uri" 29| "datetime" 30| "uri" 31| "language" 32| "cid" 33| "nsid" 34| "tid" ; 35export type StringOptions = { 36readonly description ?:string ; 37readonly format ?:StringFormat ; 38readonly minLength ?:number ; 39readonly maxLength ?:number ; 40readonly minGraphemes ?:number ; 41readonly maxGraphemes ?:number ; 42}; 43export type IntegerOptions = { 44readonly description ?:string ; 45readonly minimum ?:number ; 46readonly maximum ?:number ; 47}; 48export type LengthOptions = { 49readonly description ?:string ; 50readonly minLength ?:number ; 51readonly maxLength ?:number ; 52}; 53export type BlobOptions = { 54readonly description ?:string ; 55readonly accept ?:readonly string []; 56readonly maxSize ?:number ; 57}; 58 59export type CidLink = { readonly $link :string }; 60export type BytesValue = { readonly $bytes :string }; 61export type BlobValue = { 62readonly $type :"blob" ; 63readonly ref :CidLink ; 64readonly mimeType :string ; 65readonly size :number ; 66}; 67 68export type OptionalField < T = unknown , I = T > = Typed < T , I > & { 69readonly type :"optional" ; 70readonly inner :Schema | NullableField ; 71}; 72export type NullableField < T = unknown , I = T > = Typed < T | null , I | null > & { 73readonly type :"nullable" ; 74readonly inner :Schema ; 75}; 76export type Field = Schema | OptionalField | NullableField ; 77export type Shape = Readonly < Record < string , Field >>; 78 79type ObjectValue < S extends Shape , C extends Channel = typeof output > = 80& { readonly [ K in keyof S as S [ K ] extends OptionalField ?never :K ] :Read < S [ K ], C > } 81& { 82readonly [ K in keyof S as S [ K ] extends OptionalField ?K :never ] ?:Read < S [ K ], C > | undefined ; 83}; 84 85type Node = 86| ( 87& { readonly type :"string" ; readonly const ?:string ; readonly enum ?:readonly string [] } 88& StringOptions 89) 90| ( 91& { readonly type :"integer" ; readonly const ?:number ; readonly enum ?:readonly number [] } 92& IntegerOptions 93) 94| { readonly type :"boolean" ; readonly const ?:boolean ; readonly description ?:string } 95| ({ readonly type :"bytes" } & LengthOptions ) 96| { readonly type :"cid-link" ; readonly description ?:string } 97| ({ readonly type :"blob" } & BlobOptions ) 98| ({ readonly type :"array" ; readonly items :Schema } & LengthOptions ) 99| { readonly type :"object" ; readonly properties :Shape ; readonly description ?:string } 100| { 101readonly type :"ref" ; 102readonly id :string ; 103readonly target :Schema ; 104readonly description ?:string ; 105} 106| { 107readonly type :"union" ; 108readonly variants :Variants ; 109readonly closed :boolean ; 110readonly namespace ?:string ; 111readonly description ?:string ; 112} 113| { 114readonly type :"record" ; 115readonly id :string ; 116readonly key :RecordKey ; 117readonly record :ObjectSchema ; 118readonly description ?:string ; 119}; 120 121export type ObjectSchema < S extends Shape = Shape > = 122& Schema < ObjectValue < S >, ObjectValue < S , typeof input >> 123& { 124readonly type :"object" ; 125readonly properties :S ; 126}; 127export type RecordKey = "tid" | "any" | `literal:${string } `; 128 129const validators = new WeakMap < object , ( value :unknown ) => ValidationResult < unknown >>(); 130const schemaPrototype = { 131validate < T > ( this :Schema < T >, value :unknown ) :ValidationResult < T > { 132const check = validators . get ( this ) ?? compile ( this ); 133validators . set ( this , check ); 134return check ( value ) as ValidationResult < T >; 135}, 136}; 137const unionPrototype = Object . setPrototypeOf ({ 138build ( this :Schema , value :unknown ) { 139return expand ( this , value ); 140}, 141}, schemaPrototype ); 142const recordPrototype = Object . setPrototypeOf ({ 143build ( this :Schema , fields :Record < string , unknown >) { 144if ( Object . hasOwn ( fields , "$type" )) throw new Error ( "Record fields must not include $type" ); 145return expand ( this , fields ); 146}, 147}, schemaPrototype ); 148 149function schema < S extends Node > ( node :S ) :S & typeof schemaPrototype { 150return Object . setPrototypeOf ( node , schemaPrototype ); 151} 152 153export function withDescription < S extends Schema > ( source :S , description :string ) :S { 154return Object . create ( Object . getPrototypeOf ( source ), { 155 ...Object . getOwnPropertyDescriptors ( source ), 156description :{ value :description , enumerable :true , configurable :true , writable :true }, 157}); 158} 159 160function bounds ( min :number | undefined , max :number | undefined , nonnegative = true ) :void { 161for ( const n of [ min , max ]) { 162if ( n !== undefined && ( ! Number . isSafeInteger ( n ) || ( nonnegative && n < 0 ))) { 163throw new Error ( "Bounds must be safe integers" + ( nonnegative ?" >= 0" :"" )); 164} 165} 166if ( min !== undefined && max !== undefined && min > max ) { 167throw new Error ( "Minimum must not exceed maximum" ); 168} 169} 170 171export type StringSchema < T extends string = string > = Schema < T > & { readonly type :"string" }; 172export type IntegerSchema = Schema < number > & { readonly type :"integer" }; 173 174export function stringWith ( options :StringOptions = {}) :StringSchema { 175bounds ( options . minLength , options . maxLength ); 176bounds ( options . minGraphemes , options . maxGraphemes ); 177return schema ({ ...options , type :"string" }); 178} 179 180export function integerWith ( options :IntegerOptions = {}) :IntegerSchema { 181bounds ( options . minimum , options . maximum , false ); 182return schema ({ ...options , type :"integer" }); 183} 184 185// Exports carry explicit types: JSR's fast check can't follow `typeof` between them. 186export const string :StringSchema = stringWith (); 187export const integer :IntegerSchema = integerWith (); 188export const boolean :Schema < boolean > = schema ({ type :"boolean" }); 189export const cidLink :Schema < CidLink > = schema ({ type :"cid-link" }); 190export const did :StringSchema < `did:${string } `> = schema ({ type :"string" , format :"did" }); 191export const handle :StringSchema = stringWith ({ format :"handle" }); 192export const atIdentifier :StringSchema = stringWith ({ format :"at-identifier" }); 193export const atUri :StringSchema < `at://${string } `> = schema ({ type :"string" , format :"at-uri" }); 194export const datetime :StringSchema = stringWith ({ format :"datetime" }); 195export const uri :StringSchema = stringWith ({ format :"uri" }); 196export const language :StringSchema = stringWith ({ format :"language" }); 197export const cid :StringSchema = stringWith ({ format :"cid" }); 198export const nsid :StringSchema = stringWith ({ format :"nsid" }); 199export const tid :StringSchema = stringWith ({ format :"tid" , minLength :13 , maxLength :13 }); 200 201export function bytesWith ( options :LengthOptions = {}) :Schema < BytesValue > { 202bounds ( options . minLength , options . maxLength ); 203return schema ({ ...options , type :"bytes" }); 204} 205export const bytes :Schema < BytesValue > = bytesWith (); 206 207export function literal < const T extends string | number | boolean > ( value :T ) :Schema < T > { 208if ( typeof value === "string" ) return schema ({ type :"string" , const :value }); 209if ( typeof value === "boolean" ) return schema ({ type :"boolean" , const :value }); 210if ( ! Number . isSafeInteger ( value )) throw new Error ( "Numeric literals must be safe integers" ); 211return schema ({ type :"integer" , const :value }); 212} 213 214export function enumValues < const T extends readonly [ string , ...string []] > ( 215 ...values :T 216) :Schema < T [ number ]> { 217return schema ({ type :"string" , enum :[ ...values ] }); 218} 219 220export function blob ( options :BlobOptions = {}) :Schema < BlobValue > { 221bounds ( undefined , options . maxSize ); 222if ( 223options . accept ?. some (( mime ) => ! /^(?:\*\/\*|[\w!#$&^.+-]+\/(?:[\w!#$&^.+-]+|\*))$ / . test ( mime )) 224) { 225throw new Error ( "Invalid blob MIME pattern" ); 226} 227return schema ({ 228 ...options , 229 ...( options . accept && { accept :[ ...options . accept ] }), 230type :"blob" , 231}); 232} 233 234export function array < S extends Schema > ( 235items :S , 236options :LengthOptions = {}, 237) :Schema < readonly Infer < S >[], readonly Input < S >[]> { 238bounds ( options . minLength , options . maxLength ); 239return schema ({ ...options , type :"array" , items}); 240} 241 242export function optional < S extends Schema | NullableField > ( 243inner :S , 244) :OptionalField < Infer < S >, Input < S >> { 245return { type :"optional" , inner}; 246} 247 248export function nullable < S extends Schema > ( inner :S ) :NullableField < Infer < S >, Input < S >> { 249return { type :"nullable" , inner}; 250} 251 252export function object < const S extends Shape > ( 253properties :S , 254options :{ readonly description ?:string } = {}, 255) :ObjectSchema < S > { 256if ( Object . keys ( properties ). some (( key ) => key . startsWith ( "$" ))) { 257throw new Error ( "$-prefixed fields are reserved; records and unions supply $type" ); 258} 259return schema ({ ...options , type :"object" , properties :{ ...properties } }); 260} 261 262export function definitionId ( id :string ) :{ nsid :string ; name :string } { 263const [ nsid , name = "main" , extra ] = id . split ( "#" ); 264if ( ! nsid || ! isNsid ( nsid ) || extra !== undefined || ! /^[A-Za-z][A-Za-z0-9]*$ / . test ( name )) { 265throw new Error ( `Invalid definition ID: ${ id } ` ); 266} 267if ( id . endsWith ( "#main" )) throw new Error ( "Use the bare NSID instead of #main" ); 268return { nsid, name}; 269} 270 271export type NamedSchema < Id extends string = string , T = unknown , I = T > = 272& Validated < T , I > 273& { 274readonly type :"ref" ; 275readonly id :Id ; 276readonly target :Schema < T , I >; 277readonly description ?:string ; 278}; 279 280export function named < S extends Schema , const Id extends string = string > ( 281id :Id , 282target :S | (() => S ), 283) :NamedSchema < Id , Infer < S >, Input < S >> & { readonly target :S } { 284definitionId ( id ); 285let resolved :S | undefined ; 286let resolving = false ; 287const ref = schema ({ 288type :"ref" , 289 id, 290get target () :S { 291if ( resolved ) return resolved ; 292if ( resolving ) throw new Error ( `Circular definition factory: ${ id } ` ); 293resolving = true ; 294try { 295const value = typeof target === "function" ?target () :target ; 296if ( value . type === "ref" || value . type === "union" || value . type === "record" ) { 297throw new Error ( "Only concrete definitions can be named; records already have a name" ); 298} 299return resolved = value ; 300} finally { 301resolving = false ; 302} 303}, 304}); 305if ( typeof target !== "function" ) void ref . target ; 306return ref as NamedSchema < Id , Infer < S >, Input < S >> & { readonly target :S }; 307} 308 309type Variants = Readonly < Record < string , Schema >>; 310type ObjectVariants = Readonly < Record < string , ObjectSchema >>; 311type NamedVariants < V extends readonly NamedSchema [] > = { 312readonly [ S in V [ number ] as S [ "id" ]] :S [ "target" ]; 313}; 314type UnionValue < 315V extends Variants , 316C extends Channel = typeof output , 317Base extends string = never , 318> = { 319[ K in keyof V & string ] :Simplify < 320& { readonly $type :K | ( K extends `${Base } #${inferF } ` ? `#${F } ` :never ) } 321& Read < V [ K ], C > 322>; 323}[ keyof V & string ]; 324type UnionOptions < Closed extends boolean > = { 325readonly closed ?:Closed ; 326readonly description ?:string ; 327}; 328export type UnknownVariant = { readonly $type :string ; readonly [ key :string ] :unknown }; 329type Open < Closed extends boolean > = Closed extends true ?never :UnknownVariant ; 330export type UnionSchema < 331V extends Variants , 332Closed extends boolean = true , 333Base extends string = never , 334> = 335& Validated < UnionValue < V > | Open < Closed >, UnionValue < V , typeof input , Base > | Open < Closed >> 336& { 337readonly type :"union" ; 338readonly variants :V ; 339readonly closed :Closed ; 340readonly description ?:string ; 341build ( value :UnionValue < V , typeof input , Base > | Open < Closed >) :UnionValue < V > | Open < Closed >; 342}; 343 344// Variants of a namespaced union are declared as "#name" and stored under their full ID. 345function namespacedVariants ( namespace :string , variants :ObjectVariants ) :Variants { 346if ( ! isNsid ( namespace )) throw new Error ( `Invalid union namespace: ${ namespace } ` ); 347return Object . fromEntries ( 348Object . entries ( variants ). map (([ key , variant ]) => { 349if ( ! key . startsWith ( "#" )) throw new Error ( "Union variant names must start with #" ); 350return [ namespace + key , variant ]; 351}), 352); 353} 354 355// Resolving targets here would re-enter a definition that includes itself in a union, so 356// each variant resolves on first access. 357function namedVariants ( refs :readonly NamedSchema []) :Variants { 358const variants :Record < string , Schema > = {}; 359for ( const ref of refs ) { 360if ( ref . type !== "ref" ) throw new Error ( "Union arrays must contain named definitions" ); 361if ( Object . hasOwn ( variants , ref . id )) throw new Error ( "Duplicate union variant" ); 362Object . defineProperty ( variants , ref . id , { 363enumerable :true , 364get () { 365const target = ref . target ; 366if ( target . type !== "object" ) throw new Error ( `Union variant ${ ref . id } must be an object` ); 367return target ; 368}, 369}); 370} 371return variants ; 372} 373 374const isNamedList = ( value :unknown ) :value isreadonly NamedSchema [] => Array . isArray ( value ); 375 376function unionOf ( variants :Variants , options :UnionOptions < boolean >, namespace ?:string ) :Schema { 377const keys = Object . keys ( variants ); 378if ( keys . length === 0 && options . closed !== false ) { 379throw new Error ( "A closed union cannot be empty" ); 380} 381for ( const key of keys ) definitionId ( key ); 382return Object . setPrototypeOf ({ 383 ...options , 384type :"union" , 385 variants, 386closed :options . closed ?? true , 387 ...( namespace !== undefined && { namespace}), 388}, unionPrototype ); 389} 390 391export function union < 392const V extends readonly NamedSchema [], 393const Closed extends boolean = true , 394> ( 395variants :V , 396options ?:UnionOptions < Closed >, 397) :UnionSchema < NamedVariants < V >, Closed >; 398export function union < const V extends ObjectVariants , const Closed extends boolean = true > ( 399variants :V , 400options ?:UnionOptions < Closed >, 401) :UnionSchema < V , Closed >; 402export function union < 403const Id extends string , 404const V extends Readonly < Record < `#${string } `, ObjectSchema >>, 405const Closed extends boolean = true , 406> ( 407id :Id , 408variants :V , 409options ?:UnionOptions < Closed >, 410) :UnionSchema <{ readonly [ K in keyof V & `#${string } `as `${Id } ${K } `] :V [ K ] }, Closed , Id >; 411export function union ( 412idOrVariants :string | ObjectVariants | readonly NamedSchema [], 413variantsOrOptions :ObjectVariants | UnionOptions < boolean > = {}, 414options :UnionOptions < boolean > = {}, 415) :Schema { 416if ( typeof idOrVariants === "string" ) { 417const variants = namespacedVariants ( idOrVariants , variantsOrOptions as ObjectVariants ); 418return unionOf ( variants , options , idOrVariants ); 419} 420const variants = isNamedList ( idOrVariants ) ?namedVariants ( idOrVariants ) :{ ...idOrVariants }; 421return unionOf ( variants , variantsOrOptions as UnionOptions < boolean >); 422} 423 424export type RecordSchema < Id extends string , S extends Shape > = 425& Schema < 426Simplify <{ readonly $type :Id } & ObjectValue < S >>, 427Simplify <{ readonly $type ?:Id } & ObjectValue < S , typeof input >> 428> 429& { 430readonly type :"record" ; 431readonly id :Id ; 432readonly record :ObjectSchema < S >; 433build ( fields :ObjectValue < S , typeof input > & { readonly $type ?:never }) :Simplify < 434{ readonly $type :Id } & ObjectValue < S > 435>; 436}; 437 438export function record < const Id extends string , const S extends Shape > ( 439id :Id , 440options :{ readonly key :RecordKey ; readonly description ?:string }, 441properties :S , 442) :RecordSchema < Id , S > { 443if ( ! isNsid ( id )) throw new Error ( `Invalid record NSID: ${ id } ` ); 444if ( 445options . key !== "tid" && options . key !== "any" && 446! /^literal:[A-Za-z0-9_~.:-]{1,512}$ / . test ( options . key ) 447) { 448throw new Error ( "Invalid record key policy" ); 449} 450if ( options . key === "literal:." || options . key === "literal:.." ) { 451throw new Error ( "Invalid literal record key" ); 452} 453return Object . setPrototypeOf ( 454{ ...options , type :"record" , id, record :object ( properties ) }, 455recordPrototype , 456); 457}