153 lines
4.6 KiB
TypeScript
153 lines
4.6 KiB
TypeScript
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}`;
|
|
}
|