Suite of AT Protocol TypeScript libraries built on web standards
21
fork

Configure Feed

Select the types of activity you want to include in your feed.

lex data and schema

+4545
+99
data/blob.ts
··· 1 + import { CID, RAW_BIN_MULTICODEC, SHA2_256_MULTIHASH_CODE } from "./cid.ts"; 2 + import { isPlainObject } from "./object.ts"; 3 + 4 + export type BlobRef = { 5 + $type: "blob"; 6 + mimeType: string; 7 + ref: CID; 8 + size: number; 9 + }; 10 + 11 + export function isBlobRef( 12 + input: unknown, 13 + options?: { strict?: boolean }, 14 + ): input is BlobRef { 15 + if (!isPlainObject(input)) { 16 + return false; 17 + } 18 + 19 + if (input?.$type !== "blob") { 20 + return false; 21 + } 22 + 23 + const { mimeType, size, ref } = input; 24 + if (typeof mimeType !== "string") { 25 + return false; 26 + } 27 + 28 + if (typeof size !== "number" || size < 0 || !Number.isInteger(size)) { 29 + return false; 30 + } 31 + 32 + if (typeof ref !== "object" || ref === null) { 33 + return false; 34 + } 35 + 36 + for (const key in input) { 37 + if ( 38 + key !== "$type" && 39 + key !== "mimeType" && 40 + key !== "ref" && 41 + key !== "size" 42 + ) { 43 + return false; 44 + } 45 + } 46 + 47 + const cid = CID.asCID(ref); 48 + if (!cid) { 49 + return false; 50 + } 51 + 52 + if (options?.strict) { 53 + if (cid.version !== 1) { 54 + return false; 55 + } 56 + if (cid.code !== RAW_BIN_MULTICODEC) { 57 + return false; 58 + } 59 + if (cid.multihash.code !== SHA2_256_MULTIHASH_CODE) { 60 + return false; 61 + } 62 + } 63 + 64 + return true; 65 + } 66 + 67 + export type LegacyBlobRef = { 68 + cid: string; 69 + mimeType: string; 70 + }; 71 + 72 + export function isLegacyBlobRef(input: unknown): input is LegacyBlobRef { 73 + if (!isPlainObject(input)) { 74 + return false; 75 + } 76 + 77 + const { cid, mimeType } = input; 78 + if (typeof cid !== "string") { 79 + return false; 80 + } 81 + 82 + if (typeof mimeType !== "string") { 83 + return false; 84 + } 85 + 86 + for (const key in input) { 87 + if (key !== "cid" && key !== "mimeType") { 88 + return false; 89 + } 90 + } 91 + 92 + try { 93 + CID.parse(cid); 94 + } catch { 95 + return false; 96 + } 97 + 98 + return true; 99 + }
+50
data/cid.ts
··· 1 + import { CID } from "multiformats/cid"; 2 + 3 + export const DAG_CBOR_MULTICODEC = 0x71; 4 + export const RAW_BIN_MULTICODEC = 0x55; 5 + 6 + export const SHA2_256_MULTIHASH_CODE = 0x12; 7 + 8 + export { CID }; 9 + 10 + export function isCid( 11 + value: unknown, 12 + options?: { strict?: boolean }, 13 + ): value is CID { 14 + const cid = CID.asCID(value); 15 + if (!cid) { 16 + return false; 17 + } 18 + 19 + if (options?.strict) { 20 + if (cid.version !== 1) { 21 + return false; 22 + } 23 + if (cid.code !== RAW_BIN_MULTICODEC && cid.code !== DAG_CBOR_MULTICODEC) { 24 + return false; 25 + } 26 + if (cid.multihash.code !== SHA2_256_MULTIHASH_CODE) { 27 + return false; 28 + } 29 + } 30 + 31 + return true; 32 + } 33 + 34 + export function validateCidString(input: string): boolean { 35 + return parseCidString(input)?.toString() === input; 36 + } 37 + 38 + export function parseCidString(input: string): CID | undefined { 39 + try { 40 + return CID.parse(input); 41 + } catch { 42 + return undefined; 43 + } 44 + } 45 + 46 + export function ensureValidCidString(input: string): void { 47 + if (!validateCidString(input)) { 48 + throw new Error(`Invalid CID string`); 49 + } 50 + }
+9
data/deno.json
··· 1 + { 2 + "name": "@atp/data", 3 + "version": "0.1.0-alpha.1", 4 + "exports": "./mod.ts", 5 + "license": "MIT", 6 + "imports": { 7 + "multiformats": "npm:multiformats@^13.4.1" 8 + } 9 + }
+39
data/language.ts
··· 1 + const BCP47_REGEXP = 2 + /^((?<grandfathered>(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang))|((?<language>([A-Za-z]{2,3}(-(?<extlang>[A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-(?<script>[A-Za-z]{4}))?(-(?<region>[A-Za-z]{2}|[0-9]{3}))?(-(?<variant>[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-(?<extension>[0-9A-WY-Za-wy-z](-[A-Za-z0-9]{2,8})+))*(-(?<privateUseA>x(-[A-Za-z0-9]{1,8})+))?)|(?<privateUseB>x(-[A-Za-z0-9]{1,8})+))$/ 3 + 4 + export type LanguageTag = { 5 + grandfathered?: string 6 + language?: string 7 + extlang?: string 8 + script?: string 9 + region?: string 10 + variant?: string 11 + extension?: string 12 + privateUse?: string 13 + } 14 + 15 + export function parseLanguage(input: string): LanguageTag | null { 16 + const parsed = input.match(BCP47_REGEXP) 17 + if (!parsed?.groups) return null 18 + 19 + const { groups } = parsed 20 + return { 21 + grandfathered: groups.grandfathered, 22 + language: groups.language, 23 + extlang: groups.extlang, 24 + script: groups.script, 25 + region: groups.region, 26 + variant: groups.variant, 27 + extension: groups.extension, 28 + privateUse: groups.privateUseA || groups.privateUseB, 29 + } 30 + } 31 + 32 + /** 33 + * Validates well-formed BCP 47 syntax 34 + * 35 + * @see {@link https://www.rfc-editor.org/rfc/rfc5646.html#section-2.1} 36 + */ 37 + export function isLanguage(input: string): boolean { 38 + return BCP47_REGEXP.test(input) 39 + }
+160
data/lex.ts
··· 1 + import { CID, isCid } from "./cid.ts"; 2 + import { isPlainObject } from "./object.ts"; 3 + import { ui8Equals } from "./uint8array.ts"; 4 + 5 + // @NOTE BlobRef is just a special case of LexMap. 6 + 7 + export type LexScalar = number | string | boolean | null | CID | Uint8Array; 8 + export type LexValue = LexScalar | LexValue[] | { [_ in string]?: LexValue }; 9 + export type LexMap = { [_ in string]?: LexValue }; 10 + export type LexArray = LexValue[]; 11 + 12 + export function isLexMap(value: unknown): value is LexMap { 13 + if (!isPlainObject(value)) return false; 14 + for (const key in value) { 15 + if (!isLexValue(value[key])) return false; 16 + } 17 + return true; 18 + } 19 + 20 + export function isLexArray(value: unknown): value is LexArray { 21 + if (!Array.isArray(value)) return false; 22 + for (let i = 0; i < value.length; i++) { 23 + if (!isLexValue(value[i])) return false; 24 + } 25 + return true; 26 + } 27 + 28 + export function isLexScalar(value: unknown): value is LexScalar { 29 + switch (typeof value) { 30 + case "object": 31 + if (value === null) return true; 32 + return value instanceof Uint8Array || isCid(value); 33 + case "string": 34 + case "boolean": 35 + return true; 36 + case "number": 37 + if (Number.isInteger(value)) return true; 38 + throw new TypeError(`Invalid Lex value: ${value}`); 39 + default: 40 + throw new TypeError(`Invalid Lex value: ${typeof value}`); 41 + } 42 + } 43 + 44 + export function isLexValue(value: unknown): value is LexValue { 45 + switch (typeof value) { 46 + case "number": 47 + if (!Number.isInteger(value)) return false; 48 + // fallthrough 49 + case "string": 50 + case "boolean": 51 + return true; 52 + case "object": 53 + if (value === null) return true; 54 + if (Array.isArray(value)) { 55 + for (let i = 0; i < value.length; i++) { 56 + if (!isLexValue(value[i])) return false; 57 + } 58 + return true; 59 + } 60 + if (isPlainObject(value)) { 61 + for (const key in value) { 62 + if (!isLexValue(value[key])) return false; 63 + } 64 + return true; 65 + } 66 + if (value instanceof Uint8Array) return true; 67 + if (isCid(value)) return true; 68 + // fallthrough 69 + default: 70 + return false; 71 + } 72 + } 73 + 74 + export type TypedLexMap = LexMap & { $type: string }; 75 + export function isTypedLexMap(value: LexValue): value is TypedLexMap { 76 + return ( 77 + isLexMap(value) && typeof value.$type === "string" && value.$type.length > 0 78 + ); 79 + } 80 + 81 + export function lexEquals(a: LexValue, b: LexValue): boolean { 82 + if (Object.is(a, b)) { 83 + return true; 84 + } 85 + 86 + if ( 87 + a == null || 88 + b == null || 89 + typeof a !== "object" || 90 + typeof b !== "object" 91 + ) { 92 + return false; 93 + } 94 + 95 + if (Array.isArray(a)) { 96 + if (!Array.isArray(b)) { 97 + return false; 98 + } 99 + if (a.length !== b.length) { 100 + return false; 101 + } 102 + for (let i = 0; i < a.length; i++) { 103 + if (!lexEquals(a[i], b[i])) { 104 + return false; 105 + } 106 + } 107 + return true; 108 + } else if (Array.isArray(b)) { 109 + return false; 110 + } 111 + 112 + if (ArrayBuffer.isView(a)) { 113 + if (!ArrayBuffer.isView(b)) return false; 114 + return ui8Equals(a as Uint8Array, b as Uint8Array); 115 + } else if (ArrayBuffer.isView(b)) { 116 + return false; 117 + } 118 + 119 + if (isCid(a)) { 120 + // @NOTE CID.equals returns its argument when it is falsy (e.g. null or 121 + // undefined) so we need to explicitly check that the output is "true". 122 + return CID.asCID(a)!.equals(CID.asCID(b)) === true; 123 + } else if (isCid(b)) { 124 + return false; 125 + } 126 + 127 + if (!isPlainObject(a) || !isPlainObject(b)) { 128 + // Foolproof (should never happen) 129 + throw new TypeError( 130 + "Invalid LexValue (expected CID, Uint8Array, or LexMap)", 131 + ); 132 + } 133 + 134 + const aKeys = Object.keys(a); 135 + const bKeys = Object.keys(b); 136 + 137 + if (aKeys.length !== bKeys.length) { 138 + return false; 139 + } 140 + 141 + for (const key of aKeys) { 142 + const aVal = a[key]; 143 + const bVal = b[key]; 144 + 145 + // Needed because of the optional index signature in the Lex object type 146 + // though, in practice, aVal should never be undefined here. 147 + if (aVal === undefined) { 148 + if (bVal === undefined && bKeys.includes(key)) continue; 149 + return false; 150 + } else if (bVal === undefined) { 151 + return false; 152 + } 153 + 154 + if (!lexEquals(aVal, bVal)) { 155 + return false; 156 + } 157 + } 158 + 159 + return true; 160 + }
+7
data/mod.ts
··· 1 + export * from "./blob.ts"; 2 + export * from "./cid.ts"; 3 + export * from "./language.ts"; 4 + export * from "./lex.ts"; 5 + export * from "./object.ts"; 6 + export * from "./uint8array.ts"; 7 + export * from "./utf8.ts";
+21
data/object.ts
··· 1 + export function isObject(input: unknown): input is object { 2 + return input != null && typeof input === 'object' 3 + } 4 + 5 + const ObjectProto = Object.prototype 6 + const ObjectToString = Object.prototype.toString 7 + 8 + export function isPlainObject( 9 + input: unknown, 10 + ): input is object & Record<string, unknown> { 11 + if (!input || typeof input !== 'object') return false 12 + const proto = Object.getPrototypeOf(input) 13 + if (proto === null) return true 14 + return ( 15 + (proto === ObjectProto || 16 + // Needed to support NodeJS's `runInNewContext` which produces objects 17 + // with a different prototype 18 + Object.getPrototypeOf(proto) === null) && 19 + ObjectToString.call(input) === '[object Object]' 20 + ) 21 + }
+183
data/tests/blob_test.ts
··· 1 + import { assert, assertEquals, assertFalse } from "@std/assert"; 2 + import { isBlobRef, isLegacyBlobRef } from "../blob.ts"; 3 + import { CID } from "../cid.ts"; 4 + 5 + // await cidForRawBytes(Buffer.from('Hello, World!')) 6 + const blobCid = CID.parse( 7 + "bafkreig77vqcdozl2wyk6z3cscaj5q5fggi53aoh64fewkdiri3cdauyn4", 8 + ); 9 + // await cidForLex(Buffer.from('Hello, World!')) 10 + const lexCid = CID.parse( 11 + "bafyreic52vzks7wdklat4evp3vimohl55i2unzqpshz2ytka5omzr7exdy", 12 + ); 13 + 14 + Deno.test("isBlobRef tests valid blobCid and lexCid", () => { 15 + assertEquals(blobCid.code, 0x55); // raw 16 + assertEquals(blobCid.multihash.code, 0x12); // sha2-256 17 + assertEquals(lexCid.code, 0x71); // dag-cbor 18 + assertEquals(lexCid.multihash.code, 0x12); // sha2-256 19 + }); 20 + 21 + Deno.test("isBlobRef parses valid blob", () => { 22 + assert( 23 + isBlobRef({ 24 + $type: "blob", 25 + ref: blobCid, 26 + mimeType: "image/jpeg", 27 + size: 10000, 28 + }), 29 + ); 30 + 31 + assert( 32 + isBlobRef( 33 + { 34 + $type: "blob", 35 + ref: lexCid, 36 + mimeType: "image/jpeg", 37 + size: 10000, 38 + }, 39 + // In non-strict mode, any CID should be accepted 40 + { strict: false }, 41 + ), 42 + ); 43 + }); 44 + 45 + Deno.test("isBlobRef rejects invalid inputs", () => { 46 + assertFalse( 47 + isBlobRef({ 48 + $type: "blob", 49 + ref: { $link: blobCid.toString() }, 50 + mimeType: "image/jpeg", 51 + size: "10000", 52 + }), 53 + ); 54 + assertFalse( 55 + isBlobRef( 56 + { 57 + $type: "blob", 58 + ref: { $link: blobCid.toString() }, 59 + mimeType: "image/jpeg", 60 + size: "10000", 61 + }, 62 + { strict: true }, 63 + ), 64 + ); 65 + 66 + assertFalse( 67 + isBlobRef({ 68 + $type: "blob", 69 + mimeType: "image/jpeg", 70 + size: 10000, 71 + }), 72 + ); 73 + 74 + assertFalse( 75 + isBlobRef( 76 + { 77 + $type: "blob", 78 + mimeType: "image/jpeg", 79 + size: 10000, 80 + }, 81 + { strict: true }, 82 + ), 83 + ); 84 + }); 85 + 86 + Deno.test("isBlobRef rejects invalid CID/multihash code", () => { 87 + assert( 88 + isBlobRef( 89 + { 90 + $type: "blob", 91 + ref: blobCid, 92 + mimeType: "image/jpeg", 93 + size: 10000, 94 + }, 95 + { strict: true }, 96 + ), 97 + ); 98 + 99 + assertFalse( 100 + isBlobRef( 101 + { 102 + $type: "blob", 103 + ref: lexCid, 104 + mimeType: "image/jpeg", 105 + size: 10000, 106 + }, 107 + { strict: true }, 108 + ), 109 + ); 110 + }); 111 + 112 + Deno.test("isBlobRef rejects extra keys", () => { 113 + assertFalse( 114 + isBlobRef({ 115 + $type: "blob", 116 + ref: blobCid, 117 + mimeType: "image/jpeg", 118 + size: 10000, 119 + extra: "not allowed", 120 + }), 121 + ); 122 + 123 + assertFalse( 124 + isBlobRef( 125 + { 126 + $type: "blob", 127 + ref: blobCid, 128 + mimeType: "image/jpeg", 129 + size: 10000, 130 + extra: "not allowed", 131 + }, 132 + { strict: true }, 133 + ), 134 + ); 135 + }); 136 + 137 + Deno.test("isLegacyBlobRef parses valid legacy blob", () => { 138 + assert( 139 + isLegacyBlobRef({ 140 + cid: blobCid.toString(), 141 + mimeType: "image/jpeg", 142 + }), 143 + ); 144 + 145 + assert( 146 + isLegacyBlobRef({ 147 + cid: lexCid.toString(), 148 + mimeType: "image/jpeg", 149 + }), 150 + ); 151 + }); 152 + 153 + Deno.test("isLegacyBlobRef rejects invalid inputs", () => { 154 + assertFalse( 155 + isLegacyBlobRef({ 156 + cid: "babbaaa", 157 + mimeType: "image/jpeg", 158 + }), 159 + ); 160 + 161 + assertFalse( 162 + isLegacyBlobRef({ 163 + cid: 12345, 164 + mimeType: "image/jpeg", 165 + }), 166 + ); 167 + 168 + assertFalse( 169 + isLegacyBlobRef({ 170 + mimeType: "image/jpeg", 171 + }), 172 + ); 173 + }); 174 + 175 + Deno.test("isLegacyBlobRef rejects extra keys", () => { 176 + assertFalse( 177 + isLegacyBlobRef({ 178 + cid: blobCid.toString(), 179 + mimeType: "image/jpeg", 180 + extra: "not allowed", 181 + }), 182 + ); 183 + });
+80
data/tests/language_test.ts
··· 1 + import { assert, assertEquals, assertFalse } from "@std/assert"; 2 + import { isLanguage, parseLanguage } from "../language.ts"; 3 + 4 + Deno.test("languages validates BCP 47", () => { 5 + // valid 6 + assert(isLanguage("de")); 7 + assert(isLanguage("de-CH")); 8 + assert(isLanguage("de-DE-1901")); 9 + assert(isLanguage("es-419")); 10 + assert(isLanguage("sl-IT-nedis")); 11 + assert(isLanguage("mn-Cyrl-MN")); 12 + assert(isLanguage("x-fr-CH")); 13 + assert(isLanguage("en-GB-boont-r-extended-sequence-x-private")); 14 + assert(isLanguage("sr-Cyrl")); 15 + assert(isLanguage("hy-Latn-IT-arevela")); 16 + assert(isLanguage("i-klingon")); 17 + // invalid 18 + assertFalse(isLanguage("")); 19 + assertFalse(isLanguage("x")); 20 + assertFalse(isLanguage("de-CH-")); 21 + assertFalse(isLanguage("i-bad-grandfathered")); 22 + }); 23 + 24 + Deno.test("languages parses BCP 47", () => { 25 + // valid 26 + assertEquals(parseLanguage("de"), { 27 + language: "de", 28 + }); 29 + assertEquals(parseLanguage("de-CH"), { 30 + language: "de", 31 + region: "CH", 32 + }); 33 + assertEquals(parseLanguage("de-DE-1901"), { 34 + language: "de", 35 + region: "DE", 36 + variant: "1901", 37 + }); 38 + assertEquals(parseLanguage("es-419"), { 39 + language: "es", 40 + region: "419", 41 + }); 42 + assertEquals(parseLanguage("sl-IT-nedis"), { 43 + language: "sl", 44 + region: "IT", 45 + variant: "nedis", 46 + }); 47 + assertEquals(parseLanguage("mn-Cyrl-MN"), { 48 + language: "mn", 49 + script: "Cyrl", 50 + region: "MN", 51 + }); 52 + assertEquals(parseLanguage("x-fr-CH"), { 53 + privateUse: "x-fr-CH", 54 + }); 55 + assertEquals(parseLanguage("en-GB-boont-r-extended-sequence-x-private"), { 56 + language: "en", 57 + region: "GB", 58 + variant: "boont", 59 + extension: "r-extended-sequence", 60 + privateUse: "x-private", 61 + }); 62 + assertEquals(parseLanguage("sr-Cyrl"), { 63 + language: "sr", 64 + script: "Cyrl", 65 + }); 66 + assertEquals(parseLanguage("hy-Latn-IT-arevela"), { 67 + language: "hy", 68 + script: "Latn", 69 + region: "IT", 70 + variant: "arevela", 71 + }); 72 + assertEquals(parseLanguage("i-klingon"), { 73 + grandfathered: "i-klingon", 74 + }); 75 + // invalid 76 + assertEquals(parseLanguage(""), null); 77 + assertEquals(parseLanguage("x"), null); 78 + assertEquals(parseLanguage("de-CH-"), null); 79 + assertEquals(parseLanguage("i-bad-grandfathered"), null); 80 + });
+150
data/tests/lex-equals_test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { CID } from "../cid.ts"; 3 + import { lexEquals } from "../lex.ts"; 4 + import type { LexValue } from "../lex.ts"; 5 + import { assertThrows } from "@std/assert/throws"; 6 + 7 + function expectLexEqual(a: LexValue, b: LexValue, expected: boolean) { 8 + assertEquals(lexEquals(a, b), expected); 9 + assertEquals(lexEquals(b, a), expected); 10 + } 11 + 12 + Deno.test("lexEquals compares primitive values", () => { 13 + expectLexEqual(null, null, true); 14 + expectLexEqual(true, true, true); 15 + expectLexEqual(false, false, true); 16 + expectLexEqual(42, 42, true); 17 + expectLexEqual("hello", "hello", true); 18 + 19 + expectLexEqual(null, false, false); 20 + expectLexEqual(false, null, false); 21 + expectLexEqual(true, false, false); 22 + expectLexEqual(false, true, false); 23 + expectLexEqual(42, 43, false); 24 + expectLexEqual("hello", "world", false); 25 + }); 26 + 27 + Deno.test("lexEquals compares NaN and Infinity correctly", () => { 28 + expectLexEqual(NaN, NaN, true); 29 + expectLexEqual(Infinity, Infinity, true); 30 + expectLexEqual(-Infinity, -Infinity, true); 31 + 32 + expectLexEqual(NaN, 0, false); 33 + expectLexEqual(NaN, null, false); 34 + expectLexEqual(Infinity, -Infinity, false); 35 + }); 36 + 37 + Deno.test("lexEquals compares arrays", () => { 38 + expectLexEqual([1, 2, 3], [1, 2, 3], true); 39 + expectLexEqual([1, 2, 3], [1, 2, 4], false); 40 + expectLexEqual([1, 2, 3], [1, 2], false); 41 + expectLexEqual([1, 2, 3], "not an array", false); 42 + }); 43 + 44 + Deno.test("lexEquals compares Uint8Arrays", () => { 45 + expectLexEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]), true); 46 + expectLexEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]), false); 47 + expectLexEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2]), false); 48 + expectLexEqual(new Uint8Array([1, 2, 3]), "not a Uint8Array", false); 49 + }); 50 + 51 + Deno.test("lexEquals compares CIDs", () => { 52 + const cid1 = CID.parse( 53 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 54 + ); 55 + const cid2 = CID.parse(cid1.toString()); 56 + const cid3 = CID.parse(cid1.toString()); 57 + 58 + expectLexEqual(cid1, cid2, true); 59 + expectLexEqual(cid1, cid3, true); 60 + expectLexEqual(cid2, cid3, true); 61 + 62 + expectLexEqual(cid1, cid1.toString(), false); 63 + }); 64 + 65 + Deno.test("lexEquals compares objects", () => { 66 + expectLexEqual({ a: 1, b: 2 }, { a: 1, b: 2 }, true); 67 + expectLexEqual( 68 + { a: 1, b: { unicode: "a~öñ©⽘☎𓋓😀👨‍👩‍👧‍👧" } }, 69 + { a: 1, b: { unicode: "a~öñ©⽘☎𓋓😀👨‍👩‍👧‍👧" } }, 70 + true, 71 + ); 72 + 73 + expectLexEqual({ a: 1, b: 2 }, { a: 1, b: 3 }, false); 74 + expectLexEqual({ a: 1, b: 2 }, { a: 1 }, false); 75 + expectLexEqual({ a: 1, b: 2 }, "not an object", false); 76 + expectLexEqual({ a: 1, b: 2 }, null, false); 77 + }); 78 + 79 + Deno.test("lexEquals compares nested structures", () => { 80 + const lex1 = { 81 + foo: [1, 2, { bar: new Uint8Array([3, 4, 5]) }], 82 + baz: CID.parse( 83 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 84 + ), 85 + }; 86 + const lex2 = { 87 + foo: [1, 2, { bar: new Uint8Array([3, 4, 5]) }], 88 + baz: CID.parse( 89 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 90 + ), 91 + }; 92 + const lex3 = { 93 + foo: [1, 2, { bar: new Uint8Array([3, 4, 5 + 1]) }], 94 + baz: CID.parse( 95 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 96 + ), 97 + }; 98 + 99 + expectLexEqual(lex1, lex2, true); 100 + expectLexEqual(lex1, lex3, false); 101 + expectLexEqual(lex2, lex3, false); 102 + }); 103 + 104 + Deno.test("lexEquals allows comparing invalid numbers (floats, NaN, Infinity)", () => { 105 + expectLexEqual(3.14, 2.71, false); 106 + expectLexEqual(NaN, 0, false); 107 + expectLexEqual(Infinity, -Infinity, false); 108 + }); 109 + 110 + for (const value of [3.14, NaN, Infinity, -Infinity]) { 111 + Deno.test(`reference equality returns true for identical references of ${String(value)}`, () => { 112 + expectLexEqual(value, value, true); 113 + expectLexEqual([value], [value], true); 114 + expectLexEqual({ foo: value }, { foo: value }, true); 115 + expectLexEqual([{ foo: value }], [{ foo: value }], true); 116 + }); 117 + } 118 + 119 + Deno.test("lexEquals returns true for identical references", () => { 120 + const arr = [1, 2, 3]; 121 + expectLexEqual(arr, arr, true); 122 + 123 + const obj = { a: 1, b: 2 }; 124 + expectLexEqual(obj, obj, true); 125 + 126 + const u8 = new Uint8Array([1, 2, 3]); 127 + expectLexEqual(u8, u8, true); 128 + 129 + const cid = CID.parse( 130 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 131 + ); 132 + expectLexEqual(cid, cid, true); 133 + }); 134 + 135 + Deno.test("throws when comparing plain object with non-allowed class instance", () => { 136 + // @ts-expect-error should throw 137 + assertThrows(() => lexEquals({}, new Map())); 138 + // @ts-expect-error should throw 139 + assertThrows(() => lexEquals(new Map(), {})); 140 + // @ts-expect-error should throw 141 + assertThrows(() => lexEquals({ foo: {} }, { foo: new Map() })); 142 + // @ts-expect-error should throw 143 + assertThrows(() => lexEquals({ foo: new Map() }, { foo: {} })); 144 + 145 + assertThrows(() => lexEquals({ foo: {} }, { foo: new (class {})() })); 146 + 147 + assertThrows(() => lexEquals({ foo: new (class {})() }, { foo: {} })); 148 + 149 + assertThrows(() => lexEquals({ foo: {} }, { foo: new (class Object {})() })); 150 + });
+122
data/tests/lex_test.ts
··· 1 + import { assert } from "@std/assert"; 2 + import { isTypedLexMap } from "../lex.ts"; 3 + import { assertFalse } from "@std/assert/false"; 4 + 5 + Deno.test("isLexMap returns true for valid LexMap", () => { 6 + const record = { 7 + a: 123, 8 + b: "blah", 9 + c: true, 10 + d: null, 11 + e: new Uint8Array([1, 2, 3]), 12 + f: { 13 + nested: "value", 14 + }, 15 + g: [1, 2, 3], 16 + }; 17 + assertFalse(isTypedLexMap(record)); 18 + }); 19 + 20 + Deno.test("isLexMap returns false for non-records", () => { 21 + const values = [ 22 + 123, 23 + "blah", 24 + true, 25 + null, 26 + new Uint8Array([1, 2, 3]), 27 + [1, 2, 3], 28 + ]; 29 + for (const value of values) { 30 + assertFalse(isTypedLexMap(value)); 31 + } 32 + }); 33 + 34 + Deno.test("isLexMap returns false for records with non-Lex values", () => { 35 + assertFalse( 36 + // @ts-expect-error value passed is not a LexMap 37 + isTypedLexMap({ 38 + a: 123, 39 + b: () => {}, 40 + }), 41 + ); 42 + assertFalse( 43 + isTypedLexMap({ 44 + a: 123, 45 + b: undefined, 46 + }), 47 + ); 48 + }); 49 + 50 + Deno.test("isTypedLexMap valid records", () => { 51 + for ( 52 + const { json } of [ 53 + { 54 + note: "trivial record", 55 + json: { 56 + $type: "com.example.blah", 57 + a: 123, 58 + b: "blah", 59 + }, 60 + }, 61 + { 62 + note: "float, but integer-like", 63 + json: { 64 + $type: "com.example.blah", 65 + a: 123.0, 66 + b: "blah", 67 + }, 68 + }, 69 + { 70 + note: "empty list and object", 71 + json: { 72 + $type: "com.example.blah", 73 + a: [], 74 + b: {}, 75 + }, 76 + }, 77 + ] 78 + ) { 79 + assert(isTypedLexMap(json)); 80 + } 81 + }); 82 + 83 + Deno.test("isTypedLexMap invalid records", () => { 84 + for ( 85 + const { json } of [ 86 + { 87 + note: "float", 88 + json: { 89 + $type: "com.example.blah", 90 + a: 123.456, 91 + b: "blah", 92 + }, 93 + }, 94 + { 95 + note: "record with $type null", 96 + json: { 97 + $type: null, 98 + a: 123, 99 + b: "blah", 100 + }, 101 + }, 102 + { 103 + note: "record with $type wrong type", 104 + json: { 105 + $type: 123, 106 + a: 123, 107 + b: "blah", 108 + }, 109 + }, 110 + { 111 + note: "record with empty $type string", 112 + json: { 113 + $type: "", 114 + a: 123, 115 + b: "blah", 116 + }, 117 + }, 118 + ] 119 + ) { 120 + assertFalse(isTypedLexMap(json)); 121 + } 122 + });
+76
data/tests/object_test.ts
··· 1 + import { assert, assertFalse } from "@std/assert"; 2 + import { CID } from "../cid.ts"; 3 + import { isObject, isPlainObject } from "../object.ts"; 4 + 5 + Deno.test("isObject returns true for plain objects", () => { 6 + assert(isObject({})); 7 + assert(isObject({ a: 1 })); 8 + }); 9 + 10 + Deno.test("isObject returns true for CIDs", () => { 11 + const cid = CID.parse( 12 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 13 + ); 14 + assert(isObject(cid)); 15 + }); 16 + 17 + Deno.test("isObject returns true for class instances", () => { 18 + class MyClass {} 19 + assert(isObject(new MyClass())); 20 + }); 21 + 22 + Deno.test("isObject returns true for arrays", () => { 23 + assert(isObject([])); 24 + assert(isObject([1, 2, 3])); 25 + }); 26 + 27 + Deno.test("isObject returns false for null", () => { 28 + assertFalse(isObject(null)); 29 + }); 30 + 31 + Deno.test("isObject returns false for non-objects", () => { 32 + assertFalse(isObject(42)); 33 + assertFalse(isObject("string")); 34 + assertFalse(isObject(undefined)); 35 + assertFalse(isObject(true)); 36 + }); 37 + 38 + Deno.test("isPlainObject returns true for plain objects", () => { 39 + assert(isPlainObject({})); 40 + assert(isPlainObject({ a: 1 })); 41 + }); 42 + 43 + Deno.test("isPlainObject returns true for objects with null prototype", () => { 44 + const obj = Object.create(null); 45 + obj.a = 1; 46 + assert(isPlainObject(obj)); 47 + assert(isPlainObject({ __proto__: null, foo: "bar" })); 48 + }); 49 + 50 + Deno.test("isPlainObject returns false for class instances", () => { 51 + class MyClass {} 52 + assertFalse(isPlainObject(new MyClass())); 53 + }); 54 + 55 + Deno.test("isPlainObject returns false for CIDs", () => { 56 + const cid = CID.parse( 57 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 58 + ); 59 + assertFalse(isPlainObject(cid)); 60 + }); 61 + 62 + Deno.test("isPlainObject returns false for arrays", () => { 63 + assertFalse(isPlainObject([])); 64 + assertFalse(isPlainObject([1, 2, 3])); 65 + }); 66 + 67 + Deno.test("isPlainObject returns false for null", () => { 68 + assertFalse(isPlainObject(null)); 69 + }); 70 + 71 + Deno.test("isPlainObject returns false for non-objects", () => { 72 + assertFalse(isPlainObject(42)); 73 + assertFalse(isPlainObject("string")); 74 + assertFalse(isPlainObject(undefined)); 75 + assertFalse(isPlainObject(true)); 76 + });
+32
data/tests/utf8_test.ts
··· 1 + import { utf8Len } from "../index.ts"; 2 + import { graphemeLen } from "../utf8.ts"; 3 + import { assertEquals } from "@std/assert"; 4 + 5 + Deno.test("graphemeLen computes grapheme length", () => { 6 + assertEquals(graphemeLen("a"), 1); 7 + assertEquals(graphemeLen("~"), 1); 8 + assertEquals(graphemeLen("ö"), 1); 9 + assertEquals(graphemeLen("ñ"), 1); 10 + assertEquals(graphemeLen("©"), 1); 11 + assertEquals(graphemeLen("⽘"), 1); 12 + assertEquals(graphemeLen("☎"), 1); 13 + assertEquals(graphemeLen("𓋓"), 1); 14 + assertEquals(graphemeLen("😀"), 1); 15 + assertEquals(graphemeLen("👨‍👩‍👧‍👧"), 1); 16 + assertEquals(graphemeLen("a~öñ©⽘☎𓋓😀👨‍👩‍👧‍👧"), 10); 17 + // https://github.com/bluesky-social/atproto/issues/4321 18 + assertEquals(graphemeLen("नमस्ते"), 3); 19 + }); 20 + 21 + Deno.test("utf8Len computes utf8 string length", () => { 22 + assertEquals(utf8Len("a"), 1); 23 + assertEquals(utf8Len("~"), 1); 24 + assertEquals(utf8Len("ö"), 2); 25 + assertEquals(utf8Len("ñ"), 2); 26 + assertEquals(utf8Len("©"), 2); 27 + assertEquals(utf8Len("⽘"), 3); 28 + assertEquals(utf8Len("☎"), 3); 29 + assertEquals(utf8Len("𓋓"), 4); 30 + assertEquals(utf8Len("😀"), 4); 31 + assertEquals(utf8Len("👨‍👩‍👧‍👧"), 25); 32 + });
+38
data/uint8array.ts
··· 1 + /** 2 + * Coerces various binary data representations into a Uint8Array. 3 + * 4 + * @return `undefined` if the input could not be coerced into a {@link Uint8Array}. 5 + */ 6 + export function asUint8Array(input: unknown): Uint8Array | undefined { 7 + if (input instanceof Uint8Array) { 8 + return input; 9 + } 10 + 11 + if (ArrayBuffer.isView(input)) { 12 + return new Uint8Array( 13 + input.buffer, 14 + input.byteOffset, 15 + input.byteLength / Uint8Array.BYTES_PER_ELEMENT, 16 + ); 17 + } 18 + 19 + if (input instanceof ArrayBuffer) { 20 + return new Uint8Array(input); 21 + } 22 + 23 + return undefined; 24 + } 25 + 26 + export function ui8Equals(a: Uint8Array, b: Uint8Array): boolean { 27 + if (a.byteLength !== b.byteLength) { 28 + return false; 29 + } 30 + 31 + for (let i = 0; i < a.byteLength; i++) { 32 + if (a[i] !== b[i]) { 33 + return false; 34 + } 35 + } 36 + 37 + return true; 38 + }
+46
data/utf8.ts
··· 1 + const segmenter = new Intl.Segmenter(); 2 + 3 + export function graphemeLen(str: string): number { 4 + let length = 0; 5 + for (const _ of segmenter.segment(str)) length++; 6 + return length; 7 + } 8 + 9 + export function utf8Len(string: string): number { 10 + // similar to TextEncoder's implementation of UTF-8 encoding. 11 + // However, using TextEncoder to get the byte length is slower 12 + // as it requires allocating a new Uint8Array and copying data: 13 + 14 + // return new TextEncoder().encode(string).byteLength 15 + 16 + // The base length is the string length (all ASCII) 17 + let len = string.length; 18 + let code: number; 19 + 20 + // The loop calculates the number of additional bytes needed for 21 + // non-ASCII characters 22 + for (let i = 0; i < string.length; i += 1) { 23 + code = string.charCodeAt(i); 24 + 25 + if (code <= 0x7f) { 26 + // ASCII, 1 byte 27 + } else if (code <= 0x7ff) { 28 + // 2 bytes char 29 + len += 1; 30 + } else { 31 + // 3 bytes char 32 + len += 2; 33 + // If the current char is a high surrogate, and the next char is a low 34 + // surrogate, skip the next char as the total is a 4 bytes char 35 + // (represented as a surrogate pair in UTF-16) and was already accounted 36 + // for. 37 + if (code >= 0xd800 && code <= 0xdbff) { 38 + code = string.charCodeAt(i + 1); 39 + if (code >= 0xdc00 && code <= 0xdfff) { 40 + i++; 41 + } 42 + } 43 + } 44 + } 45 + return len; 46 + }
+2
deno.json
··· 11 11 "xrpc-server", 12 12 "sync", 13 13 "cli", 14 + "data", 15 + "lex", 14 16 "lex-gen" 15 17 ], 16 18 "imports": {
+10
deno.lock
··· 1228 1228 "npm:multiformats@^13.4.1" 1229 1229 ] 1230 1230 }, 1231 + "data": { 1232 + "dependencies": [ 1233 + "npm:multiformats@^13.4.1" 1234 + ] 1235 + }, 1231 1236 "identity": { 1232 1237 "dependencies": [ 1233 1238 "npm:@did-plc/lib@^0.0.4", 1234 1239 "npm:@did-plc/server@^0.0.1", 1235 1240 "npm:get-port@^7.1.0" 1241 + ] 1242 + }, 1243 + "lex": { 1244 + "dependencies": [ 1245 + "npm:multiformats@^13.4.1" 1236 1246 ] 1237 1247 }, 1238 1248 "lex-gen": {
+9
lex/deno.json
··· 1 + { 2 + "name": "@atp/lex", 3 + "version": "0.1.0-alpha.1", 4 + "exports": "./mod.ts", 5 + "license": "MIT", 6 + "imports": { 7 + "multiformats": "npm:multiformats@^13.4.1" 8 + } 9 + }
+5
lex/schema/core.ts
··· 1 + export * from "./core/$type.ts"; 2 + export * from "./core/record-key.ts"; 3 + export * from "./core/result.ts"; 4 + export * from "./core/string-format.ts"; 5 + export * from "./core/types.ts";
+16
lex/schema/core/$type.ts
··· 1 + import type { Nsid } from "./string-format.ts"; 2 + 3 + export type $Type< 4 + N extends Nsid = Nsid, 5 + H extends string = string, 6 + > = N extends Nsid ? string extends H ? N | `${N}#${string}` 7 + : H extends "main" ? N 8 + : `${N}#${H}` 9 + : never; 10 + 11 + export function $type<N extends Nsid, H extends string>( 12 + nsid: N, 13 + hash: H, 14 + ): $Type<N, H> { 15 + return (hash === "main" ? nsid : `${nsid}#${hash}`) as $Type<N, H>; 16 + }
+15
lex/schema/core/record-key.ts
··· 1 + export type RecordKey = "any" | "nsid" | "tid" | `literal:${string}`; 2 + 3 + export function isRecordKey<T>(key: T): key is T & RecordKey { 4 + return ( 5 + key === "any" || 6 + key === "nsid" || 7 + key === "tid" || 8 + (typeof key === "string" && key.startsWith("literal:")) 9 + ); 10 + } 11 + 12 + export function asRecordKey(key: unknown): RecordKey { 13 + if (isRecordKey(key)) return key; 14 + throw new Error(`Invalid record key: ${String(key)}`); 15 + }
+75
lex/schema/core/result.ts
··· 1 + export type ResultSuccess<V = unknown> = { success: true; value: V }; 2 + export type ResultFailure<E = Error> = { success: false; error: E }; 3 + 4 + export type Result<V = unknown, E = Error> = 5 + | ResultSuccess<V> 6 + | ResultFailure<E>; 7 + 8 + export function success<V>(value: V): ResultSuccess<V> { 9 + return { success: true, value }; 10 + } 11 + 12 + export function failure<E>(error: E): ResultFailure<E> { 13 + return { success: false, error }; 14 + } 15 + 16 + export function failureError<T>(result: ResultFailure<T>): T { 17 + return result.error; 18 + } 19 + 20 + export function successValue<T>(result: ResultSuccess<T>): T { 21 + return result.value; 22 + } 23 + 24 + /** 25 + * Catches any error and wraps it in a {@link ResultFailure<Error>}. 26 + * 27 + * @param err - The error to catch. 28 + * @returns A {@link ResultFailure<Error>} containing the caught error. 29 + * @example 30 + * 31 + * ```ts 32 + * declare function someFunction(): Promise<ResultSuccess<string>> 33 + * 34 + * const result = await someFunction().catch(catchall) 35 + * if (result.success) { 36 + * console.log(result.value) // string 37 + * } else { 38 + * console.error(result.error instanceof Error) // true 39 + * console.error(result.error.message) // string 40 + * } 41 + * ``` 42 + */ 43 + export function catchall(err: unknown): ResultFailure<Error> { 44 + if (err instanceof Error) return failure(err); 45 + return failure(new Error("Unknown error", { cause: err })); 46 + } 47 + 48 + /** 49 + * Creates a catcher function for the given constructor that wraps caught errors 50 + * in a {@link ResultFailure}. 51 + * 52 + * @example 53 + * 54 + * ```ts 55 + * class FooError extends Error {} 56 + * class BarError extends Error {} 57 + * 58 + * declare function someFunction(): Promise<ResultSuccess<string>> 59 + * 60 + * const result = await someFunction() 61 + * .catch(createCatcher(FooError)) 62 + * .catch(createCatcher(BarError)) 63 + * 64 + * if (result.success) { 65 + * console.log(result.value) // string 66 + * } else { 67 + * console.error(result.error) // FooError | BarError 68 + * } 69 + */ 70 + export function createCatcher<T>(Ctor: new (...args: unknown[]) => T) { 71 + return (err: unknown): ResultFailure<T> => { 72 + if (err instanceof Ctor) return failure(err); 73 + throw err; 74 + }; 75 + }
+121
lex/schema/core/string-format.ts
··· 1 + import { ensureValidCidString, isLanguage } from "@atp/data"; 2 + import { 3 + ensureValidAtUri, 4 + ensureValidDatetime, 5 + ensureValidDid, 6 + ensureValidHandle, 7 + ensureValidNsid, 8 + ensureValidRecordKey, 9 + ensureValidTid, 10 + } from "@atp/syntax"; 11 + 12 + // Allow (date as Date).toISOString() to be used where datetime format is expected 13 + declare global { 14 + interface Date { 15 + toISOString(): `${string}T${string}Z`; 16 + } 17 + } 18 + 19 + export const STRING_FORMATS = Object.freeze( 20 + [ 21 + "datetime", 22 + "uri", 23 + "at-uri", 24 + "did", 25 + "handle", 26 + "at-identifier", 27 + "nsid", 28 + "cid", 29 + "language", 30 + "tid", 31 + "record-key", 32 + ] as const, 33 + ); 34 + 35 + export type StringFormat = (typeof STRING_FORMATS)[number]; 36 + 37 + export type Did<M extends string = string> = `did:${M}:${string}`; 38 + export type Uri = `${string}:${string}`; 39 + export type Nsid = `${string}.${string}.${string}`; 40 + /** An ISO 8601 formatted datetime string (YYYY-MM-DDTHH:mm:ss.sssZ) */ 41 + export type Datetime = `${string}T${string}`; 42 + export type Handle = `${string}.${string}`; 43 + export type AtIdentifier = Did | Handle; 44 + export type AtUri = `at://${AtIdentifier}/${Nsid}/${string}`; 45 + 46 + export type InferStringFormat<F> = 47 + // 48 + F extends "datetime" ? Datetime 49 + : F extends "uri" ? Uri 50 + : F extends "at-uri" ? AtUri 51 + : F extends "did" ? Did 52 + : F extends "handle" ? Handle 53 + : F extends "at-identifier" ? AtIdentifier 54 + : F extends "nsid" ? Nsid 55 + : string; 56 + 57 + type AssertFn<T> = <I extends string>(input: I) => asserts input is I & T; 58 + 59 + // Re-export utility typed as assertion functions so that TypeScript can 60 + // infer the narrowed type after calling them. 61 + 62 + export const assertDid: AssertFn<Did> = ensureValidDid; 63 + export const assertAtUri: AssertFn<AtUri> = ensureValidAtUri; 64 + export const assertNsid: AssertFn<Nsid> = ensureValidNsid; 65 + export const assertTid: AssertFn<string> = ensureValidTid; 66 + export const assertRecordKey: AssertFn<string> = ensureValidRecordKey; 67 + export const assertDatetime: AssertFn<Datetime> = ensureValidDatetime; 68 + export const assertCidString: AssertFn<string> = ensureValidCidString; 69 + export const assertHandle: AssertFn<Handle> = ensureValidHandle; 70 + 71 + // Export utilities for formats missing from @atproto/syntax 72 + 73 + export const assertUri: AssertFn<Uri> = (input) => { 74 + if (!/^\w+:(?:\/\/)?[^\s/][^\s]*$/.test(input)) { 75 + throw new Error("Invalid URI"); 76 + } 77 + }; 78 + export const assertLanguage: AssertFn<string> = (input) => { 79 + if (!isLanguage(input)) { 80 + throw new Error("Invalid BCP 47 string"); 81 + } 82 + }; 83 + export const assertAtIdentifier: AssertFn<AtIdentifier> = (input) => { 84 + if (input.startsWith("did:web:") || input.startsWith("did:plc:")) { 85 + assertDid(input); 86 + } else if (input.startsWith("did:")) { 87 + throw new Error("Invalid DID method"); 88 + } else { 89 + try { 90 + assertHandle(input); 91 + } catch (cause) { 92 + throw new Error("Invalid DID or handle", { cause }); 93 + } 94 + } 95 + }; 96 + 97 + const formatters = /*#__PURE__*/ new Map<StringFormat, (str: string) => void>( 98 + [ 99 + ["datetime", assertDatetime], 100 + ["uri", assertUri], 101 + ["at-uri", assertAtUri], 102 + ["did", assertDid], 103 + ["handle", assertHandle], 104 + ["at-identifier", assertAtIdentifier], 105 + ["nsid", assertNsid], 106 + ["cid", assertCidString], 107 + ["language", assertLanguage], 108 + ["tid", assertTid], 109 + ["record-key", assertRecordKey], 110 + ] as const, 111 + ); 112 + 113 + export function assertStringFormat<F extends StringFormat>( 114 + input: string, 115 + format: F, 116 + ): asserts input is InferStringFormat<F> { 117 + const assertFn = formatters.get(format); 118 + if (assertFn) assertFn(input); 119 + // Fool-proof 120 + else throw new Error(`Unknown string format: ${format}`); 121 + }
+22
lex/schema/core/types.ts
··· 1 + /** 2 + * Same as {@link string} but prevents TypeScript allowing union types to 3 + * be widened to `string` in IDEs. 4 + */ 5 + export type UnknownString = string & NonNullable<unknown>; 6 + 7 + export type Simplify<T> = { [K in keyof T]: T[K] } & NonNullable<unknown>; 8 + 9 + // @NOTE there is no way to express "array containing at least one P", so we use 10 + // "array that contains P at first or last position" as a workaround. 11 + export type ArrayContaining<T, Items = unknown> = 12 + | readonly [T, ...Items[]] 13 + | readonly [...Items[], T]; 14 + 15 + declare const __restricted: unique symbol; 16 + /** 17 + * A type that represents a value that cannot be used, with a custom 18 + * message explaining the restriction. 19 + */ 20 + export type Restricted<Message extends string> = typeof __restricted & { 21 + [__restricted]: Message; 22 + };
+363
lex/schema/external.ts
··· 1 + import { type $Type, $type, type Nsid, type RecordKey } from "./core.ts"; 2 + import { 3 + ArraySchema, 4 + type ArraySchemaOptions, 5 + BlobSchema, 6 + type BlobSchemaOptions, 7 + BooleanSchema, 8 + type BooleanSchemaOptions, 9 + BytesSchema, 10 + type BytesSchemaOptions, 11 + CidSchema, 12 + type CustomAssertion, 13 + CustomSchema, 14 + DictSchema, 15 + DiscriminatedUnionSchema, 16 + type DiscriminatedUnionSchemaVariants, 17 + EnumSchema, 18 + IntegerSchema, 19 + type IntegerSchemaOptions, 20 + IntersectionSchema, 21 + type IntersectionSchemaValidators, 22 + LiteralSchema, 23 + NeverSchema, 24 + NullSchema, 25 + ObjectSchema, 26 + type ObjectSchemaOptions, 27 + type ObjectSchemaProperties, 28 + ParamsSchema, 29 + type ParamsSchemaOptions, 30 + type ParamsSchemaProperties, 31 + Payload, 32 + type PayloadBody, 33 + Permission, 34 + type PermissionOptions, 35 + PermissionSet, 36 + type PermissionSetOptions, 37 + Procedure, 38 + Query, 39 + RecordSchema, 40 + RefSchema, 41 + type RefSchemaGetter, 42 + StringSchema, 43 + type StringSchemaOptions, 44 + Subscription, 45 + TokenSchema, 46 + TypedObjectSchema, 47 + type TypedRefGetter, 48 + TypedRefSchema, 49 + TypedUnionSchema, 50 + UnionSchema, 51 + type UnionSchemaValidators, 52 + type UnknownObjectOutput, 53 + UnknownObjectSchema, 54 + UnknownSchema, 55 + } from "./schema.ts"; 56 + import type { Infer, PropertyKey, Validator } from "./validation.ts"; 57 + 58 + export * from "./core.ts"; 59 + export * from "./schema.ts"; 60 + export * from "./validation.ts"; 61 + 62 + /*@__NO_SIDE_EFFECTS__*/ 63 + export function never() { 64 + return new NeverSchema(); 65 + } 66 + 67 + /*@__NO_SIDE_EFFECTS__*/ 68 + export function unknown() { 69 + return new UnknownSchema(); 70 + } 71 + 72 + /*@__NO_SIDE_EFFECTS__*/ 73 + export function _null() { 74 + return new NullSchema(); 75 + } 76 + 77 + export { _null as null }; 78 + 79 + /*@__NO_SIDE_EFFECTS__*/ 80 + export function literal<const V extends null | string | number | boolean>( 81 + value: V, 82 + ) { 83 + return new LiteralSchema<V>(value); 84 + } 85 + 86 + /*@__NO_SIDE_EFFECTS__*/ 87 + export function _enum<const V extends null | string | number | boolean>( 88 + value: readonly V[], 89 + ) { 90 + return new EnumSchema<V>(value); 91 + } 92 + 93 + // @NOTE "enum" is a reserved keyword in JS/TS 94 + export { _enum as enum }; 95 + 96 + /*@__NO_SIDE_EFFECTS__*/ 97 + export function boolean(options: BooleanSchemaOptions = {}) { 98 + return new BooleanSchema(options); 99 + } 100 + 101 + /*@__NO_SIDE_EFFECTS__*/ 102 + export function integer(options: IntegerSchemaOptions = {}) { 103 + return new IntegerSchema(options); 104 + } 105 + 106 + /*@__NO_SIDE_EFFECTS__*/ 107 + export function cidLink() { 108 + return new CidSchema(); 109 + } 110 + 111 + /*@__NO_SIDE_EFFECTS__*/ 112 + export function bytes(options: BytesSchemaOptions = {}) { 113 + return new BytesSchema(options); 114 + } 115 + 116 + /*@__NO_SIDE_EFFECTS__*/ 117 + export function blob(options: BlobSchemaOptions = {}) { 118 + return new BlobSchema(options); 119 + } 120 + 121 + /*@__NO_SIDE_EFFECTS__*/ 122 + export function string< 123 + const O extends StringSchemaOptions = NonNullable<unknown>, 124 + >(options: StringSchemaOptions & O = {} as O) { 125 + return new StringSchema<O>(options); 126 + } 127 + 128 + /*@__NO_SIDE_EFFECTS__*/ 129 + export function array<const T>( 130 + items: Validator<T>, 131 + options: ArraySchemaOptions = {}, 132 + ) { 133 + return new ArraySchema(items, options); 134 + } 135 + 136 + /*@__NO_SIDE_EFFECTS__*/ 137 + export function object< 138 + const P extends ObjectSchemaProperties, 139 + const O extends ObjectSchemaOptions = NonNullable<unknown>, 140 + >( 141 + properties: ObjectSchemaProperties & P, 142 + options: ObjectSchemaOptions & O = {} as O, 143 + ) { 144 + return new ObjectSchema<P, O>(properties, options); 145 + } 146 + 147 + /*@__NO_SIDE_EFFECTS__*/ 148 + export function dict<const K extends Validator, const V extends Validator>( 149 + key: K, 150 + value: V, 151 + ) { 152 + return new DictSchema<K, V>(key, value); 153 + } 154 + 155 + // Utility 156 + export type { UnknownObjectOutput as UnknownObject }; 157 + 158 + /*@__NO_SIDE_EFFECTS__*/ 159 + export function unknownObject() { 160 + return new UnknownObjectSchema(); 161 + } 162 + 163 + /*@__NO_SIDE_EFFECTS__*/ 164 + export function ref<T>(get: RefSchemaGetter<T>) { 165 + return new RefSchema<T>(get); 166 + } 167 + 168 + /*@__NO_SIDE_EFFECTS__*/ 169 + export function custom<T>( 170 + assertion: CustomAssertion<T>, 171 + message: string, 172 + path?: PropertyKey | readonly PropertyKey[], 173 + ) { 174 + return new CustomSchema<T>(assertion, message, path); 175 + } 176 + 177 + /*@__NO_SIDE_EFFECTS__*/ 178 + export function union<const V extends UnionSchemaValidators>(validators: V) { 179 + return new UnionSchema<V>(validators); 180 + } 181 + 182 + /*@__NO_SIDE_EFFECTS__*/ 183 + export function intersection<const V extends IntersectionSchemaValidators>( 184 + validators: V, 185 + ) { 186 + return new IntersectionSchema<V>(validators); 187 + } 188 + 189 + /*@__NO_SIDE_EFFECTS__*/ 190 + export function discriminatedUnion< 191 + const Discriminator extends string, 192 + const Options extends DiscriminatedUnionSchemaVariants<Discriminator>, 193 + >(discriminator: Discriminator, variants: Options) { 194 + return new DiscriminatedUnionSchema<Discriminator, Options>( 195 + discriminator, 196 + variants, 197 + ); 198 + } 199 + 200 + /*@__NO_SIDE_EFFECTS__*/ 201 + export function token<const N extends Nsid, const H extends string>( 202 + nsid: N, 203 + hash: H, 204 + ) { 205 + return new TokenSchema($type(nsid, hash)); 206 + } 207 + 208 + /*@__NO_SIDE_EFFECTS__*/ 209 + export function typedRef<const V extends { $type?: string }>( 210 + get: TypedRefGetter<V>, 211 + ) { 212 + return new TypedRefSchema<V>(get); 213 + } 214 + 215 + /*@__NO_SIDE_EFFECTS__*/ 216 + export function typedUnion< 217 + const R extends readonly TypedRefSchema[], 218 + const C extends boolean, 219 + >(refs: R, closed: C) { 220 + return new TypedUnionSchema<R, C>(refs, closed); 221 + } 222 + 223 + /** 224 + * This function offers two overloads: 225 + * - One that allows creating a {@link TypedObjectSchema}, and infer the output 226 + * type from the provided arguments, without requiring to specify any of the 227 + * generics. This is useful when you want to define a record without 228 + * explicitly defining its interface. This version does not support circular 229 + * references, as TypeScript cannot infer types in such cases. 230 + * - One allows creating a {@link TypedObjectSchema} with an explicitly defined 231 + * interface. This will typically be used by codegen (`lex build`) to generate 232 + * schemas that work even if they contain circular references. 233 + */ 234 + export function typedObject< 235 + const N extends Nsid, 236 + const H extends string, 237 + const Schema extends Validator<{ [_ in string]?: unknown }>, 238 + >(nsid: N, hash: H, schema: Schema): TypedObjectSchema<$Type<N, H>, Schema>; 239 + export function typedObject<const V extends { $type?: $Type }>( 240 + nsid: V extends { $type?: infer T extends string } 241 + ? T extends `${infer N}#${string}` ? N 242 + : T // (T is a "main" type, so already an NSID) 243 + : never, 244 + hash: V extends { $type?: infer T extends string } 245 + ? T extends `${string}#${infer H}` ? H 246 + : "main" 247 + : never, 248 + schema: Validator<Omit<V, "$type">>, 249 + ): TypedObjectSchema<NonNullable<V["$type"]>, typeof schema, V>; 250 + /*@__NO_SIDE_EFFECTS__*/ 251 + export function typedObject< 252 + const N extends Nsid, 253 + const H extends string, 254 + const Schema extends Validator<{ [_ in string]?: unknown }>, 255 + >(nsid: N, hash: H, schema: Schema) { 256 + return new TypedObjectSchema<$Type<N, H>, Schema>($type(nsid, hash), schema); 257 + } 258 + 259 + /** 260 + * Ensures that a `$type` used in a record is a valid NSID (i.e. no fragment). 261 + */ 262 + type AsNsid<T> = T extends `${string}#${string}` ? never : T; 263 + 264 + /** 265 + * This function offers two overloads: 266 + * - One that allows creating a {@link RecordSchema}, and infer the output type 267 + * from the provided arguments, without requiring to specify any of the 268 + * generics. This is useful when you want to define a record without 269 + * explicitly defining its interface. This version does not support circular 270 + * references, as TypeScript cannot infer types in such cases. 271 + * - One allows creating a {@link RecordSchema} with an explicitly defined 272 + * interface. This will typically be used by codegen (`lex build`) to generate 273 + * schemas that work even if they contain circular references. 274 + */ 275 + export function record< 276 + const K extends RecordKey, 277 + const T extends Nsid, 278 + const S extends Validator<{ [_ in string]?: unknown }>, 279 + >( 280 + key: K, 281 + type: AsNsid<T>, 282 + schema: S, 283 + ): RecordSchema<K, T, S, Infer<S> & { $type: T }>; 284 + export function record< 285 + const K extends RecordKey, 286 + const V extends { $type: Nsid }, 287 + >( 288 + key: K, 289 + type: AsNsid<V["$type"]>, 290 + schema: Validator<Omit<V, "$type">>, 291 + ): RecordSchema<K, V["$type"], typeof schema, V>; 292 + /*@__NO_SIDE_EFFECTS__*/ 293 + export function record< 294 + const K extends RecordKey, 295 + const T extends Nsid, 296 + const S extends Validator<{ [_ in string]?: unknown }>, 297 + >(key: K, type: T, schema: S) { 298 + return new RecordSchema<K, T, S, Infer<S> & { $type: T }>(key, type, schema); 299 + } 300 + 301 + /*@__NO_SIDE_EFFECTS__*/ 302 + export function params< 303 + const P extends ParamsSchemaProperties = NonNullable<unknown>, 304 + const O extends ParamsSchemaOptions = ParamsSchemaOptions, 305 + >(properties: P = {} as P, options: ParamsSchemaOptions & O = {} as O) { 306 + return new ParamsSchema<P, O>(properties, options); 307 + } 308 + 309 + /*@__NO_SIDE_EFFECTS__*/ 310 + export function payload< 311 + const E extends string | undefined = undefined, 312 + const S extends PayloadBody<E> = undefined, 313 + >(encoding: E = undefined as E, schema: S = undefined as S) { 314 + return new Payload<E, S>(encoding, schema); 315 + } 316 + 317 + /*@__NO_SIDE_EFFECTS__*/ 318 + export function query< 319 + const N extends Nsid, 320 + const P extends ParamsSchema, 321 + const O extends Payload, 322 + const E extends undefined | readonly string[] = undefined, 323 + >(nsid: N, parameters: P, output: O, errors: E = undefined as E) { 324 + return new Query<N, P, O, E>(nsid, parameters, output, errors); 325 + } 326 + 327 + /*@__NO_SIDE_EFFECTS__*/ 328 + export function procedure< 329 + const N extends Nsid, 330 + const P extends ParamsSchema, 331 + const I extends Payload, 332 + const O extends Payload, 333 + const E extends undefined | readonly string[] = undefined, 334 + >(nsid: N, parameters: P, input: I, output: O, errors: E = undefined as E) { 335 + return new Procedure<N, P, I, O, E>(nsid, parameters, input, output, errors); 336 + } 337 + 338 + /*@__NO_SIDE_EFFECTS__*/ 339 + export function subscription< 340 + const N extends string, 341 + const P extends ParamsSchema, 342 + const M extends undefined | RefSchema | TypedUnionSchema | ObjectSchema, 343 + const E extends undefined | readonly string[] = undefined, 344 + >(nsid: N, parameters: P, message: M, errors: E = undefined as E) { 345 + return new Subscription<N, P, M, E>(nsid, parameters, message, errors); 346 + } 347 + 348 + /*@__NO_SIDE_EFFECTS__*/ 349 + export function permission< 350 + const R extends string, 351 + const O extends PermissionOptions, 352 + >(resource: R, options: PermissionOptions & O = {} as O) { 353 + return new Permission<R, O>(resource, options); 354 + } 355 + 356 + /*@__NO_SIDE_EFFECTS__*/ 357 + export function permissionSet< 358 + const N extends string, 359 + const P extends readonly Permission[], 360 + const O extends PermissionSetOptions, 361 + >(nsid: N, permissions: P, options: PermissionSetOptions & O = {} as O) { 362 + return new PermissionSet<N, P, O>(nsid, permissions, options); 363 + }
+3
lex/schema/index.ts
··· 1 + import * as l from "./external.ts"; 2 + export * from "./external.ts"; 3 + export { l };
+40
lex/schema/schema.ts
··· 1 + // Utilities (that depend on *and* are used by schemas) 2 + export * from "./schema/_parameters.ts"; 3 + 4 + // Concrete Types 5 + export * from "./schema/array.ts"; 6 + export * from "./schema/blob.ts"; 7 + export * from "./schema/boolean.ts"; 8 + export * from "./schema/bytes.ts"; 9 + export * from "./schema/cid.ts"; 10 + export * from "./schema/dict.ts"; 11 + export * from "./schema/enum.ts"; 12 + export * from "./schema/integer.ts"; 13 + export * from "./schema/literal.ts"; 14 + export * from "./schema/never.ts"; 15 + export * from "./schema/null.ts"; 16 + export * from "./schema/object.ts"; 17 + export * from "./schema/string.ts"; 18 + export * from "./schema/unknown-object.ts"; 19 + export * from "./schema/unknown.ts"; 20 + 21 + // Composite Types 22 + export * from "./schema/custom.ts"; 23 + export * from "./schema/discriminated-union.ts"; 24 + export * from "./schema/intersection.ts"; 25 + export * from "./schema/ref.ts"; 26 + export * from "./schema/union.ts"; 27 + 28 + // Lexicon specific Types 29 + export * from "./schema/params.ts"; 30 + export * from "./schema/payload.ts"; 31 + export * from "./schema/permission-set.ts"; 32 + export * from "./schema/permission.ts"; 33 + export * from "./schema/procedure.ts"; 34 + export * from "./schema/query.ts"; 35 + export * from "./schema/record.ts"; 36 + export * from "./schema/subscription.ts"; 37 + export * from "./schema/token.ts"; 38 + export * from "./schema/typed-object.ts"; 39 + export * from "./schema/typed-ref.ts"; 40 + export * from "./schema/typed-union.ts";
+26
lex/schema/schema/_parameters.ts
··· 1 + import type { Infer, Validator } from "../validation.ts"; 2 + import { ArraySchema } from "./array.ts"; 3 + import { BooleanSchema } from "./boolean.ts"; 4 + import { DictSchema } from "./dict.ts"; 5 + import { IntegerSchema } from "./integer.ts"; 6 + import { StringSchema } from "./string.ts"; 7 + import { UnionSchema } from "./union.ts"; 8 + 9 + export type ParamScalar = Infer<typeof paramScalarSchema>; 10 + const paramScalarSchema = new UnionSchema([ 11 + new BooleanSchema({}), 12 + new IntegerSchema({}), 13 + new StringSchema({}), 14 + ]); 15 + 16 + export type Param = Infer<typeof paramSchema>; 17 + export const paramSchema = new UnionSchema([ 18 + paramScalarSchema, 19 + new ArraySchema(paramScalarSchema, {}), 20 + ]); 21 + 22 + export type Params = { [_: string]: undefined | Param }; 23 + export const paramsSchema = new DictSchema( 24 + new StringSchema({}), 25 + paramSchema, 26 + ) satisfies Validator<Params>;
+55
lex/schema/schema/array.ts
··· 1 + import { 2 + type ValidationResult, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + export type ArraySchemaOptions = { 8 + minLength?: number; 9 + maxLength?: number; 10 + }; 11 + 12 + export class ArraySchema<Item = unknown> extends Validator<Array<Item>> { 13 + override readonly lexiconType = "array" as const; 14 + 15 + constructor( 16 + readonly items: Validator<Item>, 17 + readonly options: ArraySchemaOptions, 18 + ) { 19 + super(); 20 + } 21 + 22 + override validateInContext( 23 + input: unknown, 24 + ctx: ValidatorContext, 25 + ): ValidationResult<Array<Item>> { 26 + if (!Array.isArray(input)) { 27 + return ctx.issueInvalidType(input, "array"); 28 + } 29 + 30 + const { minLength, maxLength } = this.options; 31 + 32 + if (minLength != null && input.length < minLength) { 33 + return ctx.issueTooSmall(input, "array", minLength, input.length); 34 + } 35 + 36 + if (maxLength != null && input.length > maxLength) { 37 + return ctx.issueTooBig(input, "array", maxLength, input.length); 38 + } 39 + 40 + let copy: undefined | Array<Item>; 41 + 42 + for (let i = 0; i < input.length; i++) { 43 + const result = ctx.validateChild(input, i, this.items); 44 + if (!result.success) return result; 45 + 46 + if (result.value !== input[i]) { 47 + // Copy on write (but only if we did not already make a copy) 48 + copy ??= Array.from(input); 49 + copy[i] = result.value; 50 + } 51 + } 52 + 53 + return ctx.success(copy ?? input) as ValidationResult<Array<Item>>; 54 + } 55 + }
+86
lex/schema/schema/blob.ts
··· 1 + import { 2 + type BlobRef, 3 + isBlobRef, 4 + isLegacyBlobRef, 5 + type LegacyBlobRef, 6 + } from "@atp/data"; 7 + import { 8 + type ValidationResult, 9 + Validator, 10 + type ValidatorContext, 11 + } from "../validation.ts"; 12 + 13 + export type BlobSchemaOptions = { 14 + /** 15 + * Whether to allow legacy blob references format 16 + * @see {@link LegacyBlobRef} 17 + */ 18 + allowLegacy?: boolean; 19 + /** 20 + * Whether to enforce strict validation on the blob reference (CID version, codec, hash function) 21 + */ 22 + strict?: boolean; 23 + /** 24 + * List of accepted mime types 25 + */ 26 + accept?: string[]; 27 + /** 28 + * Maximum size in bytes 29 + */ 30 + maxSize?: number; 31 + }; 32 + 33 + export type { BlobRef, LegacyBlobRef }; 34 + 35 + export type BlobSchemaOutput<Options> = Options extends { allowLegacy: true } 36 + ? BlobRef | LegacyBlobRef 37 + : BlobRef; 38 + 39 + export class BlobSchema<O extends BlobSchemaOptions> extends Validator< 40 + BlobSchemaOutput<O> 41 + > { 42 + override readonly lexiconType = "blob" as const; 43 + 44 + constructor(readonly options: O) { 45 + super(); 46 + } 47 + 48 + override validateInContext( 49 + input: unknown, 50 + ctx: ValidatorContext, 51 + ): ValidationResult<BlobSchemaOutput<O>> { 52 + if (!isBlob(input, this.options)) { 53 + return ctx.issueInvalidType(input, "blob"); 54 + } 55 + 56 + // @NOTE Historically, we did not enforce constraints on blob references 57 + // https://github.com/bluesky-social/atproto/blob/4c15fb47cec26060bff2e710e95869a90c9d7fdd/packages/lexicon/src/validators/blob.ts#L5-L19 58 + 59 + // const { accept } = this.options 60 + // if (accept && !accept.includes(input.mimeType)) { 61 + // return ctx.issueInvalidValue(input, accept) 62 + // } 63 + 64 + // const { maxSize } = this.options 65 + // if (maxSize != null && input.size != -1 && input.size > maxSize) { 66 + // return ctx.issueTooBig(input, 'blob', maxSize, input.size) 67 + // } 68 + 69 + return ctx.success(input); 70 + } 71 + } 72 + 73 + function isBlob<O extends BlobSchemaOptions>( 74 + input: unknown, 75 + options: O, 76 + ): input is BlobSchemaOutput<O> { 77 + if ((input as { $type: string })?.$type !== undefined) { 78 + return isBlobRef(input, options); 79 + } 80 + 81 + if (options.allowLegacy === true) { 82 + return isLegacyBlobRef(input); 83 + } 84 + 85 + return false; 86 + }
+28
lex/schema/schema/boolean.ts
··· 1 + import { 2 + type ValidationResult, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + export type BooleanSchemaOptions = { 8 + default?: boolean; 9 + }; 10 + 11 + export class BooleanSchema extends Validator<boolean> { 12 + override readonly lexiconType = "boolean" as const; 13 + 14 + constructor(readonly options: BooleanSchemaOptions) { 15 + super(); 16 + } 17 + 18 + override validateInContext( 19 + input: unknown = this.options.default, 20 + ctx: ValidatorContext, 21 + ): ValidationResult<boolean> { 22 + if (typeof input === "boolean") { 23 + return ctx.success(input); 24 + } 25 + 26 + return ctx.issueInvalidType(input, "boolean"); 27 + } 28 + }
+42
lex/schema/schema/bytes.ts
··· 1 + import { asUint8Array } from "@atp/data"; 2 + import { 3 + type ValidationResult, 4 + Validator, 5 + type ValidatorContext, 6 + } from "../validation.ts"; 7 + 8 + export type BytesSchemaOptions = { 9 + minLength?: number; 10 + maxLength?: number; 11 + }; 12 + 13 + export class BytesSchema extends Validator<Uint8Array> { 14 + override readonly lexiconType = "bytes" as const; 15 + 16 + constructor(readonly options: BytesSchemaOptions) { 17 + super(); 18 + } 19 + 20 + override validateInContext( 21 + input: unknown, 22 + ctx: ValidatorContext, 23 + ): ValidationResult<Uint8Array> { 24 + // Coerce different binary formats into Uint8Array 25 + const bytes = asUint8Array(input); 26 + if (!bytes) { 27 + return ctx.issueInvalidType(input, "bytes"); 28 + } 29 + 30 + const { minLength } = this.options; 31 + if (minLength != null && bytes.length < minLength) { 32 + return ctx.issueTooSmall(bytes, "bytes", minLength, bytes.length); 33 + } 34 + 35 + const { maxLength } = this.options; 36 + if (maxLength != null && bytes.length > maxLength) { 37 + return ctx.issueTooBig(bytes, "bytes", maxLength, bytes.length); 38 + } 39 + 40 + return ctx.success(bytes); 41 + } 42 + }
+31
lex/schema/schema/cid.ts
··· 1 + import { CID, isCid } from "@atp/data"; 2 + import { 3 + type ValidationResult, 4 + Validator, 5 + type ValidatorContext, 6 + } from "../validation.ts"; 7 + 8 + export { CID }; 9 + 10 + export type CidSchemaOptions = { 11 + strict?: boolean; 12 + }; 13 + 14 + export class CidSchema extends Validator<CID> { 15 + override readonly lexiconType = "cid-link" as const; 16 + 17 + constructor(readonly options: CidSchemaOptions = {}) { 18 + super(); 19 + } 20 + 21 + override validateInContext( 22 + input: unknown, 23 + ctx: ValidatorContext, 24 + ): ValidationResult<CID> { 25 + if (!isCid(input, this.options)) { 26 + return ctx.issueInvalidType(input, "cid"); 27 + } 28 + 29 + return ctx.success(input); 30 + } 31 + }
+36
lex/schema/schema/custom.ts
··· 1 + import type { PropertyKey } from "../validation/property-key.ts"; 2 + import { 3 + type ContextualIssue, 4 + type ValidationResult, 5 + Validator, 6 + type ValidatorContext, 7 + } from "../validation/validator.ts"; 8 + 9 + export type CustomAssertionContext = { 10 + path: PropertyKey[]; 11 + addIssue(issue: ContextualIssue): void; 12 + }; 13 + 14 + export type CustomAssertion<T = unknown> = ( 15 + this: null, 16 + input: unknown, 17 + ctx: CustomAssertionContext, 18 + ) => input is T; 19 + 20 + export class CustomSchema<T = unknown> extends Validator<T> { 21 + constructor( 22 + private readonly assertion: CustomAssertion<T>, 23 + private readonly message: string, 24 + private readonly path?: PropertyKey | readonly PropertyKey[], 25 + ) { 26 + super(); 27 + } 28 + 29 + override validateInContext( 30 + input: unknown, 31 + ctx: ValidatorContext, 32 + ): ValidationResult<T> { 33 + if (this.assertion.call(null, input, ctx)) return ctx.success(input as T); 34 + return ctx.custom(input, this.message, this.path); 35 + } 36 + }
+67
lex/schema/schema/dict.ts
··· 1 + import { isPlainObject } from "@atp/data"; 2 + import { 3 + type Infer, 4 + type ValidationResult, 5 + Validator, 6 + type ValidatorContext, 7 + } from "../validation.ts"; 8 + 9 + export type DictSchemaOutput< 10 + KeySchema extends Validator, 11 + ValueSchema extends Validator, 12 + > = Infer<KeySchema> extends never ? Record<string, never> 13 + : Record<Infer<KeySchema> & string, Infer<ValueSchema>>; 14 + 15 + /** 16 + * @note There is no dictionary in Lexicon schemas. This is a custom extension 17 + * to allow map-like objects when using the lex library programmatically (i.e. 18 + * not code generated from a lexicon schema). 19 + */ 20 + export class DictSchema< 21 + const KeySchema extends Validator, 22 + const ValueSchema extends Validator, 23 + > extends Validator<DictSchemaOutput<KeySchema, ValueSchema>> { 24 + constructor( 25 + readonly keySchema: KeySchema, 26 + readonly valueSchema: ValueSchema, 27 + ) { 28 + super(); 29 + } 30 + 31 + override validateInContext( 32 + input: unknown, 33 + ctx: ValidatorContext, 34 + options?: { ignoredKeys?: { has(k: string): boolean } }, 35 + ): ValidationResult<DictSchemaOutput<KeySchema, ValueSchema>> { 36 + if (!isPlainObject(input)) { 37 + return ctx.issueInvalidType(input, "dict"); 38 + } 39 + 40 + let copy: undefined | Record<string, unknown>; 41 + 42 + for (const key in input) { 43 + if (options?.ignoredKeys?.has(key)) continue; 44 + 45 + const keyResult = ctx.validate(key, this.keySchema); 46 + if (!keyResult.success) return keyResult; 47 + if (keyResult.value !== key) { 48 + // We can't safely "move" the key to a different name in the output 49 + // object (because there may already be something there), so we issue a 50 + // "required key" error if the key validation changes the key 51 + return ctx.issueRequiredKey(input, key); 52 + } 53 + 54 + const valueResult = ctx.validateChild(input, key, this.valueSchema); 55 + if (!valueResult.success) return valueResult; 56 + 57 + if (valueResult.value !== input[key]) { 58 + copy ??= { ...input }; 59 + copy[key] = valueResult.value; 60 + } 61 + } 62 + 63 + return ctx.success( 64 + (copy ?? input) as DictSchemaOutput<KeySchema, ValueSchema>, 65 + ); 66 + } 67 + }
+143
lex/schema/schema/discriminated-union.ts
··· 1 + import { isPlainObject } from "@atp/data"; 2 + import type { ArrayContaining } from "../core.ts"; 3 + import { 4 + ValidationError, 5 + type ValidationFailure, 6 + type ValidationResult, 7 + Validator, 8 + type ValidatorContext, 9 + } from "../validation.ts"; 10 + import { EnumSchema } from "./enum.ts"; 11 + import { LiteralSchema } from "./literal.ts"; 12 + import type { ObjectSchema } from "./object.ts"; 13 + 14 + export type DiscriminatedUnionSchemaVariant<Discriminator extends string> = 15 + ObjectSchema< 16 + { [_ in Discriminator]: Validator }, 17 + { required: ArrayContaining<Discriminator, string> } 18 + >; 19 + 20 + export type DiscriminatedUnionSchemaVariants<Discriminator extends string> = 21 + readonly [ 22 + DiscriminatedUnionSchemaVariant<Discriminator>, 23 + ...DiscriminatedUnionSchemaVariant<Discriminator>[], 24 + ]; 25 + 26 + export type DiscriminatedUnionSchemaOutput< 27 + Options extends readonly Validator[], 28 + > = Options extends readonly [Validator<infer V>] ? V 29 + : Options extends readonly [ 30 + Validator<infer V>, 31 + ...infer Rest extends Validator[], 32 + ] ? V | DiscriminatedUnionSchemaOutput<Rest> 33 + : never; 34 + 35 + /** 36 + * @note There is no discriminated union in Lexicon schemas. This is a custom 37 + * extension to allow optimized validation of union of objects when using the 38 + * lex library programmatically (i.e. not code generated from a lexicon schema). 39 + */ 40 + export class DiscriminatedUnionSchema< 41 + const Discriminator extends string = string, 42 + const Options extends DiscriminatedUnionSchemaVariants<Discriminator> = 43 + DiscriminatedUnionSchemaVariants<Discriminator>, 44 + > extends Validator<DiscriminatedUnionSchemaOutput<Options>> { 45 + constructor( 46 + readonly discriminator: Discriminator, 47 + readonly variants: Options, 48 + ) { 49 + super(); 50 + } 51 + 52 + /** 53 + * If all variants have a literal or enum for the discriminator property, 54 + * and there are no overlapping values, returns a map of discriminator values 55 + * to variants. Otherwise, returns null. 56 + */ 57 + protected get variantsMap() { 58 + const map = new Map< 59 + unknown, 60 + DiscriminatedUnionSchemaVariant<Discriminator> 61 + >(); 62 + for (const variant of this.variants) { 63 + const schema = variant.validators[this.discriminator]; 64 + if (schema instanceof LiteralSchema) { 65 + if (map.has(schema.value)) return null; // overlapping value 66 + map.set(schema.value, variant); 67 + } else if (schema instanceof EnumSchema) { 68 + for (const val of schema.values) { 69 + if (map.has(val)) return null; // overlapping value 70 + map.set(val, variant); 71 + } 72 + } else { 73 + return null; // not a literal or enum 74 + } 75 + } 76 + 77 + // Cache the map on the instance (to avoid re-computing) 78 + Object.defineProperty(this, "variantsMap", { 79 + value: map, 80 + writable: false, 81 + enumerable: false, 82 + configurable: true, 83 + }); 84 + 85 + return map; 86 + } 87 + 88 + override validateInContext( 89 + input: unknown, 90 + ctx: ValidatorContext, 91 + ): ValidationResult<DiscriminatedUnionSchemaOutput<Options>> { 92 + if (!isPlainObject(input)) { 93 + return ctx.issueInvalidType(input, "object"); 94 + } 95 + 96 + if (!Object.hasOwn(input, this.discriminator)) { 97 + return ctx.issueRequiredKey(input, this.discriminator); 98 + } 99 + 100 + // Fast path: if we have a mapping of discriminator values to variants, 101 + // we can directly select the correct variant to validate against. This also 102 + // outputs a better error (with a single failure issue) when the discriminator. 103 + if (this.variantsMap) { 104 + const variant = this.variantsMap.get(input[this.discriminator]); 105 + if (!variant) { 106 + return ctx.issueInvalidPropertyValue(input, this.discriminator, [ 107 + ...this.variantsMap.keys(), 108 + ]); 109 + } 110 + 111 + return ctx.validate(input, variant) as ValidationResult< 112 + DiscriminatedUnionSchemaOutput<Options> 113 + >; 114 + } 115 + 116 + // Slow path: try validating against each variant and return the first 117 + // successful one (or aggregate all failures if none match). 118 + const failures: ValidationFailure[] = []; 119 + 120 + for (const variant of this.variants) { 121 + const discSchema = variant.validators[this.discriminator]; 122 + const discResult = ctx.validateChild( 123 + input, 124 + this.discriminator, 125 + discSchema, 126 + ); 127 + 128 + if (!discResult.success) { 129 + failures.push(discResult); 130 + continue; 131 + } 132 + 133 + return ctx.validate(input, variant) as ValidationResult< 134 + DiscriminatedUnionSchemaOutput<Options> 135 + >; 136 + } 137 + 138 + return { 139 + success: false, 140 + error: ValidationError.fromFailures(failures), 141 + }; 142 + } 143 + }
+24
lex/schema/schema/enum.ts
··· 1 + import { 2 + type ValidationResult, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + export class EnumSchema< 8 + Output extends null | string | number | boolean = string, 9 + > extends Validator<Output> { 10 + constructor(readonly values: readonly Output[]) { 11 + super(); 12 + } 13 + 14 + override validateInContext( 15 + input: unknown, 16 + ctx: ValidatorContext, 17 + ): ValidationResult<Output> { 18 + if (!(this.values as readonly unknown[]).includes(input)) { 19 + return ctx.issueInvalidValue(input, this.values); 20 + } 21 + 22 + return ctx.success(input as Output); 23 + } 24 + }
+45
lex/schema/schema/integer.ts
··· 1 + import { 2 + type ValidationResult, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + export type IntegerSchemaOptions = { 8 + default?: number; 9 + minimum?: number; 10 + maximum?: number; 11 + }; 12 + 13 + export class IntegerSchema extends Validator<number> { 14 + override readonly lexiconType = "integer" as const; 15 + 16 + constructor(readonly options: IntegerSchemaOptions) { 17 + super(); 18 + } 19 + 20 + override validateInContext( 21 + input: unknown = this.options.default, 22 + ctx: ValidatorContext, 23 + ): ValidationResult<number> { 24 + if (!isInteger(input)) { 25 + return ctx.issueInvalidType(input, "integer"); 26 + } 27 + 28 + if (this.options.minimum !== undefined && input < this.options.minimum) { 29 + return ctx.issueTooSmall(input, "integer", this.options.minimum, input); 30 + } 31 + 32 + if (this.options.maximum !== undefined && input > this.options.maximum) { 33 + return ctx.issueTooBig(input, "integer", this.options.maximum, input); 34 + } 35 + 36 + return ctx.success(input); 37 + } 38 + } 39 + 40 + /** 41 + * Simple wrapper around {@link Number.isInteger} that acts as a type guard. 42 + */ 43 + function isInteger(input: unknown): input is number { 44 + return Number.isInteger(input); 45 + }
+56
lex/schema/schema/intersection.ts
··· 1 + import { 2 + type Infer, 3 + type ValidationResult, 4 + Validator, 5 + type ValidatorContext, 6 + } from "../validation.ts"; 7 + 8 + export type IntersectionSchemaValidators = readonly [ 9 + Validator, 10 + Validator, 11 + ...Validator[], 12 + ]; 13 + export type IntersectionSchemaOutput< 14 + V extends readonly Validator[], 15 + Base = unknown, 16 + > = V extends readonly [ 17 + infer First extends Validator, 18 + ...infer Rest extends Validator[], 19 + ] ? IntersectionSchemaOutput<Rest, Base & Infer<First>> 20 + : Base; 21 + 22 + export class IntersectionSchema< 23 + V extends IntersectionSchemaValidators = IntersectionSchemaValidators, 24 + > extends Validator<IntersectionSchemaOutput<V>> { 25 + constructor(protected readonly validators: V) { 26 + super(); 27 + } 28 + 29 + override validateInContext( 30 + input: unknown, 31 + ctx: ValidatorContext, 32 + ): ValidationResult<IntersectionSchemaOutput<V>> { 33 + for (let i = 0; i < this.validators.length; i++) { 34 + const result = ctx.validate(input, this.validators[i]); 35 + 36 + if (!result.success) { 37 + return result; 38 + } 39 + 40 + // @NOTE because transforming the value could make it invalid for previous 41 + // validators, we need to ensure the input remains unchanged only gets 42 + // transformed by the first validator. 43 + if (i !== 0 && input !== result.value) { 44 + // The alternative would be to allow transforms on a first pass 45 + // (ignoring errors) and then re-validate the final value against all 46 + // validators (without allowing further transforms). This would be way 47 + // less efficient (we could make this optional). 48 + return ctx.issueInvalidValue(input, [result.value]); 49 + } 50 + 51 + input = result.value; 52 + } 53 + 54 + return ctx.success(input as IntersectionSchemaOutput<V>); 55 + } 56 + }
+24
lex/schema/schema/literal.ts
··· 1 + import { 2 + type ValidationResult, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + export class LiteralSchema< 8 + Output extends null | string | number | boolean = string, 9 + > extends Validator<Output> { 10 + constructor(readonly value: Output) { 11 + super(); 12 + } 13 + 14 + override validateInContext( 15 + input: unknown, 16 + ctx: ValidatorContext, 17 + ): ValidationResult<Output> { 18 + if (input !== this.value) { 19 + return ctx.issueInvalidValue(input, [this.value]); 20 + } 21 + 22 + return ctx.success(this.value); 23 + } 24 + }
+14
lex/schema/schema/never.ts
··· 1 + import { 2 + type ValidationFailure, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + export class NeverSchema extends Validator<never> { 8 + override validateInContext( 9 + input: unknown, 10 + ctx: ValidatorContext, 11 + ): ValidationFailure { 12 + return ctx.issueInvalidType(input, "never"); 13 + } 14 + }
+24
lex/schema/schema/null.ts
··· 1 + import { 2 + type ValidationResult, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + export class NullSchema extends Validator<null> { 8 + override readonly lexiconType = "null" as const; 9 + 10 + constructor() { 11 + super(); 12 + } 13 + 14 + override validateInContext( 15 + input: unknown, 16 + ctx: ValidatorContext, 17 + ): ValidationResult<null> { 18 + if (input !== null) { 19 + return ctx.issueInvalidType(input, "null"); 20 + } 21 + 22 + return ctx.success(null); 23 + } 24 + }
+178
lex/schema/schema/object.ts
··· 1 + import { isPlainObject } from "@atp/data"; 2 + import type { Simplify } from "../core.ts"; 3 + import { 4 + type Infer, 5 + type ValidationResult, 6 + Validator, 7 + type ValidatorContext, 8 + } from "../validation.ts"; 9 + import type { DictSchema } from "./dict.ts"; 10 + 11 + export type ObjectSchemaProperties = { [_ in string]: Validator }; 12 + export type ObjectSchemaOptions = { 13 + required?: readonly string[]; 14 + nullable?: readonly string[]; 15 + unknownProperties?: "strict" | DictSchema<Validator, Validator>; 16 + }; 17 + 18 + export type ObjectSchemaNullValue< 19 + O extends ObjectSchemaOptions, 20 + K extends string, 21 + > = O extends { nullable: readonly (infer N extends string)[] } 22 + ? K extends N ? null 23 + : never 24 + : never; 25 + 26 + export type ObjectSchemaPropertiesOutput< 27 + P extends ObjectSchemaProperties, 28 + O extends ObjectSchemaOptions, 29 + > = O extends { required: readonly (infer R extends string)[] } ? 30 + & { 31 + -readonly [K in string & keyof P & R]-?: 32 + | Infer<P[K]> 33 + | ObjectSchemaNullValue<O, K>; 34 + } 35 + & { 36 + -readonly [K in Exclude<string & keyof P, R>]?: 37 + | Infer<P[K]> 38 + | ObjectSchemaNullValue<O, K>; 39 + } 40 + : { 41 + -readonly [K in string & keyof P]?: 42 + | Infer<P[K]> 43 + | ObjectSchemaNullValue<O, K>; 44 + }; 45 + 46 + /** 47 + * Allows to more accurately represent the intersection of two object types 48 + * where both types may share some keys, and one of them uses an index 49 + * signature. 50 + * 51 + * @see {@link https://www.typescriptlang.org/play/?#code/C4TwDgpgBAglC8UDeUBmB7dAuKByARgIYBOuUAvlAGTJQDaA+lAJYB2UAzsMWwOYC6OVgFcAtvgjEKAKGkATCAGMANiWiL0rLlEI4YsjVuBQA1hBA4uPVrwRQARBnT2Dm7QDdCy4dESE6ZiD8UAD0IVAi4pJQABQcABbowspyUBIORMT2AJSyEAAeYOjExqCQUACSrMCSHErAzJoAPNJQsFAFNaxyHFAASkrFck1WfAA0UMKsJqzoAO6sAHxjrVAAQh35XT39g8TDozYTUzPzSyuLdqtwVKttMYHoqO00j88bnRDdvawQ7pJ3NpQAD860BbRwSHBQLadAA0ix2G91oJ1vDggAfWABcxPF5QOH8aFtci5aRlaAwVDMfIQVKIKo1Yh1RQNZq0Jw4AgkMjkCYoRiIzjcPioyISKTkRayBQqNRQQzaQgAMRpdL01NpclcRignm8EFVWrsKrVchxQVC4XF0SxmSAA Playground link} 52 + */ 53 + type Intersect< 54 + A extends Record<string, unknown>, 55 + B extends Record<string, unknown>, 56 + > = B[keyof B] extends never ? A 57 + : keyof A & keyof B extends never 58 + // If A and B don't overlap, just return A & B 59 + ? A & B 60 + // Otherwise, properly represent the fact that accessing using an 61 + // index signature could return a value from either A or B 62 + : A & { [K in keyof B]: B[K] | A[keyof A & K] }; 63 + 64 + export type ObjectSchemaOutput< 65 + P extends ObjectSchemaProperties, 66 + O extends ObjectSchemaOptions, 67 + > = O extends { 68 + unknownProperties: Validator<infer D extends Record<string, unknown>>; 69 + } ? Simplify<Intersect<ObjectSchemaPropertiesOutput<P, O>, D>> 70 + : Simplify<ObjectSchemaPropertiesOutput<P, O>>; 71 + 72 + export class ObjectSchema< 73 + const Validators extends ObjectSchemaProperties = ObjectSchemaProperties, 74 + const Options extends ObjectSchemaOptions = ObjectSchemaOptions, 75 + const Output extends ObjectSchemaOutput< 76 + Validators, 77 + Options 78 + > = ObjectSchemaOutput<Validators, Options>, 79 + > extends Validator<Output> { 80 + constructor( 81 + readonly validators: Validators, 82 + readonly options: Options, 83 + ) { 84 + super(); 85 + } 86 + 87 + get validatorsMap(): Map<string, Validator> { 88 + const map = new Map(Object.entries(this.validators)); 89 + 90 + // Cache the map on the instance (to avoid re-creating it) 91 + Object.defineProperty(this, "validatorsMap", { 92 + value: map, 93 + writable: false, 94 + enumerable: false, 95 + configurable: true, 96 + }); 97 + 98 + return map; 99 + } 100 + 101 + override validateInContext( 102 + input: unknown, 103 + ctx: ValidatorContext, 104 + ): ValidationResult<Output> { 105 + if (!isPlainObject(input)) { 106 + return ctx.issueInvalidType(input, ["object"]); 107 + } 108 + 109 + // Lazily copy value 110 + let copy: undefined | Record<string, unknown>; 111 + 112 + for (const [key, propDef] of this.validatorsMap) { 113 + if (input[key] === null && this.options.nullable?.includes(key)) { 114 + continue; 115 + } 116 + 117 + const result = ctx.validateChild(input, key, propDef); 118 + if (!result.success) { 119 + // Because default values are provided by child validators, we need to 120 + // run the validator to get the default value and, in case of failure, 121 + // ignore validation error that were caused by missing keys. 122 + if (!(key in input)) { 123 + if (this.options.required?.includes(key)) { 124 + // Transform into "required key" issue 125 + return ctx.issueRequiredKey(input, key); 126 + } 127 + 128 + // Ignore missing non-required key 129 + continue; 130 + } 131 + 132 + return result; 133 + } else if (result.value === undefined) { 134 + // Special case for validators that output "undefined" values (typically 135 + // UnknownSchema) since they cannot differentiate between "missing key" 136 + // and "key with undefined value" 137 + 138 + if (!(key in input)) { 139 + // Input was missing the key (was "undefined") 140 + if (this.options.required?.includes(key)) { 141 + return ctx.issueRequiredKey(input, key); 142 + } 143 + 144 + // Ignore missing non-required key 145 + continue; 146 + } 147 + 148 + // if "key" existed in input (would typically be "undefined"), we keep 149 + // it as-is by continuing processing as if it was any other value. 150 + } 151 + 152 + if (result.value !== input[key]) { 153 + copy ??= { ...input }; 154 + copy[key] = result.value; 155 + } 156 + } 157 + 158 + if (this.options.unknownProperties === "strict") { 159 + for (const key of Object.keys(input)) { 160 + if (!this.validatorsMap.has(key)) { 161 + return ctx.issueInvalidPropertyType(input, key, "undefined"); 162 + } 163 + } 164 + } else if (this.options.unknownProperties) { 165 + const result = this.options.unknownProperties.validateInContext( 166 + copy ?? input, 167 + ctx, 168 + { ignoredKeys: this.validatorsMap }, 169 + ); 170 + if (!result.success) return result; 171 + if (result.value !== input) copy = result.value; 172 + } 173 + 174 + const output = (copy ?? input) as Output; 175 + 176 + return ctx.success(output); 177 + } 178 + }
+160
lex/schema/schema/params.ts
··· 1 + import { isPlainObject } from "@atp/data"; 2 + import { 3 + type ValidationResult, 4 + Validator, 5 + type ValidatorContext, 6 + } from "../validation.ts"; 7 + import { type Param, type ParamScalar, paramSchema } from "./_parameters.ts"; 8 + import type { ObjectSchemaPropertiesOutput } from "./object.ts"; 9 + import { StringSchema } from "./string.ts"; 10 + 11 + export type ParamsSchemaProperties = { 12 + [_ in string]: Validator<Param>; 13 + }; 14 + 15 + export type ParamsSchemaOptions = { 16 + required?: readonly string[]; 17 + }; 18 + 19 + export type ParamsSchemaOutput< 20 + P extends ParamsSchemaProperties, 21 + O extends ParamsSchemaOptions, 22 + > = ObjectSchemaPropertiesOutput<P, O>; 23 + 24 + export type InferParamsSchema<T> = T extends ParamsSchema<infer P, infer O> 25 + ? NonNullable<unknown> extends ParamsSchemaOutput<P, O> 26 + ? ParamsSchemaOutput<P, O> | undefined 27 + : ParamsSchemaOutput<P, O> 28 + : never; 29 + 30 + export class ParamsSchema< 31 + const Validators extends ParamsSchemaProperties = ParamsSchemaProperties, 32 + const Options extends ParamsSchemaOptions = ParamsSchemaOptions, 33 + Output extends ParamsSchemaOutput<Validators, Options> = ParamsSchemaOutput< 34 + Validators, 35 + Options 36 + >, 37 + > extends Validator<Output> { 38 + override readonly lexiconType = "params" as const; 39 + 40 + constructor( 41 + readonly validators: Validators, 42 + readonly options: Options, 43 + ) { 44 + super(); 45 + } 46 + 47 + get validatorsMap(): Map<string, Validator<Param>> { 48 + const map = new Map(Object.entries(this.validators)); 49 + 50 + // Cache the map on the instance (to avoid re-creating it) 51 + Object.defineProperty(this, "validatorsMap", { 52 + value: map, 53 + writable: false, 54 + enumerable: false, 55 + configurable: true, 56 + }); 57 + 58 + return map; 59 + } 60 + 61 + override validateInContext( 62 + input: unknown = {}, 63 + ctx: ValidatorContext, 64 + ): ValidationResult<Output> { 65 + if (!isPlainObject(input)) { 66 + return ctx.issueInvalidType(input, "object"); 67 + } 68 + 69 + // Lazily copy value 70 + let copy: undefined | Record<string, unknown>; 71 + 72 + // Ensure that non-specified params conform to param schema 73 + for (const key in input) { 74 + if (this.validatorsMap.has(key)) continue; 75 + 76 + const result = ctx.validateChild(input, key, paramSchema); 77 + if (!result.success) return result; 78 + 79 + if (result.value !== input[key]) { 80 + copy ??= { ...input }; 81 + copy[key] = result.value; 82 + } 83 + } 84 + 85 + for (const [key, propDef] of this.validatorsMap) { 86 + const result = ctx.validateChild(input, key, propDef); 87 + if (!result.success) { 88 + // Because default values are provided by child validators, we need to 89 + // run the validator to get the default value and, in case of failure, 90 + // ignore validation error that were caused by missing keys. 91 + if (!(key in input)) { 92 + if (!this.options.required?.includes(key)) { 93 + // Ignore missing non-required key 94 + continue; 95 + } else { 96 + // Transform into "required key" issue 97 + return ctx.issueRequiredKey(input, key); 98 + } 99 + } 100 + 101 + return result; 102 + } 103 + 104 + if (result.value !== input[key]) { 105 + // Copy on write 106 + copy ??= { ...input }; 107 + copy![key] = result.value; 108 + } 109 + } 110 + 111 + return ctx.success((copy ?? input) as Output); 112 + } 113 + 114 + fromURLSearchParams(urlSearchParams: URLSearchParams): Output { 115 + const params: Record<string, Param> = {}; 116 + 117 + for (const [key, value] of urlSearchParams.entries()) { 118 + const validator = this.validatorsMap.get(key); 119 + 120 + const coerced: ParamScalar = 121 + validator != null && validator instanceof StringSchema 122 + ? value 123 + : value === "true" 124 + ? true 125 + : value === "false" 126 + ? false 127 + : /^-?\d+$/.test(value) 128 + ? Number(value) 129 + : value; 130 + 131 + if (params[key] === undefined) { 132 + params[key] = coerced; 133 + } else if (Array.isArray(params[key])) { 134 + params[key].push(coerced); 135 + } else { 136 + params[key] = [params[key] as ParamScalar, coerced]; 137 + } 138 + } 139 + 140 + return this.parse(params); 141 + } 142 + 143 + toURLSearchParams(input: Output): URLSearchParams { 144 + const urlSearchParams = new URLSearchParams(); 145 + 146 + if (input !== undefined) { 147 + for (const [key, value] of Object.entries(input)) { 148 + if (Array.isArray(value)) { 149 + for (const v of value) { 150 + urlSearchParams.append(key, String(v)); 151 + } 152 + } else if (value !== undefined) { 153 + urlSearchParams.append(key, String(value)); 154 + } 155 + } 156 + } 157 + 158 + return urlSearchParams; 159 + } 160 + }
+50
lex/schema/schema/payload.ts
··· 1 + import type { LexValue } from "@atp/data"; 2 + import type { Validator } from "../validation.ts"; 3 + 4 + export type LexBody<E extends string = string> = E extends `text/${string}` 5 + ? string // Text encodings always yield string bodies 6 + : E extends "application/json" ? LexValue 7 + : Uint8Array; 8 + 9 + export type InferPayloadEncoding<P extends Payload> = P extends Payload<infer E> 10 + ? E 11 + : undefined; 12 + 13 + export type InferPayloadBody<P extends Payload> = P extends 14 + Payload<string, infer S> ? S extends Validator<infer V> ? V 15 + : P extends Payload<infer E extends string> ? LexBody<E> 16 + : undefined 17 + : undefined; 18 + 19 + export type PayloadOutput< 20 + E extends string | undefined = string, 21 + S extends Validator | undefined = Validator, 22 + > = E extends string ? S extends Validator<infer V> ? { 23 + encoding: E; 24 + body: V; 25 + } 26 + : { 27 + encoding: E; 28 + body: LexBody<E>; 29 + } 30 + : void; 31 + 32 + export type PayloadBody<E extends string | undefined> = E extends undefined 33 + ? undefined 34 + : Validator | undefined; 35 + 36 + export class Payload< 37 + const Encoding extends string | undefined = string | undefined, 38 + const Body extends PayloadBody<Encoding> = PayloadBody<Encoding>, 39 + > { 40 + constructor( 41 + readonly encoding: Encoding, 42 + readonly schema: Body, 43 + ) { 44 + if (encoding === undefined && schema !== undefined) { 45 + throw new TypeError( 46 + "schema cannot be defined when encoding is undefined", 47 + ); 48 + } 49 + } 50 + }
+22
lex/schema/schema/permission-set.ts
··· 1 + import type { Permission } from "./permission.ts"; 2 + 3 + export type PermissionSetOptions = { 4 + title?: string; 5 + "title:lang"?: Record<string, undefined | string>; 6 + detail?: string; 7 + "detail:lang"?: Record<string, undefined | string>; 8 + }; 9 + 10 + export class PermissionSet< 11 + const Nsid extends string = string, 12 + const Permissions extends readonly Permission[] = readonly Permission[], 13 + const Options extends PermissionSetOptions = PermissionSetOptions, 14 + > { 15 + readonly lexiconType = "permission-set" as const; 16 + 17 + constructor( 18 + readonly nsid: Nsid, 19 + readonly permissions: Permissions, 20 + readonly options: Options, 21 + ) {} 22 + }
+15
lex/schema/schema/permission.ts
··· 1 + import type { Params } from "./_parameters.ts"; 2 + 3 + export type PermissionOptions = Params; 4 + 5 + export class Permission< 6 + const Resource extends string = string, 7 + const Options extends PermissionOptions = PermissionOptions, 8 + > { 9 + readonly lexiconType = "permission" as const; 10 + 11 + constructor( 12 + readonly resource: Resource, 13 + readonly options: Options, 14 + ) {} 15 + }
+34
lex/schema/schema/procedure.ts
··· 1 + import type { Nsid } from "../core.ts"; 2 + import type { Infer } from "../validation.ts"; 3 + import type { ParamsSchema } from "./params.ts"; 4 + import type { InferPayloadBody, Payload } from "./payload.ts"; 5 + 6 + export type InferProcedureParameters<Q extends Procedure> = Q extends 7 + Procedure<Nsid, infer P extends ParamsSchema> ? Infer<P> : never; 8 + 9 + export type InferProcedureInputBody<Q extends Procedure> = Q extends 10 + Procedure<Nsid, ParamsSchema, infer I extends Payload> ? InferPayloadBody<I> 11 + : never; 12 + 13 + export type InferProcedureOutputBody<Q extends Procedure> = Q extends 14 + Procedure<Nsid, ParamsSchema, Payload, infer O extends Payload> 15 + ? InferPayloadBody<O> 16 + : never; 17 + 18 + export class Procedure< 19 + N extends Nsid = Nsid, 20 + P extends ParamsSchema = ParamsSchema, 21 + I extends Payload = Payload, 22 + O extends Payload = Payload, 23 + E extends undefined | readonly string[] = undefined, 24 + > { 25 + readonly lexiconType = "procedure" as const; 26 + 27 + constructor( 28 + readonly nsid: N, 29 + readonly parameters: P, 30 + readonly input: I, 31 + readonly output: O, 32 + readonly errors: E, 33 + ) {} 34 + }
+27
lex/schema/schema/query.ts
··· 1 + import type { Nsid } from "../core.ts"; 2 + import type { Infer } from "../validation.ts"; 3 + import type { ParamsSchema } from "./params.ts"; 4 + import type { InferPayloadBody, Payload } from "./payload.ts"; 5 + 6 + export type InferQueryParameters<Q extends Query> = Q extends 7 + Query<Nsid, infer P extends ParamsSchema> ? Infer<P> : never; 8 + 9 + export type InferQueryOutputBody<Q extends Query> = Q extends 10 + Query<Nsid, ParamsSchema, infer O extends Payload> ? InferPayloadBody<O> 11 + : never; 12 + 13 + export class Query< 14 + N extends Nsid = Nsid, 15 + P extends ParamsSchema = ParamsSchema, 16 + O extends Payload = Payload, 17 + E extends undefined | readonly string[] = undefined, 18 + > { 19 + readonly lexiconType = "query" as const; 20 + 21 + constructor( 22 + readonly nsid: N, 23 + readonly parameters: P, 24 + readonly output: O, 25 + readonly errors: E, 26 + ) {} 27 + }
+108
lex/schema/schema/record.ts
··· 1 + import type { Nsid, RecordKey, Simplify } from "../core.ts"; 2 + import { 3 + type Infer, 4 + type ValidationResult, 5 + Validator, 6 + type ValidatorContext, 7 + } from "../validation.ts"; 8 + import { LiteralSchema } from "./literal.ts"; 9 + import { StringSchema } from "./string.ts"; 10 + 11 + export type InferRecordKey<R extends RecordSchema> = R extends RecordSchema< 12 + infer K, 13 + Nsid, 14 + Validator<object>, 15 + Infer<Validator<object>> & { $type: Nsid } 16 + > ? RecordKeySchemaOutput<K> 17 + : never; 18 + 19 + export class RecordSchema< 20 + Key extends RecordKey = RecordKey, 21 + Type extends Nsid = Nsid, 22 + Schema extends Validator<object> = Validator<object>, 23 + Output extends Infer<Schema> & { $type: Type } = Infer<Schema> & { 24 + $type: Type; 25 + }, 26 + > extends Validator<Output> { 27 + override readonly lexiconType = "record" as const; 28 + 29 + keySchema: RecordKeySchema<Key>; 30 + 31 + constructor( 32 + readonly key: Key, 33 + readonly $type: Type, 34 + readonly schema: Schema, 35 + ) { 36 + super(); 37 + this.keySchema = recordKey(key); 38 + } 39 + 40 + isTypeOf<X extends { $type?: unknown }>( 41 + value: X, 42 + ): value is X extends { $type: Type } ? X : never { 43 + return value.$type === this.$type; 44 + } 45 + 46 + build<X extends Omit<Output, "$type">>( 47 + input: X, 48 + ): Simplify<Omit<X, "$type"> & { $type: Type }> { 49 + return { ...input, $type: this.$type }; 50 + } 51 + 52 + $isTypeOf<X extends { $type?: unknown }>(value: X) { 53 + return this.isTypeOf<X>(value); 54 + } 55 + 56 + $build<X extends Omit<Output, "$type">>(input: X) { 57 + return this.build<X>(input); 58 + } 59 + 60 + override validateInContext( 61 + input: unknown, 62 + ctx: ValidatorContext, 63 + ): ValidationResult<Output> { 64 + const result = ctx.validate(input, this.schema) as ValidationResult<Output>; 65 + 66 + if (!result.success) { 67 + return result; 68 + } 69 + 70 + if (this.$type !== result.value.$type) { 71 + return ctx.issueInvalidPropertyValue(result.value, "$type", [this.$type]); 72 + } 73 + 74 + return result; 75 + } 76 + } 77 + 78 + export type RecordKeySchemaOutput<Key extends RecordKey> = Key extends "any" 79 + ? string 80 + : Key extends "tid" ? string 81 + : Key extends "nsid" ? Nsid 82 + : Key extends `literal:${infer L extends string}` ? L 83 + : never; 84 + 85 + export type RecordKeySchema<Key extends RecordKey> = Validator< 86 + RecordKeySchemaOutput<Key> 87 + >; 88 + 89 + const keySchema = new StringSchema({ minLength: 1 }); 90 + const tidSchema = new StringSchema({ format: "tid" }); 91 + const nsidSchema = new StringSchema({ format: "nsid" }); 92 + const selfLiteralSchema = new LiteralSchema("self"); 93 + 94 + function recordKey<Key extends RecordKey>(key: Key): RecordKeySchema<Key> { 95 + // @NOTE Use cached instances for common schemas 96 + if (key === "any") return keySchema as unknown as RecordKeySchema<Key>; 97 + if (key === "tid") return tidSchema as unknown as RecordKeySchema<Key>; 98 + if (key === "nsid") return nsidSchema as unknown as RecordKeySchema<Key>; 99 + if (key.startsWith("literal:")) { 100 + const value = key.slice(8) as RecordKeySchemaOutput<Key>; 101 + if (value === "self") { 102 + return selfLiteralSchema as unknown as RecordKeySchema<Key>; 103 + } 104 + return new LiteralSchema(value); 105 + } 106 + 107 + throw new Error(`Unsupported record key type: ${key}`); 108 + }
+51
lex/schema/schema/ref.ts
··· 1 + import { 2 + type ValidationResult, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + export type RefSchemaGetter<V> = () => Validator<V>; 8 + 9 + export class RefSchema<V = unknown> extends Validator<V> { 10 + override readonly lexiconType = "ref" as const; 11 + 12 + #getter: RefSchemaGetter<V>; 13 + 14 + constructor(getter: RefSchemaGetter<V>) { 15 + // @NOTE In order to avoid circular dependency issues, we don't resolve 16 + // the schema here. Instead, we resolve it lazily when first accessed. 17 + 18 + super(); 19 + 20 + this.#getter = getter; 21 + } 22 + 23 + get schema(): Validator<V> { 24 + const value = this.#getter.call(null); 25 + 26 + // Prevents a getter from depending on itself recursively, also allows GC to 27 + // clean up the getter function. 28 + this.#getter = throwAlreadyCalled; 29 + 30 + // Disable the getter and cache the resolved schema on the instance 31 + Object.defineProperty(this, "schema", { 32 + value, 33 + writable: false, 34 + enumerable: false, 35 + configurable: true, 36 + }); 37 + 38 + return value; 39 + } 40 + 41 + validateInContext( 42 + input: unknown, 43 + ctx: ValidatorContext, 44 + ): ValidationResult<V> { 45 + return ctx.validate(input, this.schema); 46 + } 47 + } 48 + 49 + function throwAlreadyCalled(): never { 50 + throw new Error("RefSchema getter called multiple times"); 51 + }
+143
lex/schema/schema/string.ts
··· 1 + import { CID, graphemeLen, utf8Len } from "@atp/data"; 2 + import { 3 + assertStringFormat, 4 + type InferStringFormat, 5 + type StringFormat, 6 + type UnknownString, 7 + } from "../core.ts"; 8 + import { 9 + type ValidationResult, 10 + Validator, 11 + type ValidatorContext, 12 + } from "../validation.ts"; 13 + import { TokenSchema } from "./token.ts"; 14 + 15 + export type StringSchemaOptions = { 16 + default?: string; 17 + knownValues?: readonly string[]; 18 + format?: StringFormat; 19 + minLength?: number; 20 + maxLength?: number; 21 + minGraphemes?: number; 22 + maxGraphemes?: number; 23 + }; 24 + 25 + export type StringSchemaOutput<Options> = 26 + // 27 + Options extends { format: infer F extends StringFormat } 28 + ? InferStringFormat<F> 29 + : Options extends { knownValues: readonly (infer K extends string)[] } 30 + ? K | UnknownString 31 + : string; 32 + 33 + export class StringSchema< 34 + const Options extends StringSchemaOptions = StringSchemaOptions, 35 + > extends Validator<StringSchemaOutput<Options>> { 36 + override readonly lexiconType = "string" as const; 37 + 38 + constructor(readonly options: Options) { 39 + super(); 40 + } 41 + 42 + override validateInContext( 43 + // @NOTE validation will be applied on the default value as well 44 + input: unknown = this.options.default, 45 + ctx: ValidatorContext, 46 + ): ValidationResult<StringSchemaOutput<Options>> { 47 + const { options } = this; 48 + 49 + const str = coerceToString(input); 50 + if (str == null) { 51 + return ctx.issueInvalidType(input, "string"); 52 + } 53 + 54 + let lazyUtf8Len: number; 55 + 56 + const { minLength } = options; 57 + if (minLength != null) { 58 + if ((lazyUtf8Len ??= utf8Len(str)) < minLength) { 59 + return ctx.issueTooSmall(str, "string", minLength, lazyUtf8Len); 60 + } 61 + } 62 + 63 + const { maxLength } = options; 64 + if (maxLength != null) { 65 + // Optimization: we can avoid computing the UTF-8 length if the maximum 66 + // possible length, in bytes, of the input JS string is smaller than the 67 + // maxLength (in UTF-8 string bytes). 68 + if (str.length * 3 <= maxLength) { 69 + // Input string so small it can't possibly exceed maxLength 70 + } else if ((lazyUtf8Len ??= utf8Len(str)) > maxLength) { 71 + return ctx.issueTooBig(str, "string", maxLength, lazyUtf8Len); 72 + } 73 + } 74 + 75 + let lazyGraphLen: number; 76 + 77 + const { minGraphemes } = options; 78 + if (minGraphemes != null) { 79 + // Optimization: avoid counting graphemes if the length check already fails 80 + if (str.length < minGraphemes) { 81 + return ctx.issueTooSmall(str, "grapheme", minGraphemes, str.length); 82 + } else if ((lazyGraphLen ??= graphemeLen(str)) < minGraphemes) { 83 + return ctx.issueTooSmall(str, "grapheme", minGraphemes, lazyGraphLen); 84 + } 85 + } 86 + 87 + const { maxGraphemes } = options; 88 + if (maxGraphemes != null) { 89 + if ((lazyGraphLen ??= graphemeLen(str)) > maxGraphemes) { 90 + return ctx.issueTooBig(str, "grapheme", maxGraphemes, lazyGraphLen); 91 + } 92 + } 93 + 94 + if (options.format !== undefined) { 95 + try { 96 + // @TODO optimize to avoid throw cost (requires re-writing utilities 97 + // from @atproto/syntax) 98 + assertStringFormat(str, options.format); 99 + } catch (err) { 100 + const message = err instanceof Error ? err.message : undefined; 101 + return ctx.issueInvalidFormat(str, options.format, message); 102 + } 103 + } 104 + 105 + return ctx.success(str as StringSchemaOutput<Options>); 106 + } 107 + } 108 + 109 + export function coerceToString(input: unknown): string | null { 110 + switch (typeof input) { 111 + case "string": 112 + return input; 113 + case "object": { 114 + if (input == null) return null; 115 + 116 + // @NOTE Allow using TokenSchema instances in places expecting strings, 117 + // converting them to their string value. 118 + if (input instanceof TokenSchema) { 119 + return input.toString(); 120 + } 121 + 122 + if (input instanceof Date) { 123 + if (Number.isNaN(input.getTime())) return null; 124 + return input.toISOString(); 125 + } 126 + 127 + if (input instanceof URL) { 128 + return input.toString(); 129 + } 130 + 131 + const cid = CID.asCID(input); 132 + if (cid) return cid.toString(); 133 + 134 + if (input instanceof String) { 135 + return input.valueOf(); 136 + } 137 + } 138 + 139 + // falls through 140 + default: 141 + return null; 142 + } 143 + }
+33
lex/schema/schema/subscription.ts
··· 1 + import type { Infer } from "../validation.ts"; 2 + import type { ObjectSchema } from "./object.ts"; 3 + import type { ParamsSchema } from "./params.ts"; 4 + import type { RefSchema } from "./ref.ts"; 5 + import type { TypedUnionSchema } from "./typed-union.ts"; 6 + 7 + export type InferSubscriptionParameters<S extends Subscription> = S extends 8 + Subscription<string, infer P extends ParamsSchema> ? Infer<P> 9 + : never; 10 + 11 + export type InferSubscriptionMessage<S extends Subscription> = S extends 12 + Subscription< 13 + string, 14 + ParamsSchema, 15 + infer M extends RefSchema | TypedUnionSchema | ObjectSchema 16 + > ? Infer<M> 17 + : unknown; 18 + 19 + export class Subscription< 20 + N extends string = string, 21 + P extends ParamsSchema = ParamsSchema, 22 + M extends undefined | RefSchema | TypedUnionSchema | ObjectSchema = undefined, 23 + E extends undefined | readonly string[] = undefined, 24 + > { 25 + readonly type = "subscription" as const; 26 + 27 + constructor( 28 + readonly nsid: N, 29 + readonly parameters: P, 30 + readonly message: M, 31 + readonly errors: E, 32 + ) {} 33 + }
+45
lex/schema/schema/token.ts
··· 1 + import { 2 + type ValidationResult, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + export class TokenSchema<V extends string = string> extends Validator<V> { 8 + override readonly lexiconType = "token" as const; 9 + 10 + constructor(protected readonly value: V) { 11 + super(); 12 + } 13 + 14 + override validateInContext( 15 + input: unknown, 16 + ctx: ValidatorContext, 17 + ): ValidationResult<V> { 18 + if (input === this.value) { 19 + return ctx.success(this.value); 20 + } 21 + 22 + // @NOTE: allow using the token instance itself (but convert to the actual 23 + // token value) 24 + if (input instanceof TokenSchema && input.value === this.value) { 25 + return ctx.success(this.value); 26 + } 27 + 28 + if (typeof input !== "string") { 29 + return ctx.issueInvalidType(input, "token"); 30 + } 31 + 32 + return ctx.issueInvalidValue(input, [this.value]); 33 + } 34 + 35 + // When using the TokenSchema instance as data, let's serialize it to the 36 + // token value 37 + 38 + toJSON(): string { 39 + return this.value; 40 + } 41 + 42 + override toString(): string { 43 + return this.value; 44 + } 45 + }
+66
lex/schema/schema/typed-object.ts
··· 1 + import { isPlainObject } from "@atp/data"; 2 + import type { $Type, Simplify } from "../core.ts"; 3 + import { 4 + type Infer, 5 + type ValidationResult, 6 + Validator, 7 + type ValidatorContext, 8 + } from "../validation.ts"; 9 + 10 + export class TypedObjectSchema< 11 + Type extends $Type = $Type, 12 + Schema extends Validator<Record<string, unknown>> = Validator< 13 + Record<string, unknown> 14 + >, 15 + Output extends Infer<Schema> & { $type?: Type } = Infer<Schema> & { 16 + $type?: Type; 17 + }, 18 + > extends Validator<Output> { 19 + override readonly lexiconType = "object" as const; 20 + 21 + constructor( 22 + readonly $type: Type, 23 + readonly schema: Schema, 24 + ) { 25 + super(); 26 + } 27 + 28 + isTypeOf<X extends { $type?: unknown }>( 29 + value: X, 30 + ): value is X extends { $type?: Type } ? X : never { 31 + return value.$type === undefined || value.$type === this.$type; 32 + } 33 + 34 + build<X extends Omit<Output, "$type">>( 35 + input: X, 36 + ): Simplify<Omit<X, "$type"> & { $type: Type }> { 37 + return { ...input, $type: this.$type }; 38 + } 39 + 40 + $isTypeOf<X extends { $type?: unknown }>(value: X) { 41 + return this.isTypeOf<X>(value); 42 + } 43 + 44 + $build<X extends Omit<Output, "$type">>(input: X) { 45 + return this.build<X>(input); 46 + } 47 + 48 + override validateInContext( 49 + input: unknown, 50 + ctx: ValidatorContext, 51 + ): ValidationResult<Output> { 52 + if (!isPlainObject(input)) { 53 + return ctx.issueInvalidType(input, "object"); 54 + } 55 + 56 + if ( 57 + "$type" in input && 58 + input.$type !== undefined && 59 + input.$type !== this.$type 60 + ) { 61 + return ctx.issueInvalidPropertyValue(input, "$type", [this.$type]); 62 + } 63 + 64 + return ctx.validate(input, this.schema as Validator<Output>); 65 + } 66 + }
+74
lex/schema/schema/typed-ref.ts
··· 1 + import { 2 + type ValidationResult, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + // Basically a RecordSchema or TypedObjectSchema 8 + export type TypedRefSchemaValidator< 9 + V extends { $type?: string } = { $type?: string }, 10 + > = V extends { $type?: infer T extends string } 11 + ? { $type: T } & Validator<V & { $type?: T }> 12 + : never; 13 + 14 + export type TypedRefGetter<V extends { $type?: string } = { $type?: string }> = 15 + () => TypedRefSchemaValidator<V>; 16 + 17 + export type TypedRefSchemaOutput< 18 + V extends { $type?: string } = { $type?: string }, 19 + > = V extends { $type?: infer T extends string } ? V & { $type: T } : never; 20 + 21 + export class TypedRefSchema< 22 + V extends { $type?: string } = { $type?: string }, 23 + > extends Validator<TypedRefSchemaOutput<V>> { 24 + #getter: TypedRefGetter<V>; 25 + 26 + constructor(getter: TypedRefGetter<V>) { 27 + // @NOTE In order to avoid circular dependency issues, we don't resolve 28 + // the schema here. Instead, we resolve it lazily when first accessed. 29 + 30 + super(); 31 + 32 + this.#getter = getter; 33 + } 34 + 35 + get schema(): TypedRefSchemaValidator<V> { 36 + const value = this.#getter.call(null); 37 + 38 + // Prevents a getter from depending on itself recursively, also allows GC to 39 + // clean up the getter function. 40 + this.#getter = throwAlreadyCalled; 41 + 42 + // Cache the resolved schema on the instance 43 + Object.defineProperty(this, "schema", { 44 + value, 45 + writable: false, 46 + enumerable: false, 47 + configurable: true, 48 + }); 49 + 50 + return value; 51 + } 52 + 53 + get $type(): TypedRefSchemaOutput<V>["$type"] { 54 + return this.schema.$type; 55 + } 56 + 57 + override validateInContext( 58 + input: unknown, 59 + ctx: ValidatorContext, 60 + ): ValidationResult<TypedRefSchemaOutput<V>> { 61 + const result = ctx.validate(input, this.schema); 62 + if (!result.success) return result; 63 + 64 + if (result.value.$type !== this.$type) { 65 + return ctx.issueInvalidPropertyValue(result.value, "$type", [this.$type]); 66 + } 67 + 68 + return result as ValidationResult<TypedRefSchemaOutput<V>>; 69 + } 70 + } 71 + 72 + function throwAlreadyCalled(): never { 73 + throw new Error("TypedRefSchema getter called multiple times"); 74 + }
+107
lex/schema/schema/typed-union.ts
··· 1 + import { isPlainObject } from "@atp/data"; 2 + import type { Restricted, UnknownString } from "../core.ts"; 3 + import { 4 + type Infer, 5 + type ValidationResult, 6 + Validator, 7 + type ValidatorContext, 8 + } from "../validation.ts"; 9 + import type { TypedRefSchema, TypedRefSchemaOutput } from "./typed-ref.ts"; 10 + 11 + export type TypedRef<T extends { $type?: string }> = TypedRefSchemaOutput<T>; 12 + 13 + export type TypedObject = 14 + & { $type: UnknownString } 15 + & { 16 + // In order to prevent places that expect an open union from accepting an 17 + // invalid version of the known typed objects, we need to prevent any other 18 + // properties from being present. 19 + // 20 + // For example, if an open union expects: 21 + // ```ts 22 + // TypedObject | { $type: 'A'; a: number } 23 + // ``` 24 + // we don't want it to accept: 25 + // ```ts 26 + // { $type: 'A' } 27 + // ``` 28 + // Which would be the case as `{ $type: 'A' }` is a valid 29 + // `TypedObject`. By adding an index signature that forbids any 30 + // property, we ensure that only valid known typed objects can be used. 31 + [K in string]: Restricted<"Unknown property">; 32 + }; 33 + 34 + type TypedRefSchemasToUnion<T extends readonly TypedRefSchema[]> = { 35 + [K in keyof T]: Infer<T[K]>; 36 + }[number]; 37 + 38 + export type TypedUnionSchemaOutput< 39 + TypedRefs extends readonly TypedRefSchema[], 40 + Closed extends boolean, 41 + > = Closed extends true ? TypedRefSchemasToUnion<TypedRefs> 42 + : TypedRefSchemasToUnion<TypedRefs> | TypedObject; 43 + 44 + export class TypedUnionSchema< 45 + TypedRefs extends readonly TypedRefSchema[] = TypedRefSchema[], 46 + Closed extends boolean = boolean, 47 + > extends Validator<TypedUnionSchemaOutput<TypedRefs, Closed>> { 48 + override readonly lexiconType = "union" as const; 49 + 50 + constructor( 51 + protected readonly refs: TypedRefs, 52 + public readonly closed: Closed, 53 + ) { 54 + // @NOTE In order to avoid circular dependency issues, we don't access the 55 + // refs's schema (or $type) here. Instead, we access them lazily when first 56 + // needed. 57 + 58 + super(); 59 + } 60 + 61 + protected get refsMap() { 62 + const map = new Map<unknown, TypedRefs[number]>(); 63 + for (const ref of this.refs) map.set(ref.$type, ref); 64 + 65 + // Cache the map on the instance 66 + Object.defineProperty(this, "refsMap", { 67 + value: map, 68 + writable: false, 69 + enumerable: false, 70 + configurable: true, 71 + }); 72 + 73 + return map; 74 + } 75 + 76 + get $types() { 77 + return Array.from(this.refsMap.keys()); 78 + } 79 + 80 + override validateInContext( 81 + input: unknown, 82 + ctx: ValidatorContext, 83 + ): ValidationResult<TypedUnionSchemaOutput<TypedRefs, Closed>> { 84 + if (!isPlainObject(input) || !("$type" in input)) { 85 + return ctx.issueInvalidType(input, "$typed"); 86 + } 87 + 88 + const { $type } = input; 89 + 90 + const def = this.refsMap.get($type); 91 + if (def) { 92 + const result = ctx.validate(input, def); 93 + return result as ValidationResult< 94 + TypedUnionSchemaOutput<TypedRefs, Closed> 95 + >; 96 + } 97 + 98 + if (this.closed) { 99 + return ctx.issueInvalidPropertyValue(input, "$type", this.$types); 100 + } 101 + if (typeof $type !== "string") { 102 + return ctx.issueInvalidPropertyType(input, "$type", "string"); 103 + } 104 + 105 + return ctx.success(input as TypedUnionSchemaOutput<TypedRefs, Closed>); 106 + } 107 + }
+42
lex/schema/schema/union.ts
··· 1 + import { 2 + type Infer, 3 + ValidationError, 4 + type ValidationFailure, 5 + type ValidationResult, 6 + Validator, 7 + type ValidatorContext, 8 + } from "../validation.ts"; 9 + 10 + export type UnionSchemaValidators = readonly [Validator, ...Validator[]]; 11 + export type UnionSchemaOutput<V extends readonly Validator[]> = Infer< 12 + V[number] 13 + >; 14 + 15 + export class UnionSchema< 16 + V extends UnionSchemaValidators = UnionSchemaValidators, 17 + > extends Validator<UnionSchemaOutput<V>> { 18 + constructor(protected readonly validators: V) { 19 + super(); 20 + } 21 + 22 + override validateInContext( 23 + input: unknown, 24 + ctx: ValidatorContext, 25 + ): ValidationResult<UnionSchemaOutput<V>> { 26 + const failures: ValidationFailure[] = []; 27 + 28 + for (const validator of this.validators) { 29 + const result = ctx.validate(input, validator); 30 + if (result.success) { 31 + return result as ValidationResult<UnionSchemaOutput<V>>; 32 + } else { 33 + failures.push(result); 34 + } 35 + } 36 + 37 + return { 38 + success: false, 39 + error: ValidationError.fromFailures(failures), 40 + }; 41 + } 42 + }
+24
lex/schema/schema/unknown-object.ts
··· 1 + import { isLexMap, type LexMap } from "@atp/data"; 2 + import { 3 + type ValidationResult, 4 + Validator, 5 + type ValidatorContext, 6 + } from "../validation.ts"; 7 + 8 + export type { LexMap }; 9 + export type UnknownObjectOutput = LexMap; 10 + 11 + export class UnknownObjectSchema extends Validator<UnknownObjectOutput> { 12 + override readonly lexiconType = "unknown" as const; 13 + 14 + override validateInContext( 15 + input: unknown, 16 + ctx: ValidatorContext, 17 + ): ValidationResult<UnknownObjectOutput> { 18 + if (isLexMap(input)) { 19 + return ctx.success(input); 20 + } 21 + 22 + return ctx.issueInvalidType(input, "unknown"); 23 + } 24 + }
+14
lex/schema/schema/unknown.ts
··· 1 + import { 2 + type ValidationResult, 3 + Validator, 4 + type ValidatorContext, 5 + } from "../validation.ts"; 6 + 7 + export class UnknownSchema extends Validator<unknown> { 8 + override validateInContext( 9 + input: unknown, 10 + ctx: ValidatorContext, 11 + ): ValidationResult<unknown> { 12 + return ctx.success(input); 13 + } 14 + }
+40
lex/schema/tests/array-agg.test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { arrayAgg } from "../util/array-agg.ts"; 3 + 4 + Deno.test("arrayAgg aggregates items based on comparison and aggregation functions", () => { 5 + const input = [1, 1, 2, 2, 3, 3, 3]; 6 + const result = arrayAgg( 7 + input, 8 + (a, b) => a === b, 9 + (items) => ({ value: items[0], count: items.length }), 10 + ); 11 + assertEquals(result, [ 12 + { value: 1, count: 2 }, 13 + { value: 2, count: 2 }, 14 + { value: 3, count: 3 }, 15 + ]); 16 + }); 17 + 18 + Deno.test("arrayAgg returns an empty array when input is empty", () => { 19 + const input: number[] = []; 20 + const result = arrayAgg( 21 + input, 22 + (a, b) => a === b, 23 + (items) => ({ value: items[0], count: items.length }), 24 + ); 25 + assertEquals(result, []); 26 + }); 27 + 28 + Deno.test("arrayAgg handles non-consecutive grouping", () => { 29 + const input = [1, 2, 1, 2, 3, 1]; 30 + const result = arrayAgg( 31 + input, 32 + (a, b) => a === b, 33 + (items) => ({ value: items[0], count: items.length }), 34 + ); 35 + assertEquals(result, [ 36 + { value: 1, count: 3 }, 37 + { value: 2, count: 2 }, 38 + { value: 3, count: 1 }, 39 + ]); 40 + });
+131
lex/schema/tests/object.test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { BooleanSchema } from "../schema/boolean.ts"; 3 + import { DictSchema } from "../schema/dict.ts"; 4 + import { EnumSchema } from "../schema/enum.ts"; 5 + import { IntegerSchema } from "../schema/integer.ts"; 6 + import { ObjectSchema } from "../schema/object.ts"; 7 + import { StringSchema } from "../schema/string.ts"; 8 + 9 + const simpleSchema = new ObjectSchema( 10 + { 11 + name: new StringSchema({}), 12 + age: new IntegerSchema({}), 13 + gender: new EnumSchema(["male", "female"]), 14 + }, 15 + { 16 + required: ["name"], 17 + nullable: ["gender"], 18 + }, 19 + ); 20 + 21 + Deno.test("ObjectSchema simple schema validates plain objects", () => { 22 + const result = simpleSchema.validate({ 23 + name: "Alice", 24 + age: 30, 25 + gender: "female", 26 + }); 27 + assertEquals(result.success, true); 28 + }); 29 + 30 + Deno.test("ObjectSchema simple schema rejects non-objects", () => { 31 + const result = simpleSchema.validate("not an object"); 32 + assertEquals(result.success, false); 33 + }); 34 + 35 + Deno.test("ObjectSchema simple schema rejects missing properties", () => { 36 + const result = simpleSchema.validate({ 37 + age: 30, 38 + gender: "female", 39 + }); 40 + assertEquals(result.success, false); 41 + }); 42 + 43 + Deno.test("ObjectSchema simple schema validates optional properties", () => { 44 + const result = simpleSchema.validate({ 45 + name: "Alice", 46 + }); 47 + assertEquals(result.success, true); 48 + }); 49 + 50 + Deno.test("ObjectSchema simple schema validates nullable properties", () => { 51 + const result = simpleSchema.validate({ 52 + name: "Alice", 53 + gender: null, 54 + }); 55 + assertEquals(result.success, true); 56 + }); 57 + 58 + Deno.test("ObjectSchema simple schema rejects invalid property types", () => { 59 + const result = simpleSchema.validate({ 60 + name: "Alice", 61 + age: "thirty", 62 + }); 63 + assertEquals(result.success, false); 64 + }); 65 + 66 + Deno.test("ObjectSchema simple schema ignores extra properties", () => { 67 + const result = simpleSchema.validate({ 68 + name: "Alice", 69 + age: 30, 70 + extra: "value", 71 + }); 72 + assertEquals(result.success, true); 73 + }); 74 + 75 + const strictSchema = new ObjectSchema( 76 + { 77 + id: new StringSchema({}), 78 + score: new IntegerSchema({}), 79 + }, 80 + { 81 + required: ["id", "score"], 82 + unknownProperties: "strict", 83 + }, 84 + ); 85 + 86 + Deno.test("ObjectSchema strict schema rejects extra properties", () => { 87 + const result = strictSchema.validate({ 88 + id: "item1", 89 + score: 100, 90 + extra: "not allowed", 91 + }); 92 + assertEquals(result.success, false); 93 + }); 94 + 95 + Deno.test("ObjectSchema strict schema accepts only defined properties", () => { 96 + const result = strictSchema.validate({ 97 + id: "item1", 98 + score: 100, 99 + }); 100 + assertEquals(result.success, true); 101 + }); 102 + 103 + const unknownPropertiesSchema = new ObjectSchema( 104 + { 105 + title: new StringSchema({}), 106 + }, 107 + { 108 + required: ["title"], 109 + unknownProperties: new DictSchema( 110 + new EnumSchema(["tag1", "tag2"]), 111 + new BooleanSchema({}), 112 + ), 113 + }, 114 + ); 115 + 116 + Deno.test("schema with unknownProperties validator validates extra properties with the provided validator", () => { 117 + const result = unknownPropertiesSchema.validate({ 118 + title: "My Post", 119 + tag1: true, 120 + tag2: false, 121 + }); 122 + assertEquals(result.success, true); 123 + }); 124 + 125 + Deno.test("schema with unknownProperties rejects extra properties that fail the provided validator", () => { 126 + const result = unknownPropertiesSchema.validate({ 127 + title: "My Post", 128 + tag1: "not a boolean", 129 + }); 130 + assertEquals(result.success, false); 131 + });
+43
lex/schema/util/array-agg.ts
··· 1 + /** 2 + * Aggregates items in an array based on a comparison function and an aggregation function. 3 + * 4 + * @param arr - The input array to aggregate. 5 + * @param cmp - A comparison function that determines if two items belong to the same group. 6 + * @param agg - An aggregation function that combines items in a group into a single item. 7 + * @returns An array of aggregated items. 8 + * @example 9 + * ```ts 10 + * const input = [1, 1, 2, 2, 3, 3, 3] 11 + * const result = arrayAgg( 12 + * input, 13 + * (a, b) => a === b, 14 + * (items) => { value: items[0], sum: items.reduce((sum, item) => sum + item, 0) }, 15 + * ) 16 + * // result is [{ value: 1, sum: 2 }, { value: 2, sum: 4 }, { value: 3, sum: 6 }] 17 + * ``` 18 + */ 19 + export function arrayAgg<T, O>( 20 + arr: readonly T[], 21 + cmp: (a: T, b: T) => boolean, 22 + agg: (items: [T, ...T[]]) => O, 23 + ): O[] { 24 + if (arr.length === 0) return []; 25 + 26 + const groups: [T, ...T[]][] = [[arr[0]]]; 27 + const skipped = Array<undefined | boolean>(arr.length); 28 + 29 + outer: for (let i = 1; i < arr.length; i++) { 30 + if (skipped[i]) continue; 31 + const item = arr[i]; 32 + for (let j = 0; j < groups.length; j++) { 33 + if (cmp(item, groups[j][0])) { 34 + groups[j].push(item); 35 + skipped[i] = true; 36 + continue outer; 37 + } 38 + } 39 + groups.push([item]); 40 + } 41 + 42 + return groups.map(agg); 43 + }
+4
lex/schema/validation.ts
··· 1 + export * from "./validation/property-key.ts"; 2 + export * from "./validation/validation-error.ts"; 3 + export * from "./validation/validation-issue.ts"; 4 + export * from "./validation/validator.ts";
+1
lex/schema/validation/property-key.ts
··· 1 + export type PropertyKey = string | number;
+32
lex/schema/validation/validation-error.ts
··· 1 + import { failureError, type ResultFailure } from "../core.ts"; 2 + import { 3 + aggregateIssues, 4 + stringifyIssue, 5 + type ValidationIssue, 6 + } from "./validation-issue.ts"; 7 + 8 + export class ValidationError extends Error { 9 + override name = "ValidationError"; 10 + 11 + constructor( 12 + readonly issues: ValidationIssue[], 13 + options?: ErrorOptions, 14 + ) { 15 + super(issues.map(stringifyIssue).join(", "), options); 16 + } 17 + 18 + static fromFailures( 19 + failures: ResultFailure<ValidationError>[], 20 + ): ValidationError { 21 + if (failures.length === 1) return failures[0].error; 22 + const issues = failures.flatMap(extractFailureIssues); 23 + return new ValidationError(aggregateIssues(issues), { 24 + // Keep the original errors as the cause chain 25 + cause: failures.map(failureError), 26 + }); 27 + } 28 + } 29 + 30 + function extractFailureIssues(result: ResultFailure<ValidationError>) { 31 + return result.error.issues; 32 + }
+239
lex/schema/validation/validation-issue.ts
··· 1 + import { CID, isPlainObject } from "@atp/data"; 2 + import { arrayAgg } from "../util/array-agg.ts"; 3 + import type { PropertyKey } from "./property-key.ts"; 4 + 5 + export interface Issue<I = unknown> { 6 + readonly input: I; 7 + readonly code: string; 8 + readonly message?: string; 9 + readonly path: readonly PropertyKey[]; 10 + } 11 + 12 + export interface IssueCustom extends Issue { 13 + readonly code: "custom"; 14 + readonly message: string; 15 + } 16 + 17 + export interface IssueInvalidFormat extends Issue { 18 + readonly code: "invalid_format"; 19 + readonly format: string; 20 + } 21 + 22 + export interface IssueInvalidType extends Issue { 23 + readonly code: "invalid_type"; 24 + readonly expected: readonly string[]; 25 + } 26 + 27 + export interface IssueInvalidValue extends Issue { 28 + readonly code: "invalid_value"; 29 + readonly values: readonly unknown[]; 30 + } 31 + 32 + export interface IssueRequiredKey extends Issue { 33 + readonly code: "required_key"; 34 + readonly key: PropertyKey; 35 + } 36 + 37 + export interface IssueTooBig extends Issue { 38 + readonly code: "too_big"; 39 + readonly maximum: number; 40 + readonly type: "array" | "string" | "integer" | "grapheme" | "bytes" | "blob"; 41 + readonly actual: number; 42 + } 43 + 44 + export interface IssueTooSmall extends Issue { 45 + readonly code: "too_small"; 46 + readonly minimum: number; 47 + readonly type: "array" | "string" | "integer" | "grapheme" | "bytes"; 48 + readonly actual: number; 49 + } 50 + 51 + export type ValidationIssue = 52 + | IssueCustom 53 + | IssueInvalidFormat 54 + | IssueInvalidType 55 + | IssueInvalidValue 56 + | IssueRequiredKey 57 + | IssueTooBig 58 + | IssueTooSmall; 59 + 60 + export function stringifyIssue(issue: ValidationIssue): string { 61 + const pathStr = issue.path.length ? ` at ${buildJsonPath(issue.path)}` : ""; 62 + 63 + switch (issue.code) { 64 + case "invalid_format": 65 + return `Invalid ${stringifyStringFormat(issue.format)} format${ 66 + issue.message ? ` (${issue.message})` : "" 67 + }${pathStr} (got ${stringifyValue(issue.input)})`; 68 + case "invalid_type": 69 + return `Expected ${ 70 + oneOf(issue.expected.map(stringifyExpectedType)) 71 + } value type${pathStr} (got ${stringifyType(issue.input)})`; 72 + case "invalid_value": 73 + return `Expected ${ 74 + oneOf(issue.values.map(stringifyValue)) 75 + }${pathStr} (got ${stringifyValue(issue.input)})`; 76 + case "required_key": 77 + return `Missing required key "${String(issue.key)}"${pathStr}`; 78 + case "too_big": 79 + return `${issue.type} too big (maximum ${issue.maximum})${pathStr} (got ${issue.actual})`; 80 + case "too_small": 81 + return `${issue.type} too small (minimum ${issue.minimum})${pathStr} (got ${issue.actual})`; 82 + case "custom": 83 + return `${issue.message}${pathStr}`; 84 + default: 85 + // @ts-expect-error fool-proofing 86 + return `${issue.code} validation error${pathStr}`; 87 + } 88 + } 89 + 90 + function stringifyExpectedType(expected: string): string { 91 + if (expected === "$typed") { 92 + return 'an object or record which includes a "$type" property'; 93 + } 94 + 95 + return expected; 96 + } 97 + 98 + function buildJsonPath(path: readonly PropertyKey[]): string { 99 + let jsonPath = "$"; 100 + for (const segment of path) { 101 + if (typeof segment === "number") { 102 + jsonPath += `[${segment}]`; 103 + } else if (/^[a-zA-Z_$][a-zA-Z0-9_]*$/.test(segment as string)) { 104 + jsonPath += `.${segment}`; 105 + } else { 106 + jsonPath += `[${JSON.stringify(segment)}]`; 107 + } 108 + } 109 + return jsonPath; 110 + } 111 + 112 + function oneOf(arr: readonly string[]): string { 113 + if (arr.length === 0) return ""; 114 + if (arr.length === 1) return arr[0]; 115 + return `one of ${arr.slice(0, -1).join(", ")} or ${arr.at(-1)}`; 116 + } 117 + 118 + function stringifyStringFormat(format: string): string { 119 + switch (format) { 120 + case "datetime": 121 + return "datetime"; 122 + case "language": 123 + return "language"; 124 + case "at-identifier": 125 + return `AT identifier`; 126 + case "did": 127 + return `DID`; 128 + case "handle": 129 + return `handle`; 130 + case "nsid": 131 + return `NSID`; 132 + case "cid": 133 + return `CID string`; 134 + case "tid": 135 + return `TID string`; 136 + case "record-key": 137 + return `record key`; 138 + default: 139 + return format; 140 + } 141 + } 142 + 143 + export function stringifyType(value: unknown): string { 144 + switch (typeof value) { 145 + case "object": 146 + if (value === null) return "null"; 147 + if (Array.isArray(value)) return "array"; 148 + if (CID.asCID(value)) return "cid"; 149 + if (value instanceof Date) return "date"; 150 + if (value instanceof RegExp) return "regexp"; 151 + if (value instanceof Map) return "map"; 152 + if (value instanceof Set) return "set"; 153 + return "object"; 154 + case "number": 155 + if (Number.isInteger(value)) return "integer"; 156 + if (Number.isNaN(value)) return "NaN"; 157 + return "float"; 158 + default: 159 + return typeof value; 160 + } 161 + } 162 + 163 + export function stringifyValue(value: unknown): string { 164 + switch (typeof value) { 165 + case "bigint": 166 + return `${value}n`; 167 + case "number": 168 + case "string": 169 + case "boolean": 170 + return JSON.stringify(value); 171 + case "object": 172 + if (Array.isArray(value)) { 173 + return `[${stringifyArray(value, stringifyValue)}]`; 174 + } 175 + if (isPlainObject(value)) { 176 + return `{${ 177 + stringifyArray(Object.entries(value), stringifyObjectEntry) 178 + }}`; 179 + } 180 + // fallthrough 181 + default: 182 + return stringifyType(value); 183 + } 184 + } 185 + 186 + function stringifyObjectEntry([key, _value]: [PropertyKey, unknown]): string { 187 + return `${JSON.stringify(key)}: ...`; 188 + } 189 + 190 + function stringifyArray<T>( 191 + arr: readonly T[], 192 + fn: (item: T) => string, 193 + n = 2, 194 + ): string { 195 + return arr.slice(0, n).map(fn).join(", ") + (arr.length > n ? ", ..." : ""); 196 + } 197 + 198 + export function aggregateIssues(issues: ValidationIssue[]): ValidationIssue[] { 199 + // Quick path for common cases 200 + if (issues.length <= 1) return issues; 201 + if (issues.length === 2 && issues[0].code !== issues[1].code) return issues; 202 + 203 + return [ 204 + // Aggregate invalid_type with identical paths 205 + ...arrayAgg( 206 + issues.filter((issue) => issue.code === "invalid_type"), 207 + (a, b) => comparePropertyPaths(a.path, b.path), 208 + (issues) => ({ 209 + ...issues[0], 210 + expected: Array.from(new Set(issues.flatMap((iss) => iss.expected))), 211 + }), 212 + ), 213 + // Aggregate invalid_value with identical paths 214 + ...arrayAgg( 215 + issues.filter((issue) => issue.code === "invalid_value"), 216 + (a, b) => comparePropertyPaths(a.path, b.path), 217 + (issues) => ({ 218 + ...issues[0], 219 + values: Array.from(new Set(issues.flatMap((iss) => iss.values))), 220 + }), 221 + ), 222 + // Pass through other issues 223 + ...issues.filter( 224 + (issue) => 225 + issue.code !== "invalid_type" && issue.code !== "invalid_value", 226 + ), 227 + ]; 228 + } 229 + 230 + function comparePropertyPaths( 231 + a: readonly PropertyKey[], 232 + b: readonly PropertyKey[], 233 + ) { 234 + if (a.length !== b.length) return false; 235 + for (let i = 0; i < a.length; i++) { 236 + if (a[i] !== b[i]) return false; 237 + } 238 + return true; 239 + }
+368
lex/schema/validation/validator.ts
··· 1 + import { 2 + failure, 3 + type ResultFailure, 4 + type ResultSuccess, 5 + success, 6 + } from "../core.ts"; 7 + import type { PropertyKey } from "./property-key.ts"; 8 + import { ValidationError } from "./validation-error.ts"; 9 + import type { 10 + IssueTooBig, 11 + IssueTooSmall, 12 + ValidationIssue, 13 + } from "./validation-issue.ts"; 14 + 15 + export type ValidationSuccess<Value = unknown> = ResultSuccess<Value>; 16 + export type ValidationFailure = ResultFailure<ValidationError>; 17 + export type ValidationResult<Value = unknown> = 18 + | ValidationSuccess<Value> 19 + | ValidationFailure; 20 + 21 + type ValidationOptions = { 22 + path?: PropertyKey[]; 23 + 24 + /** @default true */ 25 + allowTransform?: boolean; 26 + }; 27 + 28 + export type Infer<T extends Validator> = T["_lex"]["output"]; 29 + 30 + export abstract class Validator<Output = unknown> { 31 + /** 32 + * This property is used for type inference purposes and does not actually 33 + * exist at runtime. 34 + * 35 + * @deprecated For internal use only (not actually deprecated) 36 + */ 37 + _lex!: { output: Output }; 38 + 39 + readonly lexiconType?: string; 40 + 41 + /** 42 + * @internal **INTERNAL API, DO NOT USE**. 43 + * 44 + * Use {@link Validator.assert assert}, {@link Validator.check check}, 45 + * {@link Validator.parse parse} or {@link Validator.validate validate} 46 + * instead. 47 + * 48 + * This method is implemented by subclasses to perform transformation and 49 + * validation of the input value. Do not call this method directly; as the 50 + * {@link ValidatorContext.options.allowTransform} option will **not** be 51 + * enforced. See {@link ValidatorContext.validate} for details. When 52 + * delegating validation from one validator sub-class implementation to 53 + * another schema, {@link ValidatorContext.validate} should be used instead 54 + * of calling {@link Validator.validateInContext}. This will allow to stop the 55 + * validation process if the value was transformed (by the other schema) but 56 + * transformations are not allowed. 57 + * 58 + * By convention, the {@link ValidationResult} must return the original input 59 + * value if validation was successful and no transformation was applied (i.e. 60 + * the input already conformed to the schema). If a default value, or any 61 + * other transformation was applied, the returned value c&an be different from 62 + * the input. 63 + * 64 + * This convention allows the {@link Validator.check check} and 65 + * {@link Validator.assert assert} methods to check whether the input value 66 + * exactly matches the schema (without defaults or transformations), by 67 + * checking if the returned value is strictly equal to the input. 68 + * 69 + * @see {@link ValidatorContext.validate} 70 + */ 71 + abstract validateInContext( 72 + input: unknown, 73 + ctx: ValidatorContext, 74 + ): ValidationResult<Output>; 75 + 76 + assert(input: unknown): asserts input is Output { 77 + const result = this.validate(input, { allowTransform: false }); 78 + if (!result.success) throw result.error; 79 + } 80 + 81 + check(input: unknown): input is Output { 82 + const result = this.validate(input, { allowTransform: false }); 83 + return result.success; 84 + } 85 + 86 + maybe<I>(input: I): (I & Output) | undefined { 87 + return this.check(input) ? input : undefined; 88 + } 89 + 90 + parse<I>( 91 + input: I, 92 + options: ValidationOptions & { allowTransform: false }, 93 + ): I & Output; 94 + parse(input: unknown, options?: ValidationOptions): Output; 95 + parse(input: unknown, options?: ValidationOptions): Output { 96 + const result = ValidatorContext.validate(input, this, options); 97 + if (!result.success) throw result.error; 98 + return result.value; 99 + } 100 + 101 + validate<I>( 102 + input: I, 103 + options: ValidationOptions & { allowTransform: false }, 104 + ): ValidationResult<I & Output>; 105 + validate( 106 + input: unknown, 107 + options?: ValidationOptions, 108 + ): ValidationResult<Output>; 109 + validate( 110 + input: unknown, 111 + options?: ValidationOptions, 112 + ): ValidationResult<Output> { 113 + return ValidatorContext.validate(input, this, options); 114 + } 115 + 116 + // @NOTE The built lexicons namespaces will export utility functions that 117 + // allow accessing the schema's methods without the need to specify ".main." 118 + // as part of the namespace. This way, a utility for a particular record type 119 + // can be called like "app.bsky.feed.post.<utility>()" instead of 120 + // "app.bsky.feed.post.main.<utility>()". Because those utilities could 121 + // conflict with other schemas (e.g. if there is a lexicon definition at 122 + // "#<utility>"), those exported utilities will be prefixed with "$". In order 123 + // to be able to consistently call the utilities, when using the "main" and 124 + // non "main" definitions, we also expose the same methods with a "$" prefix. 125 + // Thanks to this, both of the following call will be possible: 126 + // 127 + // - "app.bsky.feed.post.$parse(...)" // calls a utility function created by "lex build" 128 + // - "app.bsky.feed.defs.postView.$parse(...)" // uses the alias defined below on the schema instance 129 + 130 + $assert(input: unknown): asserts input is Output { 131 + return this.assert(input); 132 + } 133 + 134 + $check(input: unknown): input is Output { 135 + return this.check(input); 136 + } 137 + 138 + $maybe<I>(input: I): (I & Output) | undefined { 139 + return this.maybe(input); 140 + } 141 + 142 + $parse(input: unknown, options?: ValidationOptions): Output { 143 + return this.parse(input, options); 144 + } 145 + 146 + $validate( 147 + input: unknown, 148 + options?: ValidationOptions, 149 + ): ValidationResult<Output> { 150 + return this.validate(input, options); 151 + } 152 + } 153 + 154 + export type ContextualIssue = { 155 + [Code in ValidationIssue["code"]]: 156 + & Omit< 157 + Extract<ValidationIssue, { code: Code }>, 158 + "path" 159 + > 160 + & { path?: PropertyKey | readonly PropertyKey[] }; 161 + }[ValidationIssue["code"]]; 162 + 163 + const asIssue = ( 164 + { path, ...issue }: ContextualIssue, 165 + currentPath: readonly PropertyKey[], 166 + ): ValidationIssue & { path: PropertyKey[] } => ({ 167 + ...issue, 168 + path: path != null ? currentPath.concat(path) : [...currentPath], 169 + }); 170 + 171 + export class ValidatorContext { 172 + /** 173 + * Creates a new validation context and validates the input using the 174 + * provided validator. 175 + */ 176 + static validate<V>( 177 + input: unknown, 178 + validator: Validator<V>, 179 + options: ValidationOptions = {}, 180 + ): ValidationResult<V> { 181 + const context = new ValidatorContext(options); 182 + return context.validate(input, validator); 183 + } 184 + 185 + private readonly currentPath: PropertyKey[]; 186 + private readonly issues: ValidationIssue[] = []; 187 + 188 + protected constructor(readonly options: ValidationOptions) { 189 + // Create a copy because we will be mutating the array during validation. 190 + this.currentPath = options?.path != null ? [...options.path] : []; 191 + } 192 + 193 + get path() { 194 + return [...this.currentPath]; 195 + } 196 + 197 + get allowTransform() { 198 + // Default to true 199 + return this.options?.allowTransform !== false; 200 + } 201 + 202 + /** 203 + * This is basically the entry point for validation within a context. Use this 204 + * method instead of {@link Validator.validateInContext} directly, because 205 + * this method enforces the {@link ValidationOptions.allowTransform} option. 206 + */ 207 + validate<V>(input: unknown, validator: Validator<V>): ValidationResult<V> { 208 + const result = validator.validateInContext(input, this); 209 + 210 + if (result.success) { 211 + if (!this.allowTransform && !Object.is(result.value, input)) { 212 + // If the value changed, it means that a default (or some other 213 + // transformation) was applied, meaning that the original value did 214 + // *not* match the (output) schema. When "allowTransform" is false, we 215 + // consider this a failure. 216 + 217 + // This check is the reason why Validator.validateInContext should not 218 + // be used directly, and ValidatorContext.validate should be used 219 + // instead, even when delegating validation from one validator to 220 + // another. 221 + 222 + // This if block comes before the next one because 'this.issues' will 223 + // end-up being appended to the returned ValidationError (see the 224 + // "failure" method below), resulting in a more complete error report. 225 + return this.issueInvalidValue(input, [result.value]); 226 + } 227 + 228 + if (this.issues.length > 0) { 229 + // Validator returned a success but issues were added via the context. 230 + // This means the overall validation failed. 231 + return { success: false, error: new ValidationError(this.issues) }; 232 + } 233 + } 234 + 235 + return result; 236 + } 237 + 238 + validateChild<I extends object, K extends PropertyKey & keyof I, V>( 239 + input: I, 240 + key: K, 241 + validator: Validator<V>, 242 + ): ValidationResult<V> { 243 + // Instead of creating a new context, we just push/pop the path segment. 244 + this.currentPath.push(key); 245 + try { 246 + return this.validate(input[key], validator); 247 + } finally { 248 + this.currentPath.length--; 249 + } 250 + } 251 + 252 + addIssue(issue: ContextualIssue): void { 253 + this.issues.push(asIssue(issue, this.currentPath)); 254 + } 255 + 256 + success<V>(value: V): ValidationResult<V> { 257 + return success(value); 258 + } 259 + 260 + failure(issue: ContextualIssue): ValidationFailure { 261 + return failure( 262 + new ValidationError([...this.issues, asIssue(issue, this.currentPath)]), 263 + ); 264 + } 265 + 266 + issueInvalidValue( 267 + input: unknown, 268 + values: readonly unknown[], 269 + path?: PropertyKey | readonly PropertyKey[], 270 + ) { 271 + return this.failure({ 272 + code: "invalid_value", 273 + input, 274 + values, 275 + path, 276 + }); 277 + } 278 + 279 + issueInvalidType( 280 + input: unknown, 281 + expected: string | readonly string[], 282 + path?: PropertyKey | readonly PropertyKey[], 283 + ) { 284 + return this.failure({ 285 + code: "invalid_type", 286 + input, 287 + expected: Array.isArray(expected) ? expected : [expected], 288 + path, 289 + }); 290 + } 291 + 292 + issueInvalidPropertyValue<I>( 293 + input: I, 294 + property: keyof I & PropertyKey, 295 + values: readonly unknown[], 296 + ) { 297 + return this.issueInvalidValue(input[property], values, property); 298 + } 299 + 300 + issueInvalidPropertyType<I>( 301 + input: I, 302 + property: keyof I & PropertyKey, 303 + expected: string | readonly string[], 304 + ) { 305 + return this.issueInvalidType(input[property], expected, property); 306 + } 307 + 308 + issueRequiredKey(input: object, key: PropertyKey) { 309 + return this.failure({ 310 + code: "required_key", 311 + key, 312 + input, 313 + path: key, 314 + }); 315 + } 316 + 317 + issueInvalidFormat(input: unknown, format: string, message?: string) { 318 + return this.failure({ 319 + code: "invalid_format", 320 + message, 321 + format, 322 + input, 323 + }); 324 + } 325 + 326 + issueTooBig( 327 + input: unknown, 328 + type: IssueTooBig["type"], 329 + maximum: number, 330 + actual: number, 331 + ) { 332 + return this.failure({ 333 + code: "too_big", 334 + type, 335 + maximum, 336 + actual, 337 + input, 338 + }); 339 + } 340 + 341 + issueTooSmall( 342 + input: unknown, 343 + type: IssueTooSmall["type"], 344 + minimum: number, 345 + actual: number, 346 + ) { 347 + return this.failure({ 348 + code: "too_small", 349 + type, 350 + minimum, 351 + actual, 352 + input, 353 + }); 354 + } 355 + 356 + custom( 357 + input: unknown, 358 + message: string, 359 + path?: PropertyKey | readonly PropertyKey[], 360 + ) { 361 + return this.failure({ 362 + code: "custom", 363 + input, 364 + message, 365 + path, 366 + }); 367 + } 368 + }