cerulea/lexicon
define atproto schemas in TypeScript
git clone https://git.t4t.associates/cerulea/lexicon
2d07e5f
main
1import * as l from "../mod.ts" ; 2import { assert , equal , throws } from "./assert.ts" ; 3 4Deno . test ( "named accepts eager or memoized lazy definitions without losing descriptions" , () => { 5let calls = 0 ; 6const Lazy = l . named ( "blue.cerulea.app.defs#holder" , () => { 7calls ++ ; 8return l . object ({ item :Item }); 9}); 10const Described = l . withDescription ( Lazy , "A holder." ); 11const Item = l . named ( 12"blue.cerulea.app.defs#item" , 13l . object ({ text :l . string }), 14); 15assert ( calls === 0 ); 16const Eager = l . named ( 17"blue.cerulea.app.defs#holder" , 18l . object ({ item :Item }), 19); 20const value = { item :{ text :"hello" } }; 21assert ( Described . validate ( value ). success ); 22assert ( Lazy . validate ( value ). success ); 23equal ( l . toLexicons ( Lazy ), l . toLexicons ( Eager )); 24equal ( calls , 1 ); 25equal ( Described . description , "A holder." ); 26equal ( Lazy . description , undefined ); 27const Container = l . named ( 28"blue.cerulea.app.defs#container" , 29l . object ({ holder :Described }), 30); 31equal ( l . toLexicons ( Container )[ 0 ] ! . defs . container ! . properties , { 32holder :{ type :"ref" , ref :Lazy . id , description :"A holder." }, 33}); 34}); 35 36Deno . test ( "recursive objects emit refs, validate nested paths, and share untouched builder values" , () => { 37type Tree = { 38readonly text :string ; 39readonly children ?:readonly Tree [] | null ; 40}; 41const Tree :l . NamedSchema < "blue.cerulea.app.defs#tree" , Tree > = l . named ( 42"blue.cerulea.app.defs#tree" , 43() => 44l . object ({ 45text :l . string , 46children :l . optional ( l . nullable ( l . array ( Tree ))), 47}), 48); 49const Holder = l . record ( "blue.cerulea.app.tree" , { key :"tid" }, { 50tree :Tree , 51}); 52const leaf :Tree = { text :"leaf" , children :null }; 53const tree = { 54text :"root" , 55children :[ leaf , leaf , { text :"branch" , children :[ leaf ] }], 56}; 57const original = structuredClone ( tree ); 58assert ( Tree . validate ( tree ). success ); 59assert ( Holder . build ({ tree}). tree === tree ); 60assert ( Holder . validate ( Holder . build ({ tree})). success ); 61equal ( tree , original ); 62const bad = Tree . validate ({ 63text :"root" , 64children :[{ text :"branch" , children :[{ text :1 }] }], 65}); 66assert ( ! bad . success ); 67equal ( bad . issues . map (( issue ) => issue . path ), [[ 68"children" , 690 , 70"children" , 710 , 72"text" , 73]]); 74equal ( l . toLexicons ( Tree ), [{ 75lexicon :1 , 76id :"blue.cerulea.app.defs" , 77defs :{ 78tree :{ 79type :"object" , 80properties :{ 81text :{ type :"string" }, 82children :{ type :"array" , items :{ type :"ref" , ref :Tree . id } }, 83}, 84required :[ "text" ], 85nullable :[ "children" ], 86}, 87}, 88}]); 89equal ( l . toLexicons ( Holder , Tree ), l . toLexicons ( Holder )); 90}); 91 92Deno . test ( "unions of named definitions support recursive variants and reuse ordinary refs" , () => { 93type Thread = { readonly text :string ; readonly replies ?:readonly Reply [] }; 94type Reply = 95| ({ readonly $type :"app.bsky.feed.defs#threadViewPost" } & Thread ) 96| { 97readonly $type :"app.bsky.feed.defs#notFoundPost" ; 98readonly uri : `at://${string } `; 99}; 100const Thread :l . NamedSchema < "app.bsky.feed.defs#threadViewPost" , Thread > = l 101. named ( 102"app.bsky.feed.defs#threadViewPost" , 103() => 104l . object ({ 105text :l . string , 106replies :l . optional ( l . array ( l . union ([ Thread , Missing ]))), 107}), 108); 109const Missing = l . named ( 110"app.bsky.feed.defs#notFoundPost" , 111l . object ({ uri :l . atUri }), 112); 113const Reply = l . union ([ Thread , Missing ]); 114const value :l . Input < typeof Reply > = { 115$type :Thread . id , 116text :"root" , 117replies :[{ 118$type :Thread . id , 119text :"child" , 120replies :[{ 121$type :Missing . id , 122uri :"at://did:plc:alice/app.bsky.feed.post/abc" , 123}], 124}], 125}; 126const built = Reply . build ( value ); 127equal ( built , value ); 128assert ( Reply . validate ( built ). success ); 129const bad = Reply . validate ({ 130 ...value , 131replies :[{ 132$type :Thread . id , 133text :"child" , 134replies :[{ $type :Missing . id , uri :"bad" }], 135}], 136}); 137assert ( ! bad . success ); 138equal ( bad . issues . map (( issue ) => issue . path ), [[ 139"replies" , 1400 , 141"replies" , 1420 , 143"uri" , 144]]); 145assert ( 146! Reply . validate ({ ...value , $type :"app.bsky.feed.defs#other" }). success , 147); 148assert ( 149l . union ([ Thread , Missing ], { closed :false }). validate ({ 150$type :"app.bsky.feed.defs#future" , 151}). success , 152); 153const docs = l . toLexicons ( Reply , Thread , Missing ); 154equal ( docs , l . toLexicons ( Missing , Thread )); 155equal ( docs , [{ 156lexicon :1 , 157id :"app.bsky.feed.defs" , 158defs :{ 159notFoundPost :{ 160type :"object" , 161properties :{ uri :{ type :"string" , format :"at-uri" } }, 162required :[ "uri" ], 163}, 164threadViewPost :{ 165type :"object" , 166properties :{ 167text :{ type :"string" }, 168replies :{ 169type :"array" , 170items :{ 171type :"union" , 172refs :[ Missing . id , Thread . id ], 173closed :true , 174}, 175}, 176}, 177required :[ "text" ], 178}, 179}, 180}]); 181}); 182 183Deno . test ( "builders expand unions and records throughout mutually recursive definitions" , () => { 184const Payload = l . union ( "blue.cerulea.app.payload" , { 185"#text" :l . object ({ text :l . string }), 186}); 187const Note = l . record ( "blue.cerulea.app.note" , { key :"tid" }, { 188text :l . string , 189}); 190type A = { 191readonly b ?:B ; 192readonly payload :l . Infer < typeof Payload >; 193readonly note :l . Infer < typeof Note >; 194}; 195type B = { readonly a ?:A }; 196type AInput = { 197readonly b ?:BInput ; 198readonly payload :l . Input < typeof Payload >; 199readonly note :l . Input < typeof Note >; 200}; 201type BInput = { readonly a ?:AInput }; 202const A :l . NamedSchema < "blue.cerulea.app.a#view" , A , AInput > = l . named ( 203"blue.cerulea.app.a#view" , 204() => l . object ({ b :l . optional ( B ), payload :Payload , note :Note }), 205); 206const B :l . NamedSchema < "blue.cerulea.app.b#view" , B , BInput > = l . named ( 207"blue.cerulea.app.b#view" , 208() => l . object ({ a :l . optional ( A ) }), 209); 210const Holder = l . record ( "blue.cerulea.app.holder" , { key :"tid" }, { 211a :A , 212b :B , 213}); 214const payload = { $type :"#text" , text :"hello" } as const ; 215const note = { text :"note" }; 216const leaf = { payload, note}; 217const input = { 218a :{ b :{ a :leaf }, ...leaf }, 219b :{ a :{ b :{ a :leaf }, ...leaf } }, 220}; 221const original = structuredClone ( input ); 222const expanded = { 223payload :{ $type :"blue.cerulea.app.payload#text" , text :"hello" }, 224note :{ $type :"blue.cerulea.app.note" , text :"note" }, 225}; 226equal ( Payload . build ( payload ), expanded . payload ); 227equal ( Note . build ( note ), expanded . note ); 228const expected = { 229$type :Holder . id , 230a :{ b :{ a :expanded }, ...expanded }, 231b :{ a :{ b :{ a :expanded }, ...expanded } }, 232}; 233equal ( Holder . build ( input ), expected ); 234equal ( Holder . build ( input ), expected ); 235equal ( Payload . build ( payload ), expanded . payload ); 236equal ( Note . build ( note ), expanded . note ); 237assert ( Holder . validate ( expected ). success ); 238equal ( input , original ); 239equal ( l . toLexicons ( A , B , Holder ), l . toLexicons ( Holder )); 240const docs = l . toLexicons ( A ); 241equal ( 242docs . find (( doc ) => doc . id === "blue.cerulea.app.b" ) ! . defs . view ! . properties , 243{ 244a :{ type :"ref" , ref :A . id }, 245}, 246); 247}); 248 249Deno . test ( "lazy definitions can lead back to an enclosing record" , () => { 250type Tree = { 251readonly $type :"blue.cerulea.app.tree" ; 252readonly text :string ; 253readonly branch ?:Branch ; 254}; 255type Branch = { readonly tree ?:Tree }; 256type TreeInput = { 257readonly $type ?:"blue.cerulea.app.tree" ; 258readonly text :string ; 259readonly branch ?:BranchInput ; 260}; 261type BranchInput = { readonly tree ?:TreeInput }; 262const Branch :l . NamedSchema < "blue.cerulea.app.tree#branch" , Branch , BranchInput > = l . named ( 263"blue.cerulea.app.tree#branch" , 264() => l . object ({ tree :l . optional ( Tree ) }), 265); 266const Tree = l . record ( "blue.cerulea.app.tree" , { key :"tid" }, { 267text :l . string , 268branch :l . optional ( Branch ), 269}); 270const built = Tree . build ({ text :"root" , branch :{ tree :{ text :"leaf" } } }); 271equal ( built , { 272$type :Tree . id , 273text :"root" , 274branch :{ tree :{ $type :Tree . id , text :"leaf" } }, 275}); 276assert ( Tree . validate ( built ). success ); 277const [ doc ] = l . toLexicons ( Tree ); 278equal ( Object . keys ( doc ! . defs ), [ "branch" , "main" ]); 279equal ( doc ! . defs . branch ! . properties , { tree :{ type :"ref" , ref :Tree . id } }); 280equal ( doc ! . defs . main ! . record , { 281type :"object" , 282properties :{ text :{ type :"string" }, branch :{ type :"ref" , ref :Branch . id } }, 283required :[ "text" ], 284}); 285}); 286 287Deno . test ( "recursive array definitions and lazy RPC bodies retain their schemas" , () => { 288type Nested = readonly Nested []; 289const Nested :l . NamedSchema < "blue.cerulea.app.defs#nested" , Nested > = l . named ( 290"blue.cerulea.app.defs#nested" , 291() => l . array ( Nested ), 292); 293assert ( Nested . validate ([[], [[[]]]]). success ); 294const bad = Nested . validate ([[ 1 ]]); 295assert ( ! bad . success ); 296equal ( bad . issues . map (( issue ) => issue . path ), [[ 0 , 0 ]]); 297equal ( l . toLexicons ( Nested )[ 0 ] ! . defs . nested , { 298type :"array" , 299items :{ type :"ref" , ref :Nested . id }, 300}); 301const Body = l . named ( 302"blue.cerulea.app.defs#body" , 303() => l . object ({ nested :Nested }), 304); 305const endpoint = l . procedure ( "blue.cerulea.app.echo" , { 306input :Body , 307output :Body , 308}); 309assert ( endpoint . input . validate ({ nested :[[]] }). success ); 310equal ( 311l . toLexicons ( endpoint ). find (( doc ) => doc . id === endpoint . id ) ! . defs . main ! 312. output , 313{ 314encoding :"application/json" , 315schema :{ type :"ref" , ref :Body . id }, 316}, 317); 318throws ( 319() => l . query ( "blue.cerulea.app.read" , { output :Nested }), 320"RPC bodies" , 321); 322}); 323 324Deno . test ( "recursive definition deduplication does not hide conflicting IDs" , () => { 325type Link < T > = { readonly value :T ; readonly next ?:Link < T > }; 326const id = "blue.cerulea.app.defs#link" ; 327const A :l . NamedSchema < typeof id , Link < string >> = l . named ( 328id , 329() => l . object ({ value :l . string , next :l . optional ( A ) }), 330); 331const Same :l . NamedSchema < typeof id , Link < string >> = l . named ( 332id , 333() => l . object ({ value :l . string , next :l . optional ( Same ) }), 334); 335const Different :l . NamedSchema < typeof id , Link < number >> = l . named ( 336id , 337() => l . object ({ value :l . integer , next :l . optional ( Different ) }), 338); 339equal ( l . toLexicons ( A , Same ), l . toLexicons ( A )); 340throws (() => l . toLexicons ( A , Different ), `Conflicting definition: ${ id } ` ); 341throws (() => l . toLexicons ( Different , A ), `Conflicting definition: ${ id } ` ); 342const NestedConflict :l . NamedSchema = l . named ( 343id , 344() => l . object ({ next :Different }), 345); 346throws (() => l . toLexicons ( NestedConflict ), `Conflicting definition: ${ id } ` ); 347}); 348 349Deno . test ( "lazy targets enforce concrete definitions and failed resolution can be retried" , () => { 350let available = false ; 351const Retry = l . named ( "blue.cerulea.app.defs#retry" , () => { 352if ( ! available ) throw new Error ( "Not ready" ); 353return l . object ({ text :l . string }); 354}); 355const Holder = l . record ( "blue.cerulea.app.retry" , { key :"tid" }, { 356item :Retry , 357}); 358throws (() => Holder . build ({ item :{ text :"hi" } }), "Not ready" ); 359throws (() => Retry . validate ({}), "Not ready" ); 360throws (() => l . toLexicons ( Retry ), "Not ready" ); 361available = true ; 362assert ( Holder . validate ( Holder . build ({ item :{ text :"hi" } })). success ); 363assert ( l . toLexicons ( Retry ). length === 1 ); 364for ( 365const target of [ 366Retry , 367l . union ({ "blue.cerulea.app.defs#empty" :l . object ({}) }), 368Holder , 369] 370) { 371throws ( 372() => l . named ( "blue.cerulea.app.defs#invalid" , target ), 373"concrete definitions" , 374); 375const Lazy = l . named ( "blue.cerulea.app.defs#invalid" , () => target ); 376throws (() => l . compile ( Lazy ), "concrete definitions" ); 377throws (() => l . toLexicons ( Lazy ), "concrete definitions" ); 378} 379const Reentrant :l . NamedSchema = l . named ( 380"blue.cerulea.app.defs#reentrant" , 381() => Reentrant . target , 382); 383throws (() => l . toLexicons ( Reentrant ), "Circular definition factory" ); 384throws (() => l . compile ( Reentrant ), "Circular definition factory" ); 385throws (() => l . named ( "invalid" , () => l . string ), "definition ID" ); 386}); 387 388Deno . test ( "named union variants must resolve to objects and have unique IDs" , () => { 389const Text = l . named ( "blue.cerulea.app.defs#text" , () => l . string ); 390const Union = l . union ([ Text ]); 391throws (() => l . compile ( Union ), "must be an object" ); 392throws (() => l . toLexicons ( Union ), "must be an object" ); 393throws (() => Union . build ({ $type :Text . id } as never ), "must be an object" ); 394const Object = l . named ( "blue.cerulea.app.defs#object" , l . object ({})); 395throws (() => l . union ([ Object , Object ]), "Duplicate union variant" ); 396throws (() => l . union ([]), "empty" ); 397assert ( 398l . union ([], { closed :false }). validate ({ 399$type :"blue.cerulea.app.defs#future" , 400}). success , 401); 402});