release: WRNexusJS 0.8.0
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user