feat: standalone module-build readiness for the public @ekklesia packages (ADR 0053)
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m42s

Makes the public module-development package set usable from a module
repository outside the monorepo. Nothing is published.

- Curated @ekklesia/module-sdk: an explicit author API, no contracts
  barrel, no identity/authorization internals, no Drizzle and no legacy
  ModuleDatabase type. defineModuleRuntime returns the SDK's own named
  interfaces so a module's emitted declarations never reach into
  @ekklesia/contracts. defineModuleSchema is gone; createModuleTable and
  defineModuleSchemaMetadata live in @ekklesia/module-database.
- Drizzle: no longer a dependency of module-sdk or contracts. Core keeps
  the legacy Drizzle schema validator (moved to core/src/modules) and
  its legacy runtime support; drizzle-orm is an optional peer only.
- React is a peer (plus devDependency) of @ekklesia/ui; the boundary
  checker allows exactly that consumer-selected peer.
- @ekklesia/typescript-config is publishable; module-ui-host is private.
  Every public package carries "ekklesia.distribution":
  "module-development" and no monorepo-root prepack.
- module-pack reads the Node.js range from the module's own engines.node
  and recognizes Ekklesia packages by name plus that marker, so
  registry-installed packages are incorporated, SDK-compatibility
  checked, and never credited as third-party.
- scripts/pack-public-packages.ts (pnpm packages:pack) rebuilds, packs
  and inspects the nine public tarballs; scripts/test-standalone-module.sh
  (pnpm test:standalone-module, new CI job) installs them into a fixture
  module outside the repository and proves it typechecks, builds,
  packages, and verifies.
- Public-package boundary tests and module-development docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-08 07:28:54 -05:00
co-authored by Claude Opus 5.5
parent 9426c83109
commit 89d5d0f2da
63 changed files with 2320 additions and 237 deletions
+25 -33
View File
@@ -20,10 +20,10 @@ import { build, type Message, type Plugin } from "esbuild";
import { minVersion, satisfies, subset, validRange } from "semver";
import {
NATIVE_TARGETS,
type NativePackage,
type NativeTarget,
NativeError,
type NativeErrorCode,
type NativePackage,
type NativeTarget,
nativeClosure,
nativeRuntime,
} from "./native.js";
@@ -130,7 +130,7 @@ const messages: Readonly<Record<ModulePackErrorCode, string>> = Object.freeze({
"A package path is not portable printable ASCII that fits the archive format.",
MODULE_PACK_REVISION_INVALID: "The source revision must be a full 40- or 64-character commit id.",
MODULE_PACK_RUNTIME_UNKNOWN:
"The Node.js range could not be determined. Run inside an Ekklesia workspace, or pass one.",
"The module's package.json must declare the Node.js versions it supports in engines.node.",
MODULE_PACK_SDK_INCOMPATIBLE:
"The bundled Module SDK is outside the manifest's own compatibility.moduleSdk range.",
MODULE_PACK_TARGET_UNSUPPORTED:
@@ -170,8 +170,7 @@ export interface PackModuleOptions {
readonly sourceRevision: string;
/** Informational only; recorded as given. */
readonly repository?: string;
/** The Node.js range the package is built for. Defaults to the enclosing workspace's
* `engines.node`, the toolchain the module was built and tested with. */
/** The Node.js range the package is built for. Defaults to the module's own `engines.node`. */
readonly nodeRange?: string;
/** Reviewed license supplements (ADR 0052). Defaults to the ones this packager ships. */
readonly supplementsDirectory?: string;
@@ -234,27 +233,16 @@ async function packagerIdentity(): Promise<{ readonly name: string; readonly ver
return { name: String(manifest.name), version: String(manifest.version) };
}
async function workspaceNodeRange(moduleDirectory: string): Promise<string> {
let directory = moduleDirectory;
for (;;) {
try {
await lstat(path.join(directory, "pnpm-workspace.yaml"));
const manifest = await readJson(path.join(directory, "package.json"));
const engines = isRecord(manifest) ? manifest.engines : undefined;
const range = isRecord(engines) ? engines.node : undefined;
if (typeof range === "string") {
return range;
}
break;
} catch {
const parent = path.dirname(directory);
if (parent === directory) {
break;
}
directory = parent;
}
/** The Node.js range a module declares for itself, in its own package.json `engines.node` (ADR
* 0053). Read from the module, never from an enclosing workspace: a standalone module repository has
* none. */
function moduleNodeRange(developmentPackage: Readonly<Record<string, unknown>>): string {
const engines = developmentPackage.engines;
const range = isRecord(engines) ? engines.node : undefined;
if (typeof range !== "string" || range.trim() === "") {
throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN");
}
throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN");
return range;
}
/** `./dist/server/index.js` → `dist/server/index.js`, inside the module directory. */
@@ -537,7 +525,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
throw new ModulePackError("MODULE_PACK_MODULE_LICENSE_FILE_INVALID");
}
const nodeRange = options.nodeRange ?? (await workspaceNodeRange(moduleDirectory));
const nodeRange = options.nodeRange ?? moduleNodeRange(developmentPackage);
if (validRange(nodeRange) === null) {
throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN");
}
@@ -624,14 +612,17 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
if (bundled === undefined || bundled.root === moduleDirectory) {
continue;
}
if (!bundled.thirdParty) {
if (bundled.name.startsWith("@ekklesia/")) {
const known = incorporates.get(bundled.name);
if (known !== undefined && known !== bundled.version) {
throw new ModulePackError("MODULE_PACK_INCORPORATED_CONFLICT", bundled.name);
}
incorporates.set(bundled.name, bundled.version);
if (bundled.ekklesiaOwned) {
// An Ekklesia module-development package, recognized by its identity, not by where it was
// installed (ADR 0053): recorded under incorporates, never credited as third-party software.
const known = incorporates.get(bundled.name);
if (known !== undefined && known !== bundled.version) {
throw new ModulePackError("MODULE_PACK_INCORPORATED_CONFLICT", bundled.name);
}
incorporates.set(bundled.name, bundled.version);
continue;
}
if (!bundled.thirdParty) {
continue;
}
if (use === "backend" && bundled.name === "drizzle-orm") {
@@ -657,6 +648,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
manifest: nativePackage.manifest,
name: nativePackage.name,
root: nativePackage.root,
ekklesiaOwned: false,
thirdParty: true,
version: nativePackage.version,
},
+30 -4
View File
@@ -3,18 +3,42 @@ import path from "node:path";
/**
* What the packager knows about a package whose files were bundled: where it is, what it is called,
* and whether it is third-party software. A package installed under a `node_modules` directory is
* third-party; one that is not (a workspace package, or the module itself) is the platform's or the
* module's own code.
* and whose code it is.
*
* - **Ekklesia-owned** (`ekklesiaOwned`): it identifies itself as a published Ekklesia
* module-development package (`isEkklesiaPackage`), wherever it was installed from: a workspace
* link or a registry copy under `node_modules` (ADR 0053). It is recorded under `incorporates`,
* never credited as third-party software.
* - **Third-party** (`thirdParty`): anything else installed under a `node_modules` directory.
* - Otherwise it is the module's own code, or a local package of the module's own repository.
*/
export interface BundledPackage {
readonly root: string;
readonly name: string;
readonly version: string;
readonly ekklesiaOwned: boolean;
readonly thirdParty: boolean;
readonly manifest: Readonly<Record<string, unknown>>;
}
/** The marker every published Ekklesia module-development package carries in its package.json. */
export const EKKLESIA_PACKAGE_DISTRIBUTION = "module-development";
/**
* Whether a package is an Ekklesia module-development package: its name is in the `@ekklesia` scope
* **and** its package.json says so explicitly (`"ekklesia": { "distribution": "module-development" }`).
* Neither a path nor a scope name alone is enough.
*/
export function isEkklesiaPackage(manifest: Readonly<Record<string, unknown>>): boolean {
const ekklesia = manifest.ekklesia;
return (
typeof manifest.name === "string" &&
manifest.name.startsWith("@ekklesia/") &&
isRecord(ekklesia) &&
ekklesia.distribution === EKKLESIA_PACKAGE_DISTRIBUTION
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -48,11 +72,13 @@ export class BundledPackageIndex {
visited.push(directory);
const manifest = await readManifest(directory);
if (manifest && typeof manifest.name === "string" && typeof manifest.version === "string") {
const ekklesiaOwned = isEkklesiaPackage(manifest);
found = Object.freeze({
ekklesiaOwned,
manifest: Object.freeze(manifest),
name: manifest.name,
root: directory,
thirdParty: directory.split(path.sep).includes("node_modules"),
thirdParty: !ekklesiaOwned && directory.split(path.sep).includes("node_modules"),
version: manifest.version,
});
break;