213 lines
6.1 KiB
TypeScript
213 lines
6.1 KiB
TypeScript
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;
|
|
}
|