release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+4 -6
View File
@@ -7,9 +7,9 @@ export interface AsyncValidationContext<T> {
}
export type AsyncRefinement<T> = (context: AsyncValidationContext<T>) => void | Promise<void>;
export class AsyncObjectSchema<T extends Record<string, unknown> = Record<string, unknown>> {
export class AsyncObjectSchema<T extends object = Record<string, unknown>> {
readonly #refinements: AsyncRefinement<T>[] = [];
constructor(readonly base: ObjectSchema) {}
constructor(readonly base: ObjectSchema<T>) {}
refine(refinement: AsyncRefinement<T>): this {
this.#refinements.push(refinement);
return this;
@@ -36,13 +36,11 @@ export class AsyncObjectSchema<T extends Record<string, unknown> = Record<string
}
}
export function asyncSchema<T extends Record<string, unknown> = Record<string, unknown>>(
schema: ObjectSchema,
): AsyncObjectSchema<T> {
export function asyncSchema<T extends object>(schema: ObjectSchema<T>): AsyncObjectSchema<T> {
return new AsyncObjectSchema<T>(schema);
}
export async function parseBodyAsync<T extends Record<string, unknown>>(
export async function parseBodyAsync<T extends object>(
schema: AsyncObjectSchema<T>,
request: Request,
signal?: AbortSignal,
+212
View File
@@ -0,0 +1,212 @@
import type { ObjectSchema, SchemaDescriptor } from "./index.ts";
export type ContractKind =
| "api"
| "action"
| "webhook"
| "realtime"
| "queue"
| "cron"
| "pubsub"
| "plugin"
| "config"
| "env";
export interface ContractDefinition<T extends object = Record<string, unknown>> {
kind: ContractKind;
name: string;
version: number;
payload: ObjectSchema<T> | SchemaDescriptor;
consumers?: string[];
description?: string;
}
export interface ContractRecord {
kind: ContractKind;
name: string;
version: number;
payload: SchemaDescriptor;
consumers: string[];
description?: string;
}
export interface ContractSnapshot {
format: 1;
contracts: ContractRecord[];
}
export interface ContractIssue {
code:
| "WRN-CONTRACT-REMOVED"
| "WRN-CONTRACT-FIELD-REMOVED"
| "WRN-CONTRACT-FIELD-REQUIRED"
| "WRN-CONTRACT-FIELD-TYPE"
| "WRN-CONTRACT-RULE-TIGHTENED";
contract: string;
field?: string;
message: string;
consumers: string[];
}
function isDescriptor(value: ObjectSchema<object> | SchemaDescriptor): value is SchemaDescriptor {
return "type" in value && value.type === "object" && "fields" in value;
}
function keyOf(contract: Pick<ContractRecord, "kind" | "name" | "version">): string {
return `${contract.kind}:${contract.name}@${contract.version}`;
}
export function defineContract<T extends object>(
definition: ContractDefinition<T>,
): ContractDefinition<T> {
if (!definition.name.trim()) throw new TypeError("Contract name is required.");
if (!Number.isInteger(definition.version) || definition.version < 1) {
throw new TypeError("Contract version must be a positive integer.");
}
return definition;
}
export function defineEvent<T extends object>(
definition: Omit<ContractDefinition<T>, "kind"> & { kind?: "realtime" | "pubsub" },
): ContractDefinition<T> {
return defineContract({ ...definition, kind: definition.kind ?? "pubsub" });
}
export class ContractRegistry {
private readonly records = new Map<string, ContractRecord>();
register<T extends object>(definition: ContractDefinition<T>): this {
const valid = defineContract(definition);
const record: ContractRecord = {
kind: valid.kind,
name: valid.name,
version: valid.version,
payload: isDescriptor(valid.payload)
? structuredClone(valid.payload)
: valid.payload.describe(),
consumers: [...new Set(valid.consumers ?? [])].sort(),
description: valid.description,
};
const key = keyOf(record);
if (this.records.has(key)) throw new Error(`Duplicate contract: ${key}`);
this.records.set(key, record);
return this;
}
snapshot(): ContractSnapshot {
return {
format: 1,
contracts: [...this.records.values()]
.map((record) => structuredClone(record))
.sort((left, right) => keyOf(left).localeCompare(keyOf(right))),
};
}
}
function ruleStrength(
rule: SchemaDescriptor["fields"][string]["rules"][number],
): number | undefined {
if (rule.kind === "min" || rule.kind === "length") return rule.n;
if (rule.kind === "max") return -rule.n;
return undefined;
}
export function checkContractCompatibility(
previous: ContractSnapshot,
current: ContractSnapshot,
): ContractIssue[] {
const now = new Map(current.contracts.map((contract) => [keyOf(contract), contract]));
const issues: ContractIssue[] = [];
for (const before of previous.contracts) {
const contract = keyOf(before);
const after = now.get(contract);
if (!after) {
issues.push({
code: "WRN-CONTRACT-REMOVED",
contract,
message: `Contract removed: ${contract}`,
consumers: before.consumers,
});
continue;
}
for (const [field, oldField] of Object.entries(before.payload.fields)) {
const newField = after.payload.fields[field];
if (!newField) {
issues.push({
code: "WRN-CONTRACT-FIELD-REMOVED",
contract,
field,
message: `Field removed: ${field}`,
consumers: before.consumers,
});
continue;
}
if (oldField.type !== newField.type) {
issues.push({
code: "WRN-CONTRACT-FIELD-TYPE",
contract,
field,
message: `Field type changed: ${field} (${oldField.type} -> ${newField.type})`,
consumers: before.consumers,
});
}
if (oldField.optional && !newField.optional) {
issues.push({
code: "WRN-CONTRACT-FIELD-REQUIRED",
contract,
field,
message: `Optional field became required: ${field}`,
consumers: before.consumers,
});
}
for (const newRule of newField.rules) {
const oldRule = oldField.rules.find((rule) => rule.kind === newRule.kind);
const newStrength = ruleStrength(newRule);
const oldStrength = oldRule && ruleStrength(oldRule);
const enumNarrowed =
newRule.kind === "oneOf" &&
oldRule?.kind === "oneOf" &&
oldRule.values.some((value) => !newRule.values.includes(value));
const constraintAdded =
!oldRule &&
[
"email",
"url",
"uuid",
"date",
"pattern",
"integer",
"min",
"max",
"length",
"oneOf",
].includes(newRule.kind);
if (
constraintAdded ||
enumNarrowed ||
(newStrength !== undefined && oldStrength !== undefined && newStrength > oldStrength)
) {
issues.push({
code: "WRN-CONTRACT-RULE-TIGHTENED",
contract,
field,
message: `Validation tightened: ${field}.${newRule.kind}`,
consumers: before.consumers,
});
}
}
}
for (const [field, descriptor] of Object.entries(after.payload.fields)) {
if (!before.payload.fields[field] && !descriptor.optional) {
issues.push({
code: "WRN-CONTRACT-FIELD-REQUIRED",
contract,
field,
message: `Required field added: ${field}`,
consumers: before.consumers,
});
}
}
}
return issues;
}
+46
View File
@@ -0,0 +1,46 @@
import type { ObjectSchema, ParseResult, SchemaDescriptor } from "./index.ts";
export class ValidationError<T = Record<string, unknown>> extends Error {
constructor(public readonly result: ParseResult<T>) {
super("Validation failed");
this.name = "ValidationError";
}
}
export function parseOrThrow<T extends object>(schema: ObjectSchema<T>, input: unknown): T {
const result = schema.parse(input);
if (!result.ok) throw new ValidationError(result);
return result.value;
}
export function validationResponse(
result: ParseResult,
options: { successStatus?: number; failureStatus?: number } = {},
): Response {
return Response.json(
result.ok
? { ok: true, value: result.value }
: { ok: false, errors: result.errors, value: result.value },
{
status: result.ok ? (options.successStatus ?? 200) : (options.failureStatus ?? 422),
headers: { "cache-control": "no-store" },
},
);
}
export function firstValidationError(errors: Record<string, string>): string | null {
return Object.values(errors).find(Boolean) ?? null;
}
export function validationSummary(
errors: Record<string, string>,
): Array<{ field: string; message: string }> {
return Object.entries(errors)
.filter(([, message]) => Boolean(message))
.map(([field, message]) => ({ field, message }));
}
export function schemaFieldNames(schema: ObjectSchema | SchemaDescriptor): string[] {
const descriptor = "describe" in schema ? schema.describe() : schema;
return Object.keys(descriptor.fields);
}
+100 -24
View File
@@ -24,6 +24,8 @@ export interface FieldDescriptor {
optional?: boolean;
/** Message used when a required field is empty. Defaults to "Required". */
requiredMessage?: string;
/** Message used when coercion to the declared type fails. */
typeMessage?: string;
label?: string;
/** Trim string input before validating. */
trim?: boolean;
@@ -131,7 +133,7 @@ export function checkField(
let value: unknown;
if (desc.type === "number") {
value = Number(pre);
if (Number.isNaN(value)) return { value, error: "Must be a number" };
if (Number.isNaN(value)) return { value, error: desc.typeMessage ?? "Must be a number" };
} else {
value = String(pre);
}
@@ -214,7 +216,9 @@ export abstract class FieldSchema {
}
}
export class StringSchema extends FieldSchema {
export class StringSchema<TValue extends string = string> extends FieldSchema {
/** Type-only marker used to preserve literal unions through schema inference. */
declare readonly __value: TValue;
readonly type = "string" as const;
private _trim = false;
email(message?: string): this {
@@ -237,9 +241,14 @@ export class StringSchema extends FieldSchema {
this.rules.push({ kind: "length", n, message });
return this;
}
oneOf(values: string[], message?: string): this {
this.rules.push({ kind: "oneOf", values, message });
return this;
oneOf<const TValues extends readonly string[]>(
values: TValues,
message?: string,
): StringSchema<TValues extends readonly [string, ...string[]] ? TValues[number] : TValue> {
this.rules.push({ kind: "oneOf", values: [...values], message });
return this as unknown as StringSchema<
TValues extends readonly [string, ...string[]] ? TValues[number] : TValue
>;
}
trim(): this {
this._trim = true;
@@ -254,7 +263,9 @@ export class StringSchema extends FieldSchema {
}
}
export class NumberSchema extends FieldSchema {
export class NumberSchema<TValue extends number = number> extends FieldSchema {
/** Type-only marker used to preserve numeric literal unions through schema inference. */
declare readonly __value: TValue;
readonly type = "number" as const;
integer(message?: string): this {
this.rules.push({ kind: "integer", message });
@@ -264,9 +275,14 @@ export class NumberSchema extends FieldSchema {
this.rules.push({ kind: "min", n: Number.MIN_VALUE, message: message ?? "Must be positive" });
return this;
}
oneOf(values: number[], message?: string): this {
this.rules.push({ kind: "oneOf", values, message });
return this;
oneOf<const TValues extends readonly number[]>(
values: TValues,
message?: string,
): NumberSchema<TValues extends readonly [number, ...number[]] ? TValues[number] : TValue> {
this.rules.push({ kind: "oneOf", values: [...values], message });
return this as unknown as NumberSchema<
TValues extends readonly [number, ...number[]] ? TValues[number] : TValue
>;
}
}
@@ -278,9 +294,32 @@ export class UnknownSchema extends FieldSchema {
readonly type = "unknown" as const;
}
export type AnyFieldSchema = StringSchema | NumberSchema | BooleanSchema | UnknownSchema;
export type AnyFieldSchema =
StringSchema<string> | NumberSchema<number> | BooleanSchema | UnknownSchema;
/** Infer the runtime value produced by a field schema. */
export type InferFieldValue<TField extends FieldSchema> =
TField extends StringSchema<infer TValue>
? TValue
: TField extends NumberSchema<infer TValue>
? TValue
: TField extends BooleanSchema
? boolean
: unknown;
/** Infer the validated object produced by a field map. */
export type InferObjectFields<TFields extends Record<string, FieldSchema>> = {
[K in keyof TFields]: InferFieldValue<TFields[K]>;
};
/** Infer the object value produced by an object schema. */
export type InferSchema<TSchema extends ObjectSchema> =
TSchema extends ObjectSchema<infer TValue> ? TValue : never;
export class ObjectSchema<TValue extends object = Record<string, unknown>> {
/** Type-only marker used by helper functions to infer validated output. */
declare readonly __output: TValue;
export class ObjectSchema {
constructor(private readonly fields: Record<string, FieldSchema>) {}
/** Return a defensive copy of the schema fields. */
@@ -289,17 +328,19 @@ export class ObjectSchema {
}
/** Create a new schema with fields added or replaced. The original is unchanged. */
extend(fields: Record<string, FieldSchema>): ObjectSchema {
extend<TFields extends Record<string, FieldSchema>>(
fields: TFields,
): ObjectSchema<Omit<TValue, keyof TFields> & InferObjectFields<TFields>> {
return new ObjectSchema({ ...this.fields, ...fields });
}
/** Create a new schema containing fields from both schemas. */
merge(schema: ObjectSchema): ObjectSchema {
merge<TOther extends object>(schema: ObjectSchema<TOther>): ObjectSchema<TValue & TOther> {
return new ObjectSchema({ ...this.fields, ...schema.getFields() });
}
/** Validate an input object; returns coerced values + per-field errors. */
parse(input: unknown): ParseResult {
parse(input: unknown): ParseResult<TValue> {
const source = (input ?? {}) as Record<string, unknown>;
const value: Record<string, unknown> = {};
const errors: Record<string, string> = {};
@@ -323,7 +364,7 @@ export class ObjectSchema {
value[name] = finalValue;
}
}
return { ok: Object.keys(errors).length === 0, value, errors };
return { ok: Object.keys(errors).length === 0, value: value as TValue, errors };
}
describe(): SchemaDescriptor {
@@ -335,11 +376,12 @@ export class ObjectSchema {
/** The fluent schema builder. */
export const v = {
string: () => new StringSchema(),
number: () => new NumberSchema(),
string: () => new StringSchema<string>(),
number: () => new NumberSchema<number>(),
boolean: () => new BooleanSchema(),
unknown: () => new UnknownSchema(),
object: (fields: Record<string, FieldSchema>) => new ObjectSchema(fields),
object: <TFields extends Record<string, FieldSchema>>(fields: TFields) =>
new ObjectSchema<InferObjectFields<TFields>>(fields),
};
// --- Environment configuration --------------------------------------------
@@ -364,8 +406,8 @@ function readEnv(): Record<string, string | undefined> {
* PORT: v.number(),
* }));
*/
export function parseEnv<T = Record<string, unknown>>(
schema: ObjectSchema,
export function parseEnv<T extends object>(
schema: ObjectSchema<T>,
source: Record<string, string | undefined> = readEnv(),
): T {
const result = schema.parse(source);
@@ -373,7 +415,7 @@ export function parseEnv<T = Record<string, unknown>>(
const lines = Object.entries(result.errors).map(([name, message]) => `${name}: ${message}`);
throw new Error(`Invalid environment variables:\n${lines.join("\n")}`);
}
return result.value as T;
return result.value;
}
// --- API helpers -----------------------------------------------------------
@@ -387,14 +429,14 @@ export function invalid(errors: Record<string, string>): Response {
* Parse a request's JSON body against a schema. On failure returns
* `{ ok: false, response }` (a ready 400); on success `{ ok: true, value }`.
*/
export async function parseBody<T = Record<string, unknown>>(
schema: ObjectSchema,
export async function parseBody<T extends object>(
schema: ObjectSchema<T>,
req: Request,
): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
const body = await readBody(req);
const result = schema.parse(body);
if (!result.ok) return { ok: false, response: invalid(result.errors) };
return { ok: true, value: result.value as T };
return { ok: true, value: result.value };
}
/** Read a request body as an object from JSON, form-urlencoded, or multipart. */
@@ -428,3 +470,37 @@ export {
mergeValidationResults,
} from "./advanced.ts";
export type { AsyncValidationContext, AsyncRefinement, OpenApiSchema } from "./advanced.ts";
export {
ValidationError,
parseOrThrow,
validationResponse,
firstValidationError,
validationSummary,
schemaFieldNames,
} from "./helpers.ts";
export { validationPlugin, validationComponentsDir } from "./plugin.ts";
export type { ValidationPluginOptions } from "./plugin.ts";
export {
toJsonSchema,
openApiRequestBody,
localizeDescriptor,
parseDescriptor,
} from "./interop.ts";
export type {
JsonSchemaDocument,
ValidationMessageKey,
ValidationMessageTranslator,
} from "./interop.ts";
export {
ContractRegistry,
checkContractCompatibility,
defineContract,
defineEvent,
} from "./contracts.ts";
export type {
ContractDefinition,
ContractIssue,
ContractKind,
ContractRecord,
ContractSnapshot,
} from "./contracts.ts";
+150
View File
@@ -0,0 +1,150 @@
import {
checkField,
type ObjectSchema,
type ParseResult,
type RuleDescriptor,
type SchemaDescriptor,
} from "./index.ts";
export interface JsonSchemaDocument {
$schema: "https://json-schema.org/draft/2020-12/schema";
$id?: string;
title?: string;
type: "object";
properties: Record<string, Record<string, unknown>>;
required?: string[];
additionalProperties: false;
}
function descriptorOf(schema: ObjectSchema | SchemaDescriptor): SchemaDescriptor {
return "describe" in schema ? schema.describe() : schema;
}
function fieldJsonSchema(field: SchemaDescriptor["fields"][string]): Record<string, unknown> {
const output: Record<string, unknown> = field.type === "unknown" ? {} : { type: field.type };
if (field.label) output.title = field.label;
if (field.trim) output["x-wrn-trim"] = true;
if (field.typeMessage) output["x-wrn-type-message"] = field.typeMessage;
for (const rule of field.rules) {
switch (rule.kind) {
case "min":
output[field.type === "string" ? "minLength" : "minimum"] = rule.n;
break;
case "max":
output[field.type === "string" ? "maxLength" : "maximum"] = rule.n;
break;
case "length":
output.minLength = rule.n;
output.maxLength = rule.n;
break;
case "email":
case "uuid":
case "date":
output.format = rule.kind;
break;
case "url":
output.format = "uri";
break;
case "oneOf":
output.enum = rule.values;
break;
case "pattern":
output.pattern = rule.source;
if (rule.flags) output["x-wrn-pattern-flags"] = rule.flags;
break;
case "integer":
output.type = "integer";
break;
}
if (rule.message) {
const messages = (output["x-wrn-messages"] ??= []) as string[];
messages.push(rule.message);
}
}
return output;
}
export function toJsonSchema(
schema: ObjectSchema | SchemaDescriptor,
options: { id?: string; title?: string } = {},
): JsonSchemaDocument {
const descriptor = descriptorOf(schema);
const properties: Record<string, Record<string, unknown>> = {};
const required: string[] = [];
for (const [name, field] of Object.entries(descriptor.fields)) {
properties[name] = fieldJsonSchema(field);
if (!field.optional) required.push(name);
}
return {
$schema: "https://json-schema.org/draft/2020-12/schema",
...(options.id ? { $id: options.id } : {}),
...(options.title ? { title: options.title } : {}),
type: "object",
properties,
...(required.length ? { required } : {}),
additionalProperties: false,
};
}
export function openApiRequestBody(
schema: ObjectSchema | SchemaDescriptor,
options: { description?: string; required?: boolean; contentTypes?: string[] } = {},
) {
const document = toJsonSchema(schema);
const { $schema: _, ...openApiSchema } = document;
const content = Object.fromEntries(
(options.contentTypes ?? ["application/json"]).map((contentType) => [
contentType,
{ schema: openApiSchema },
]),
);
return {
...(options.description ? { description: options.description } : {}),
required: options.required ?? true,
content,
};
}
export type ValidationMessageKey = "required" | "number" | `rule.${RuleDescriptor["kind"]}`;
export type ValidationMessageTranslator = (
key: ValidationMessageKey,
params: Record<string, unknown>,
) => string;
export function localizeDescriptor(
schema: ObjectSchema | SchemaDescriptor,
translate: ValidationMessageTranslator,
): SchemaDescriptor {
const descriptor = structuredClone(descriptorOf(schema));
for (const [field, value] of Object.entries(descriptor.fields)) {
value.requiredMessage ??= translate("required", { field, label: value.label ?? field });
if (value.type === "number") {
value.typeMessage ??= translate("number", { field, label: value.label ?? field });
}
value.rules = value.rules.map((rule) => ({
...rule,
message:
rule.message ??
translate(`rule.${rule.kind}`, {
field,
label: value.label ?? field,
...(rule as unknown as Record<string, unknown>),
}),
}));
}
return descriptor;
}
export function parseDescriptor<T = Record<string, unknown>>(
descriptor: SchemaDescriptor,
source: Record<string, unknown>,
): ParseResult<T> {
const value: Record<string, unknown> = {};
const errors: Record<string, string> = {};
for (const [name, field] of Object.entries(descriptor.fields)) {
const result = checkField(field, source[name]);
value[name] = result.value;
if (result.error) errors[name] = result.error;
}
return { ok: Object.keys(errors).length === 0, value: value as T, errors };
}
+20
View File
@@ -0,0 +1,20 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin } from "@wrnexus/plugin";
export interface ValidationPluginOptions {
components?: boolean;
componentDir?: string;
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
export function validationComponentsDir(): string {
return join(packageRoot, "components");
}
export function validationPlugin(options: ValidationPluginOptions = {}) {
return definePlugin({
name: "@wrnexus/validation",
version: "0.8.0",
componentDirs:
options.components === false ? [] : [options.componentDir ?? validationComponentsDir()],
});
}
export default validationPlugin;
+1 -1
View File
@@ -52,7 +52,7 @@ export const VALIDATE_RUNTIME = String.raw`
var empty = pre === undefined || pre === null || pre === "";
if (empty) return desc.optional ? null : (desc.requiredMessage || "Required");
var value;
if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return "Must be a number"; }
if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return desc.typeMessage || "Must be a number"; }
else value = String(pre);
for (var i = 0; i < desc.rules.length; i++) {
var err = applyRule(desc.type, desc.rules[i], value);