import { expand } from "./util/build.ts"; import { compile, type ValidationResult } from "./validation.ts"; import { isNsid } from "./util/syntax.ts"; export declare const output: unique symbol; export declare const input: unique symbol; interface Typed { readonly [output]?: readonly [T]; readonly [input]?: readonly [I]; } // deno-lint-ignore ban-types -- the intersection forces editor expansion type Simplify = { [K in keyof T]: T[K] } & {}; type Channel = typeof output | typeof input; type Read = S extends { readonly [K in C]?: readonly [infer T] } ? T : never; export type Infer = Read; /** What builders accept for a schema, where union and record discriminators may be implied. */ export type Input = Read; type Validated = Typed & { validate(value: unknown): ValidationResult }; export type Schema = Node & Validated; export type StringFormat = | "did" | "handle" | "at-identifier" | "at-uri" | "datetime" | "uri" | "language" | "cid" | "nsid" | "tid"; export type StringOptions = { readonly description?: string; readonly format?: StringFormat; readonly minLength?: number; readonly maxLength?: number; readonly minGraphemes?: number; readonly maxGraphemes?: number; }; export type IntegerOptions = { readonly description?: string; readonly minimum?: number; readonly maximum?: number; }; export type LengthOptions = { readonly description?: string; readonly minLength?: number; readonly maxLength?: number; }; export type BlobOptions = { readonly description?: string; readonly accept?: readonly string[]; readonly maxSize?: number; }; export type CidLink = { readonly $link: string }; export type BytesValue = { readonly $bytes: string }; export type BlobValue = { readonly $type: "blob"; readonly ref: CidLink; readonly mimeType: string; readonly size: number; }; export type OptionalField = Typed & { readonly type: "optional"; readonly inner: Schema | NullableField; }; export type NullableField = Typed & { readonly type: "nullable"; readonly inner: Schema; }; export type Field = Schema | OptionalField | NullableField; export type Shape = Readonly>; type ObjectValue = & { readonly [K in keyof S as S[K] extends OptionalField ? never : K]: Read } & { readonly [K in keyof S as S[K] extends OptionalField ? K : never]?: Read | undefined; }; type Node = | ( & { readonly type: "string"; readonly const?: string; readonly enum?: readonly string[] } & StringOptions ) | ( & { readonly type: "integer"; readonly const?: number; readonly enum?: readonly number[] } & IntegerOptions ) | { readonly type: "boolean"; readonly const?: boolean; readonly description?: string } | ({ readonly type: "bytes" } & LengthOptions) | { readonly type: "cid-link"; readonly description?: string } | ({ readonly type: "blob" } & BlobOptions) | ({ readonly type: "array"; readonly items: Schema } & LengthOptions) | { readonly type: "object"; readonly properties: Shape; readonly description?: string } | { readonly type: "ref"; readonly id: string; readonly target: Schema; readonly description?: string; } | { readonly type: "union"; readonly variants: Variants; readonly closed: boolean; readonly namespace?: string; readonly description?: string; } | { readonly type: "record"; readonly id: string; readonly key: RecordKey; readonly record: ObjectSchema; readonly description?: string; }; export type ObjectSchema = & Schema, ObjectValue> & { readonly type: "object"; readonly properties: S; }; export type RecordKey = "tid" | "any" | `literal:${string}`; const validators = new WeakMap ValidationResult>(); const schemaPrototype = { validate(this: Schema, value: unknown): ValidationResult { const check = validators.get(this) ?? compile(this); validators.set(this, check); return check(value) as ValidationResult; }, }; const unionPrototype = Object.setPrototypeOf({ build(this: Schema, value: unknown) { return expand(this, value); }, }, schemaPrototype); const recordPrototype = Object.setPrototypeOf({ build(this: Schema, fields: Record) { if (Object.hasOwn(fields, "$type")) throw new Error("Record fields must not include $type"); return expand(this, fields); }, }, schemaPrototype); function schema(node: S): S & typeof schemaPrototype { return Object.setPrototypeOf(node, schemaPrototype); } export function withDescription(source: S, description: string): S { return Object.create(Object.getPrototypeOf(source), { ...Object.getOwnPropertyDescriptors(source), description: { value: description, enumerable: true, configurable: true, writable: true }, }); } function bounds(min: number | undefined, max: number | undefined, nonnegative = true): void { for (const n of [min, max]) { if (n !== undefined && (!Number.isSafeInteger(n) || (nonnegative && n < 0))) { throw new Error("Bounds must be safe integers" + (nonnegative ? " >= 0" : "")); } } if (min !== undefined && max !== undefined && min > max) { throw new Error("Minimum must not exceed maximum"); } } export type StringSchema = Schema & { readonly type: "string" }; export type IntegerSchema = Schema & { readonly type: "integer" }; export function stringWith(options: StringOptions = {}): StringSchema { bounds(options.minLength, options.maxLength); bounds(options.minGraphemes, options.maxGraphemes); return schema({ ...options, type: "string" }); } export function integerWith(options: IntegerOptions = {}): IntegerSchema { bounds(options.minimum, options.maximum, false); return schema({ ...options, type: "integer" }); } // Exports carry explicit types: JSR's fast check can't follow `typeof` between them. export const string: StringSchema = stringWith(); export const integer: IntegerSchema = integerWith(); export const boolean: Schema = schema({ type: "boolean" }); export const cidLink: Schema = schema({ type: "cid-link" }); export const did: StringSchema<`did:${string}`> = schema({ type: "string", format: "did" }); export const handle: StringSchema = stringWith({ format: "handle" }); export const atIdentifier: StringSchema = stringWith({ format: "at-identifier" }); export const atUri: StringSchema<`at://${string}`> = schema({ type: "string", format: "at-uri" }); export const datetime: StringSchema = stringWith({ format: "datetime" }); export const uri: StringSchema = stringWith({ format: "uri" }); export const language: StringSchema = stringWith({ format: "language" }); export const cid: StringSchema = stringWith({ format: "cid" }); export const nsid: StringSchema = stringWith({ format: "nsid" }); export const tid: StringSchema = stringWith({ format: "tid", minLength: 13, maxLength: 13 }); export function bytesWith(options: LengthOptions = {}): Schema { bounds(options.minLength, options.maxLength); return schema({ ...options, type: "bytes" }); } export const bytes: Schema = bytesWith(); export function literal(value: T): Schema { if (typeof value === "string") return schema({ type: "string", const: value }); if (typeof value === "boolean") return schema({ type: "boolean", const: value }); if (!Number.isSafeInteger(value)) throw new Error("Numeric literals must be safe integers"); return schema({ type: "integer", const: value }); } export function enumValues( ...values: T ): Schema { return schema({ type: "string", enum: [...values] }); } export function blob(options: BlobOptions = {}): Schema { bounds(undefined, options.maxSize); if ( options.accept?.some((mime) => !/^(?:\*\/\*|[\w!#$&^.+-]+\/(?:[\w!#$&^.+-]+|\*))$/.test(mime)) ) { throw new Error("Invalid blob MIME pattern"); } return schema({ ...options, ...(options.accept && { accept: [...options.accept] }), type: "blob", }); } export function array( items: S, options: LengthOptions = {}, ): Schema[], readonly Input[]> { bounds(options.minLength, options.maxLength); return schema({ ...options, type: "array", items }); } export function optional( inner: S, ): OptionalField, Input> { return { type: "optional", inner }; } export function nullable(inner: S): NullableField, Input> { return { type: "nullable", inner }; } export function object( properties: S, options: { readonly description?: string } = {}, ): ObjectSchema { if (Object.keys(properties).some((key) => key.startsWith("$"))) { throw new Error("$-prefixed fields are reserved; records and unions supply $type"); } return schema({ ...options, type: "object", properties: { ...properties } }); } export function definitionId(id: string): { nsid: string; name: string } { const [nsid, name = "main", extra] = id.split("#"); if (!nsid || !isNsid(nsid) || extra !== undefined || !/^[A-Za-z][A-Za-z0-9]*$/.test(name)) { throw new Error(`Invalid definition ID: ${id}`); } if (id.endsWith("#main")) throw new Error("Use the bare NSID instead of #main"); return { nsid, name }; } export type NamedSchema = & Validated & { readonly type: "ref"; readonly id: Id; readonly target: Schema; readonly description?: string; }; export function named( id: Id, target: S | (() => S), ): NamedSchema, Input> & { readonly target: S } { definitionId(id); let resolved: S | undefined; let resolving = false; const ref = schema({ type: "ref", id, get target(): S { if (resolved) return resolved; if (resolving) throw new Error(`Circular definition factory: ${id}`); resolving = true; try { const value = typeof target === "function" ? target() : target; if (value.type === "ref" || value.type === "union" || value.type === "record") { throw new Error("Only concrete definitions can be named; records already have a name"); } return resolved = value; } finally { resolving = false; } }, }); if (typeof target !== "function") void ref.target; return ref as NamedSchema, Input> & { readonly target: S }; } type Variants = Readonly>; type ObjectVariants = Readonly>; type NamedVariants = { readonly [S in V[number] as S["id"]]: S["target"]; }; type UnionValue< V extends Variants, C extends Channel = typeof output, Base extends string = never, > = { [K in keyof V & string]: Simplify< & { readonly $type: K | (K extends `${Base}#${infer F}` ? `#${F}` : never) } & Read >; }[keyof V & string]; type UnionOptions = { readonly closed?: Closed; readonly description?: string; }; export type UnknownVariant = { readonly $type: string; readonly [key: string]: unknown }; type Open = Closed extends true ? never : UnknownVariant; export type UnionSchema< V extends Variants, Closed extends boolean = true, Base extends string = never, > = & Validated | Open, UnionValue | Open> & { readonly type: "union"; readonly variants: V; readonly closed: Closed; readonly description?: string; build(value: UnionValue | Open): UnionValue | Open; }; // Variants of a namespaced union are declared as "#name" and stored under their full ID. function namespacedVariants(namespace: string, variants: ObjectVariants): Variants { if (!isNsid(namespace)) throw new Error(`Invalid union namespace: ${namespace}`); return Object.fromEntries( Object.entries(variants).map(([key, variant]) => { if (!key.startsWith("#")) throw new Error("Union variant names must start with #"); return [namespace + key, variant]; }), ); } // Resolving targets here would re-enter a definition that includes itself in a union, so // each variant resolves on first access. function namedVariants(refs: readonly NamedSchema[]): Variants { const variants: Record = {}; for (const ref of refs) { if (ref.type !== "ref") throw new Error("Union arrays must contain named definitions"); if (Object.hasOwn(variants, ref.id)) throw new Error("Duplicate union variant"); Object.defineProperty(variants, ref.id, { enumerable: true, get() { const target = ref.target; if (target.type !== "object") throw new Error(`Union variant ${ref.id} must be an object`); return target; }, }); } return variants; } const isNamedList = (value: unknown): value is readonly NamedSchema[] => Array.isArray(value); function unionOf(variants: Variants, options: UnionOptions, namespace?: string): Schema { const keys = Object.keys(variants); if (keys.length === 0 && options.closed !== false) { throw new Error("A closed union cannot be empty"); } for (const key of keys) definitionId(key); return Object.setPrototypeOf({ ...options, type: "union", variants, closed: options.closed ?? true, ...(namespace !== undefined && { namespace }), }, unionPrototype); } export function union< const V extends readonly NamedSchema[], const Closed extends boolean = true, >( variants: V, options?: UnionOptions, ): UnionSchema, Closed>; export function union( variants: V, options?: UnionOptions, ): UnionSchema; export function union< const Id extends string, const V extends Readonly>, const Closed extends boolean = true, >( id: Id, variants: V, options?: UnionOptions, ): UnionSchema<{ readonly [K in keyof V & `#${string}` as `${Id}${K}`]: V[K] }, Closed, Id>; export function union( idOrVariants: string | ObjectVariants | readonly NamedSchema[], variantsOrOptions: ObjectVariants | UnionOptions = {}, options: UnionOptions = {}, ): Schema { if (typeof idOrVariants === "string") { const variants = namespacedVariants(idOrVariants, variantsOrOptions as ObjectVariants); return unionOf(variants, options, idOrVariants); } const variants = isNamedList(idOrVariants) ? namedVariants(idOrVariants) : { ...idOrVariants }; return unionOf(variants, variantsOrOptions as UnionOptions); } export type RecordSchema = & Schema< Simplify<{ readonly $type: Id } & ObjectValue>, Simplify<{ readonly $type?: Id } & ObjectValue> > & { readonly type: "record"; readonly id: Id; readonly record: ObjectSchema; build(fields: ObjectValue & { readonly $type?: never }): Simplify< { readonly $type: Id } & ObjectValue >; }; export function record( id: Id, options: { readonly key: RecordKey; readonly description?: string }, properties: S, ): RecordSchema { if (!isNsid(id)) throw new Error(`Invalid record NSID: ${id}`); if ( options.key !== "tid" && options.key !== "any" && !/^literal:[A-Za-z0-9_~.:-]{1,512}$/.test(options.key) ) { throw new Error("Invalid record key policy"); } if (options.key === "literal:." || options.key === "literal:..") { throw new Error("Invalid literal record key"); } return Object.setPrototypeOf( { ...options, type: "record", id, record: object(properties) }, recordPrototype, ); }