Files
Signage/scripts/validate-module.mjs
ekklesiaandClaude Opus 5.5 33bb146349
CI / lint (pull_request) Successful in 11s
CI / typecheck (pull_request) Successful in 11s
CI / test (pull_request) Successful in 17s
CI / build (pull_request) Successful in 12s
CI / integration (pull_request) Skipped
feat: build Signage on its own from the published Ekklesia 0.2.0 packages
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]>
2026-10-08 20:49:17 -05:00

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.`);