cerulea/lexicon
define atproto schemas in TypeScript
git clone https://git.t4t.associates/cerulea/lexicon
1945441
main
1import * as l from "../mod.ts" ; 2import { assert , equal , throws } from "./assert.ts" ; 3 4const range = { start :l . integerWith ({ minimum :0 }), end :l . integerWith ({ minimum :0 }) }; 5const Facet = l . union ( "blue.cerulea.app.facet" , { 6"#mention" :l . object ({ ...range , did :l . did }), 7"#link" :l . object ({ ...range , uri :l . uri }), 8}); 9const Post = l . record ( "blue.cerulea.app.post" , { key :"tid" }, { 10text :l . string , 11facets :l . array ( Facet ), 12createdAt :l . optional ( l . datetime ), 13}); 14const post = { 15$type :"blue.cerulea.app.post" , 16text :"🙂 hi" , 17facets :[{ $type :"blue.cerulea.app.facet#mention" , start :3 , end :5 , did :"did:plc:alice" }], 18}; 19const cid = "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ; 20 21Deno . test ( "records and discriminated variants validate without mutating data" , () => { 22const check = l . compile ( Post ); 23const input = structuredClone ( post ); 24const result = check ( input ); 25assert ( result . success ); 26assert ( result . value === input ); 27equal ( input , post ); 28assert ( check ({ ...post , extra :{ future :true } }). success ); 29assert ( ! check ({ ...post , $type :"blue.cerulea.app.other" }). success ); 30assert ( ! check ({ text :"" , facets :[] }). success ); 31}); 32 33Deno . test ( "record builder adds the discriminator without mutating fields" , () => { 34const fields = { text :"hello" , facets :[] }; 35const built = Post . build ( fields ); 36equal ( built , { ...fields , $type :Post . id }); 37assert ( ! Object . hasOwn ( fields , "$type" )); 38assert ( Post . validate ( built ). success ); 39throws (() => Post . build ({ ...fields , $type :Post . id } as never ), "$type" ); 40const described = l . withDescription ( Post , "A post." ); 41assert ( described . build ( fields ). $type === Post . id ); 42assert ( described . validate ( built ). success ); 43}); 44 45Deno . test ( "builders expand short union variants and nested records throughout the value" , () => { 46const Embed = l . union ( "blue.cerulea.app.embed" , { 47"#facets" :l . object ({ facets :l . array ( Facet ) }), 48"#post" :l . object ({ post :Post }), 49}); 50const Holder = l . record ( "blue.cerulea.app.holder" , { key :"tid" }, { 51embeds :l . array ( Embed ), 52pin :l . optional ( l . nullable ( l . named ( "blue.cerulea.app.defs#pin" , l . object ({ facet :Facet })))), 53}); 54const stored = Post . validate ( post ); 55assert ( stored . success ); 56const link = { start :0 , end :1 , uri :"https://example.com" }; 57const fields = { 58embeds :[ 59{ $type :"#facets" , facets :[{ $type :"#link" , ...link }] }, 60{ $type :"#post" , post :{ text :"" , facets :[{ $type :"#link" , ...link }] } }, 61{ $type :"blue.cerulea.app.embed#post" , post :stored . value }, 62], 63pin :{ facet :{ $type :"#link" , ...link } }, 64} as const ; 65const original = structuredClone ( fields ); 66const built = Holder . build ( fields ); 67equal ( fields , original ); 68const facet = { $type :"blue.cerulea.app.facet#link" , ...link }; 69equal ( built , { 70$type :Holder . id , 71embeds :[ 72{ $type :"blue.cerulea.app.embed#facets" , facets :[ facet ] }, 73{ $type :"blue.cerulea.app.embed#post" , post :{ $type :Post . id , text :"" , facets :[ facet ] } }, 74{ $type :"blue.cerulea.app.embed#post" , post}, 75], 76pin :{ facet}, 77}); 78assert ( Holder . validate ( built ). success ); 79equal ( Holder . build ({ embeds :[], pin :null }), { $type :Holder . id , embeds :[], pin :null }); 80equal ( Facet . build ({ $type :"#link" , ...link }), facet ); 81}); 82 83Deno . test ( "builders reject discriminators they cannot expand" , () => { 84throws (() => Facet . build ({ $type :"#other" } as never ), "Unknown union variant #other at $type" ); 85throws (() => Post . build ({ text :"" , facets :[{ $type :"#other" }] } as never ), "facets.0.$type" ); 86const anonymous = l . union ({ "blue.cerulea.app.facet#link" :l . object ({}) }); 87throws (() => anonymous . build ({ $type :"#link" } as never ), "namespaced" ); 88const Holder = l . record ( "blue.cerulea.app.holder" , { key :"tid" }, { post :Post }); 89throws ( 90() => Holder . build ({ post :{ ...post , $type :"blue.cerulea.app.other" } } as never ), 91"Expected blue.cerulea.app.post at post.$type" , 92); 93const open = l . union ( "blue.cerulea.app.open" , { 94"#known" :l . object ({ facets :l . array ( Facet ) }), 95}, { closed :false }); 96const future = { $type :"other.example.defs#future" , facets :[{ $type :"#link" }] }; 97equal ( open . build ( future ), future ); 98throws (() => open . build ({ $type :"#future" }), "Unknown union variant" ); 99}); 100 101Deno . test ( "schema validation is bound to each schema, including derived descriptions" , () => { 102const text = l . stringWith ({ maxLength :3 }); 103const described = l . withDescription ( text , "A short string." ); 104assert ( described . validate ( "abc" ). success ); 105assert ( ! described . validate ( "abcd" ). success ); 106assert ( ! text . validate ( 42 ). success ); 107assert ( described . description === "A short string." ); 108assert ( text . description === undefined ); 109assert ( l . params ({ limit :l . integer }). validate ({ limit :3 }). success ); 110}); 111 112Deno . test ( "union validation selects only the declared discriminator" , () => { 113const check = l . compile ( Facet ); 114assert ( check ( post . facets [ 0 ]). success ); 115for ( 116const value of [ 117{ start :0 , end :1 , did :"did:plc:alice" }, 118{ $type :"blue.cerulea.app.facet#unknown" , start :0 , end :1 }, 119{ $type :"blue.cerulea.app.facet#mention" , start :0 , end :1 , uri :"https://example.com" }, 120{ $type :"blue.cerulea.app.facet#link" , start :0.5 , end :1 , uri :"https://example.com" }, 121] 122) assert ( ! check ( value ). success ); 123const bad = l . compile ( Post )({ ...post , facets :[{ ...post . facets [ 0 ], did :42 }] }); 124assert ( ! bad . success ); 125equal ( bad . issues . map (( i ) => i . path ), [[ "facets" , 0 , "did" ]]); 126}); 127 128Deno . test ( "Lexicon output declares variants and uses refs, not explicit $type fields" , () => { 129equal ( l . toLexicons ( Post ), [ 130{ 131lexicon :1 , 132id :"blue.cerulea.app.facet" , 133defs :{ 134link :{ 135type :"object" , 136properties :{ 137start :{ type :"integer" , minimum :0 }, 138end :{ type :"integer" , minimum :0 }, 139uri :{ type :"string" , format :"uri" }, 140}, 141required :[ "end" , "start" , "uri" ], 142}, 143mention :{ 144type :"object" , 145properties :{ 146start :{ type :"integer" , minimum :0 }, 147end :{ type :"integer" , minimum :0 }, 148did :{ type :"string" , format :"did" }, 149}, 150required :[ "did" , "end" , "start" ], 151}, 152}, 153}, 154{ 155lexicon :1 , 156id :"blue.cerulea.app.post" , 157defs :{ 158main :{ 159type :"record" , 160key :"tid" , 161record :{ 162type :"object" , 163properties :{ 164text :{ type :"string" }, 165facets :{ 166type :"array" , 167items :{ 168type :"union" , 169refs :[ "blue.cerulea.app.facet#link" , "blue.cerulea.app.facet#mention" ], 170closed :true , 171}, 172}, 173createdAt :{ type :"string" , format :"datetime" }, 174}, 175required :[ "facets" , "text" ], 176}, 177}, 178}, 179}, 180]); 181}); 182 183Deno . test ( "optional and nullable are independent and undefined means absent" , () => { 184const schema = l . named ( 185"blue.cerulea.app.defs#fields" , 186l . object ({ 187required :l . string , 188optional :l . optional ( l . string ), 189nullable :l . nullable ( l . string ), 190both :l . optional ( l . nullable ( l . string )), 191}), 192); 193const check = l . compile ( schema ); 194assert ( check ({ required :"" , nullable :null }). success ); 195assert ( check ({ required :"" , nullable :"" , optional :"" , both :null }). success ); 196assert ( ! check ({ required :"" }). success ); 197assert ( ! check ({ required :"" , nullable :null , optional :null }). success ); 198assert ( check ({ required :"" , nullable :null , optional :undefined , both :undefined }). success ); 199assert ( ! check ({ required :"" , nullable :undefined }). success ); 200const def = l . toLexicons ( schema )[ 0 ] ! . defs . fields ! ; 201equal ( def . required , [ "nullable" , "required" ]); 202equal ( def . nullable , [ "both" , "nullable" ]); 203}); 204 205Deno . test ( "open unions permit well-formed unknown variants but still validate known ones" , () => { 206const schema = l . union ( "blue.cerulea.app.open" , { 207"#known" :l . object ({ text :l . string }), 208}, { closed :false }); 209const check = l . compile ( schema ); 210assert ( check ({ $type :"other.example.defs#future" , value :[ true , null , 3 ] }). success ); 211assert ( ! check ({ $type :"blue.cerulea.app.open#known" , text :3 }). success ); 212assert ( ! check ({ $type :"future" , text :"" }). success ); 213assert ( check ({ $type :"other.example.defs#future" , value :0.5 }). success ); 214assert ( 215l . compile ( l . union ( "blue.cerulea.app.empty" , {}, { closed :false }))({ 216$type :"other.example.defs#future" , 217}). success , 218); 219}); 220 221Deno . test ( "named definitions are collected transitively, deduplicated, and ordered" , () => { 222const Ref = l . named ( "blue.cerulea.app.defs#strongRef" , l . object ({ uri :l . atUri , cid :l . cid })); 223const Reply = l . named ( "blue.cerulea.app.defs#reply" , l . object ({ root :Ref , parent :Ref })); 224const A = l . record ( "blue.cerulea.app.a" , { key :"tid" }, { reply :Reply }); 225const B = l . record ( "blue.cerulea.app.b" , { key :"literal:self" }, { pinned :Ref , record :A }); 226const docs = l . toLexicons ( A , B , Ref , Ref ); 227equal ( docs , l . toLexicons ( B , A )); 228equal ( docs . map (( d ) => d . id ), [ 229"blue.cerulea.app.a" , 230"blue.cerulea.app.b" , 231"blue.cerulea.app.defs" , 232]); 233equal ( Object . keys ( docs [ 2 ] ! . defs ), [ "reply" , "strongRef" ]); 234equal ( docs [ 2 ] ! . defs . reply , { 235type :"object" , 236properties :{ 237root :{ type :"ref" , ref :"blue.cerulea.app.defs#strongRef" }, 238parent :{ type :"ref" , ref :"blue.cerulea.app.defs#strongRef" }, 239}, 240required :[ "parent" , "root" ], 241}); 242}); 243 244Deno . test ( "conflicting definitions fail rather than overwrite each other" , () => { 245const a = l . named ( "blue.cerulea.app.defs#item" , l . object ({ a :l . string })); 246const same = l . named ( "blue.cerulea.app.defs#item" , l . object ({ a :l . string })); 247const b = l . named ( "blue.cerulea.app.defs#item" , l . object ({ b :l . integer })); 248equal ( l . toLexicons ( a , same ), l . toLexicons ( a )); 249throws (() => l . toLexicons ( a , b ), "Conflicting definition" ); 250}); 251 252Deno . test ( "unrepresentable and invalid definitions are rejected" , () => { 253throws (() => l . toLexicons ( l . object ({})), "roots" ); 254throws ( 255() => l . toLexicons ( l . record ( "blue.cerulea.app.post" , { key :"tid" }, { nested :l . object ({}) })), 256"Nested objects" , 257); 258throws ( 259() => l . toLexicons ( l . named ( "blue.cerulea.app.defs#matrix" , l . array ( l . array ( l . integer )))), 260"Nested arrays" , 261); 262throws (() => l . named ( "blue.cerulea.app.defs#union" , Facet ), "concrete definitions" ); 263throws (() => l . object ({ $type :l . string }), "$type" ); 264throws (() => l . union ( "blue.cerulea.app.facet" , {}), "empty" ); 265throws (() => l . union ( "bad" , { "#x" :l . object ({}) }), "namespace" ); 266throws (() => l . union ( "blue.cerulea.app.facet" , { "#main" :l . object ({}) }), "#main" ); 267throws (() => l . union ( "blue.cerulea.app.facet" , { "#bad-name" :l . object ({}) }), "definition ID" ); 268throws (() => l . named ( "blue.cerulea.app.defs#main" , l . string ), "#main" ); 269throws (() => l . record ( "blue.cerulea.app.post" , { key :"literal:.." }, {}), "record key" ); 270throws (() => l . stringWith ({ minLength :4 , maxLength :3 }), "Minimum" ); 271throws (() => l . integerWith ({ minimum :0.5 }), "safe integers" ); 272throws (() => l . array ( l . string , { maxLength :- 1 }), ">= 0" ); 273throws (() => l . literal ( NaN ), "safe integers" ); 274throws (() => l . blob ({ accept :[ "image/p*" ] }), "MIME pattern" ); 275}); 276 277Deno . test ( "string limits count UTF-8 bytes and graphemes, not UTF-16 indices" , () => { 278const check = l . compile ( l . stringWith ({ maxLength :4 , maxGraphemes :1 })); 279assert ( check ( "🙂" ). success ); 280assert ( check ( "e\u0301" ). success ); 281assert ( ! check ( "🙂a" ). success ); 282assert ( ! check ( "ab" ). success ); 283assert ( ! check ( "\ud800" ). success ); 284}); 285 286Deno . test ( "integer, literal, enum, array and byte constraints" , () => { 287const check = l . compile ( l . integerWith ({ minimum :- 1 , maximum :1 })); 288for ( const v of [ - 1 , 0 , 1 ]) assert ( check ( v ). success ); 289for ( const v of [ 2 , 0.5 , NaN , Infinity , Number . MAX_SAFE_INTEGER + 1 , "0" ]) { 290assert ( ! check ( v ). success ); 291} 292assert ( l . compile ( l . literal ( false ))( false ). success ); 293assert ( ! l . compile ( l . literal ( false ))( true ). success ); 294assert ( l . compile ( l . enumValues ( "one" , "two" ))( "two" ). success ); 295assert ( ! l . compile ( l . enumValues ( "one" , "two" ))( "three" ). success ); 296assert ( l . compile ( l . array ( l . boolean , { minLength :1 , maxLength :2 }))([ false ]). success ); 297assert ( ! l . compile ( l . array ( l . boolean , { minLength :1 }))([]). success ); 298assert ( ! l . compile ( l . array ( l . boolean , { maxLength :1 }))([ true , false ]). success ); 299assert ( ! l . compile ( l . array ( l . boolean ))( new Array ( 1 )). success ); 300assert ( l . compile ( l . bytesWith ({ minLength :1 , maxLength :2 }))({ $bytes :"AQI=" }). success ); 301assert ( ! l . compile ( l . bytesWith ({ maxLength :1 }))({ $bytes :"AQI=" }). success ); 302assert ( ! l . compile ( l . bytes )([ 1 ]). success ); 303}); 304 305Deno . test ( "blob refs enforce CID, size, and MIME constraints" , () => { 306const check = l . compile ( l . blob ({ accept :[ "image/*" ], maxSize :100 })); 307const value = { $type :"blob" , ref :{ $link :cid }, mimeType :"image/png" , size :100 }; 308assert ( check ( value ). success ); 309for ( 310const change of [ 311{ size :101 }, 312{ size :- 1 }, 313{ size :0 }, 314{ size :1.5 }, 315{ mimeType :"video/mp4" }, 316{ ref :{ $link :"bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } }, 317{ mimeType :"image/*" }, 318{ ref :{ $link :"not a CID" } }, 319{ $type :"other" }, 320] 321) assert ( ! check ({ ...value , ...change }). success ); 322const def = l . toLexicons ( 323l . named ( "blue.cerulea.app.defs#image" , l . blob ({ accept :[ "image/*" ], maxSize :100 })), 324)[ 0 ] ! . defs . image ; 325equal ( def , { type :"blob" , accept :[ "image/*" ], maxSize :100 }); 326}); 327 328Deno . test ( "extra fields are accepted whatever their value, for forward compatibility" , () => { 329const check = l . compile ( l . object ({ text :l . string })); 330const cyclic :Record < string , unknown > = { text :"" }; 331cyclic . extra = cyclic ; 332assert ( check ( cyclic ). success ); 333for ( 334const extra of [ undefined , 0.1 , new Date (), { $bytes :false }, { $link :"x" }, { 335$type :"blob" , 336}] 337) assert ( check ({ text :"" , extra}). success ); 338assert ( ! check ( Object . create ({ text :"inherited" })). success ); 339assert ( check ( Object . assign ( Object . create ( null ), { text :"" })). success ); 340}); 341 342Deno . test ( "prototype-looking property names are ordinary data" , () => { 343const properties = { [ "__proto__" ] :l . string , constructor :l . integer }; 344const schema = l . named ( "blue.cerulea.app.defs#safe" , l . object ( properties )); 345const value = JSON . parse ( '{"__proto__":"ok","constructor":1}' ); 346assert ( l . compile ( schema )( value ). success ); 347const doc = JSON . parse ( JSON . stringify ( l . toLexicons ( schema )))[ 0 ]; 348equal ( doc . defs . safe . properties . __proto__ , { type :"string" }); 349});