feat: add core registry schemas
This commit is contained in:
152
packages/core/src/registry-loader.ts
Normal file
152
packages/core/src/registry-loader.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user