import type { ObjectSchema, SchemaDescriptor } from "./index.ts"; export type ContractKind = | "api" | "action" | "webhook" | "realtime" | "queue" | "cron" | "pubsub" | "plugin" | "config" | "env"; export interface ContractDefinition> { kind: ContractKind; name: string; version: number; payload: ObjectSchema | 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 | SchemaDescriptor): value is SchemaDescriptor { return "type" in value && value.type === "object" && "fields" in value; } function keyOf(contract: Pick): string { return `${contract.kind}:${contract.name}@${contract.version}`; } export function defineContract( definition: ContractDefinition, ): ContractDefinition { 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( definition: Omit, "kind"> & { kind?: "realtime" | "pubsub" }, ): ContractDefinition { return defineContract({ ...definition, kind: definition.kind ?? "pubsub" }); } export class ContractRegistry { private readonly records = new Map(); register(definition: ContractDefinition): 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; }