Signage no longer needs an Ekklesia checkout. A clean clone installs with `pnpm install --frozen-lockfile` and runs lint, typecheck, test, build, and validate by itself, taking Ekklesia's module-development packages from Ekklesia's public registry (ADRs 0053 and 0054). - Dependencies: every `workspace:*` becomes `^0.2.0`. @ekklesia/module-pack is added as a devDependency pinned exactly to 0.2.0, as ADR 0053 requires; it is not invoked yet. No @ekklesia/contracts dependency. drizzle-orm stays a direct dependency, satisfying the adapter's optional peer. - .npmrc points the @ekklesia scope at https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ with no credential, because reading needs none. - pnpm-workspace.yaml holds this repository's pnpm settings only: - `allowBuilds: esbuild: true`, as the platform's standalone fixture does; - exact-version minimumReleaseAgeExclude entries for the 0.2.0 train, which pnpm 11 otherwise holds back on its release day; - the platform's narrowly scoped override for GHSA-67mh-4wv8-2f99 (esbuild via drizzle-kit), which Signage inherited while it built inside Ekklesia's workspace. - engines.node `>=24.11.0 <25` and packageManager `[email protected].0`, both from Ekklesia 0.2.0. Node 24.18.0 goes in .node-version for CI, as in Ekklesia. - biome.json carries the platform's Biome configuration, which `biome lint` used to pick up from the workspace root, so lint rules are unchanged. - pnpm-lock.yaml is Signage's own: registry tarballs with integrity hashes, and no workspace or file links. - `prepack` runs this package's own build instead of the workspace root's turbo. - `pnpm validate` (scripts/validate-module.mjs) checks the built package and its migrations with the published SDK parsers only: - the manifest, its identity, and its entrypoints; - that the built runtime's id, version, and database ABI match the manifest; - that the built schema metadata is plain, namespaced, and has module-internal foreign keys; - the SDK compatibility range; - that the migration journal and policy agree. Core's own `modules:validate` and `migrations:validate` are not published. What only an Ekklesia installation can check is named in the script. Removed: ci/assemble.sh, ci/platform.env, ci/pnpm-lock.yaml, ci/update-lock.sh. The lint, typecheck, test, and build jobs check out only this repository; build also runs `pnpm validate`. The integration job stays disabled. Its first step is now an explicit placeholder rather than the deleted assembly, and its explanation says it returns when Signage is a canonical linux-x64-glibc Module Package installed into Ekklesia's generic image. No source, behaviour, schema, or version change: Signage stays 0.1.2. Co-Authored-By: Claude Opus 5.5 <[email protected]>
146 lines
5.9 KiB
JavaScript
146 lines
5.9 KiB
JavaScript
// Validates the built Signage package and its migrations the way the platform reads them, with only
|
|
// Ekklesia's published module-development packages: no Ekklesia checkout, no database.
|
|
//
|
|
// pnpm build && pnpm validate
|
|
//
|
|
// What it checks:
|
|
// - the manifest parses, and its id and version match package.json and the built runtime;
|
|
// - every declared entrypoint exists in the built package;
|
|
// - the built runtime declares the same Module Database ABI as the manifest (Core refuses a
|
|
// mismatch);
|
|
// - the built schema entrypoint's only export is plain ABI v1 metadata for this module's own,
|
|
// namespaced tables, and every foreign key targets one of them;
|
|
// - the installed module SDK satisfies the manifest's `compatibility.moduleSdk`;
|
|
// - the migration journal and policy parse, name the same migrations in the same order, and every
|
|
// migration's SQL is present.
|
|
//
|
|
// What it cannot check, because only Ekklesia itself can: compatibility with a particular platform
|
|
// installation, capability and module dependencies between installed modules, and a migration's
|
|
// checksum against a database's applied history. The module packager and the platform's package
|
|
// verifier re-check all of the above when Signage is built as a Module Package.
|
|
import { readFile, stat } from "node:fs/promises";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { defineModuleSchemaMetadata, moduleTablePrefix } from "@ekklesia/module-database";
|
|
import {
|
|
parseDrizzleMigrationJournal,
|
|
parseModuleManifest,
|
|
parseModuleMigrationPolicy,
|
|
} from "@ekklesia/module-sdk";
|
|
|
|
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
const failures = [];
|
|
const passes = [];
|
|
|
|
function check(condition, description) {
|
|
(condition ? passes : failures).push(description);
|
|
}
|
|
|
|
async function json(relative) {
|
|
return JSON.parse(await readFile(new URL(relative, pathToFileURL(root)), "utf8"));
|
|
}
|
|
|
|
async function exists(relative) {
|
|
try {
|
|
return (await stat(new URL(relative, pathToFileURL(root)))).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** A caret range, `^X.Y.Z`, as the platform's manifests write them. Anything else is refused here
|
|
* rather than half understood. */
|
|
function satisfiesCaret(version, range) {
|
|
const wanted = /^\^(\d+)\.(\d+)\.(\d+)$/u.exec(range);
|
|
const actual = /^(\d+)\.(\d+)\.(\d+)$/u.exec(version);
|
|
if (!wanted || !actual) {
|
|
return false;
|
|
}
|
|
const [w, a] = [wanted.slice(1).map(Number), actual.slice(1).map(Number)];
|
|
if (w[0] !== a[0] || (w[0] === 0 && w[1] !== a[1])) {
|
|
return false;
|
|
}
|
|
for (let index = 0; index < 3; index += 1) {
|
|
if (a[index] !== w[index]) {
|
|
return a[index] > w[index];
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const packageJson = await json("package.json");
|
|
const manifest = parseModuleManifest(await json("ekklesia.module.json"));
|
|
check(true, "ekklesia.module.json is a valid manifest");
|
|
check(packageJson.version === manifest.version, "package.json and the manifest name one version");
|
|
check(packageJson.type === "module", "the package is ESM");
|
|
check(packageJson.ekklesia?.layer === "module", "the package carries the module layer marker");
|
|
|
|
const { server, schema, migrations, ui, display } = manifest.entrypoints;
|
|
for (const entrypoint of [server, schema, ui, display?.artifact].filter(Boolean)) {
|
|
check(await exists(entrypoint), `the built package has ${entrypoint}`);
|
|
}
|
|
|
|
const runtime = (await import(new URL(server, pathToFileURL(root)).href)).default;
|
|
check(runtime?.id === manifest.id, "the built runtime's id is the manifest's");
|
|
check(runtime?.version === manifest.version, "the built runtime's version is the manifest's");
|
|
check(
|
|
JSON.stringify(runtime?.database ?? null) === JSON.stringify(manifest.database ?? null),
|
|
"the built runtime declares the manifest's database ABI",
|
|
);
|
|
|
|
if (schema) {
|
|
const namespace = await import(new URL(schema, pathToFileURL(root)).href);
|
|
check(
|
|
Object.keys(namespace).join() === "default",
|
|
"the schema entrypoint exports nothing but its metadata",
|
|
);
|
|
const metadata = defineModuleSchemaMetadata(namespace.default);
|
|
check(
|
|
JSON.stringify(JSON.parse(JSON.stringify(metadata))) === JSON.stringify(metadata),
|
|
"the schema metadata is plain data",
|
|
);
|
|
check(metadata.moduleId === manifest.id, "the schema metadata is this module's");
|
|
const prefix = moduleTablePrefix(manifest.id);
|
|
const owned = new Set(metadata.tables.map((table) => table.name));
|
|
check(
|
|
metadata.tables.every((table) => table.name.startsWith(prefix)),
|
|
`every table is in the ${prefix} namespace`,
|
|
);
|
|
check(
|
|
metadata.tables.every((table) => table.foreignKeys.every((key) => owned.has(key.table))),
|
|
"every foreign key targets one of this module's own tables",
|
|
);
|
|
}
|
|
|
|
const sdkVersion = (await json("node_modules/@ekklesia/module-sdk/package.json")).version;
|
|
check(
|
|
satisfiesCaret(sdkVersion, manifest.compatibility.moduleSdk),
|
|
`the installed module SDK ${sdkVersion} satisfies compatibility.moduleSdk ${manifest.compatibility.moduleSdk}`,
|
|
);
|
|
|
|
if (migrations) {
|
|
const directory = migrations.endsWith("/") ? migrations : `${migrations}/`;
|
|
const journal = parseDrizzleMigrationJournal(await json(`${directory}meta/_journal.json`));
|
|
const policy = parseModuleMigrationPolicy(await json(`${directory}meta/_ekklesia.json`));
|
|
check(policy.moduleId === manifest.id, "the migration policy is this module's");
|
|
check(
|
|
journal.entries.map((entry) => entry.tag).join() ===
|
|
policy.entries.map((entry) => entry.tag).join(),
|
|
"the migration journal and policy name the same migrations in the same order",
|
|
);
|
|
for (const entry of journal.entries) {
|
|
check(await exists(`${directory}${entry.tag}.sql`), `${entry.tag}.sql is present`);
|
|
}
|
|
}
|
|
|
|
for (const description of passes) {
|
|
console.log(`ok ${description}`);
|
|
}
|
|
for (const description of failures) {
|
|
console.log(`FAIL ${description}`);
|
|
}
|
|
if (failures.length > 0) {
|
|
console.error(`validate: ${failures.length} check(s) failed.`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`validate: ${manifest.id}@${manifest.version} passed ${passes.length} checks.`);
|