feat: add core registry schemas

This commit is contained in:
chungyeong
2026-05-09 23:56:10 +09:00
parent 44103839af
commit 4a7fc94f5c
18 changed files with 1267 additions and 12 deletions

View File

@@ -0,0 +1,152 @@
import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
import { basename, join } from "node:path";
import { parse } from "yaml";
import { Persona, personaHash } from "./persona.js";
import { Template, templateHash } from "./template.js";
export interface RegistryEntry<TDefinition> {
name: string;
version: number;
hash: string;
definition: TDefinition;
path: string;
}
export interface PublishedRegistryRow {
name: string;
version: number;
hash: string;
referencedByRun: boolean;
}
export interface RegistrySeedPlan<TDefinition> {
inserts: RegistryEntry<TDefinition>[];
missingReferenced: PublishedRegistryRow[];
missingUnreferenced: PublishedRegistryRow[];
unchanged: RegistryEntry<TDefinition>[];
}
export type RegistryKind = "persona" | "template";
export function loadPersonaFiles(directory: string): RegistryEntry<Persona>[] {
return loadVersionedYamlFiles(directory, Persona, personaHash);
}
export function loadTemplateFiles(directory: string): RegistryEntry<Template>[] {
return loadVersionedYamlFiles(directory, Template, templateHash);
}
export function buildRegistrySeedPlan<TDefinition>(
entries: RegistryEntry<TDefinition>[],
publishedRows: PublishedRegistryRow[],
): RegistrySeedPlan<TDefinition> {
const publishedByIdentity = new Map(
publishedRows.map((row) => [identityKey(row.name, row.version), row]),
);
const entriesByIdentity = new Map(
entries.map((entry) => [identityKey(entry.name, entry.version), entry]),
);
const inserts: RegistryEntry<TDefinition>[] = [];
const missingReferenced: PublishedRegistryRow[] = [];
const missingUnreferenced: PublishedRegistryRow[] = [];
const unchanged: RegistryEntry<TDefinition>[] = [];
for (const entry of entries) {
const published = publishedByIdentity.get(identityKey(entry.name, entry.version));
if (!published) {
inserts.push(entry);
continue;
}
if (published.hash !== entry.hash) {
throw new Error(
`A published registry entry was modified in place: ${entry.name}@${entry.version}`,
);
}
unchanged.push(entry);
}
for (const published of publishedRows) {
if (entriesByIdentity.has(identityKey(published.name, published.version))) {
continue;
}
if (published.referencedByRun !== false) {
missingReferenced.push(published);
} else {
missingUnreferenced.push(published);
}
}
return { inserts, missingReferenced, missingUnreferenced, unchanged };
}
export function assertNoReferencedRegistryDeletions<TDefinition>(
kind: RegistryKind,
plan: RegistrySeedPlan<TDefinition>,
) {
if (plan.missingReferenced.length === 0) {
return;
}
const identities = plan.missingReferenced.map((entry) => `${entry.name}@${entry.version}`);
throw new Error(
`Cannot delete published ${kind} registry files referenced by runs: ${identities.join(", ")}`,
);
}
export { personaHash, templateHash };
function loadVersionedYamlFiles<TDefinition extends { name: string; version: number }>(
directory: string,
schema: { parse(value: unknown): TDefinition },
hashDefinition: (definition: TDefinition) => string,
): RegistryEntry<TDefinition>[] {
return readdirSync(directory)
.filter((fileName) => {
if (fileName.endsWith(".yml")) {
throw new Error(`Invalid registry filename ${fileName}; expected <name>@<version>.yaml`);
}
return fileName.endsWith(".yaml");
})
.sort()
.map((fileName) => {
const path = join(directory, fileName);
if (lstatSync(path).isSymbolicLink()) {
throw new Error(`Registry filename ${fileName} must be a regular YAML file, not a symlink`);
}
const canonicalPath = realpathSync(path);
const definition = schema.parse(parse(readFileSync(path, "utf8")));
assertFilenameIdentity(fileName, definition);
return {
name: definition.name,
version: definition.version,
hash: hashDefinition(definition),
definition,
path: canonicalPath,
};
});
}
function assertFilenameIdentity(fileName: string, definition: { name: string; version: number }) {
const match = basename(fileName).match(/^(.+)@([1-9]\d*)\.yaml$/);
if (!match) {
throw new Error(`Invalid registry filename ${fileName}; expected <name>@<version>.yaml`);
}
const [, name, versionText] = match;
if (name !== definition.name || versionText !== String(definition.version)) {
throw new Error(
`Registry filename identity mismatch for ${fileName}: expected ${definition.name}@${definition.version}`,
);
}
}
function identityKey(name: string, version: number) {
return `${name}@${version}`;
}