feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped

The production image now contains zero feature modules and is the same for every
installation. Installed modules live in module storage that one Core resolver
selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an
absolute path that must exist when set (/var/lib/ekklesia/modules in the image).
A relative, blank, missing, or unreadable configured directory fails closed with
a sanitized error that never contains the value.

- Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply
  and validate, module validation, module enablement, and Doctor all resolve it
  the same way; the test-only API override still takes precedence.
- Doctor receives Core's migrations directory explicitly instead of deriving it
  from the modules directory's parent.
- The migration runner retains and reports the applied history of a module that
  is not installed, and plans and applies Core's and present modules' migrations
  around it. Core's history and every installed module's history stay strict: a
  missing or changed applied migration, or a downgrade, still fails. No down
  migrations, no data deletion.
- deploy/Dockerfile copies no module and installs nothing for the module
  template; .dockerignore excludes modules/ and the build refuses a context that
  contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and
  sets EKKLESIA_MODULES_DIR.
- deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api
  and migrate, with create_host_path disabled.
- scripts/check-image.sh proves the image holds no modules and that a populated
  modules/ directory leaves its runtime file system identical; CI runs it.
- The split deploy smoke now uses the same generic image: it boots with no
  modules, installs a self-contained test module packaged from the template
  (test-only), migrates and restarts, removes it (Core runs, planning reports the
  retained history, data survives), and restores it with its data.

Docs: deploy README (module storage, install/update/remove/restore, the runtime
modules target), .env.example, API configuration, module development guide,
testing, workflow, security, and the architecture overview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-07 08:38:14 -05:00
co-authored by Claude Opus 5.5
parent 4d28a82937
commit e36b70d8d4
50 changed files with 2275 additions and 224 deletions
+23 -3
View File
@@ -8,6 +8,7 @@ import {
formatDoctorReport,
} from "../diagnostics/index.js";
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
interface ModuleSdkPackageDocument {
readonly version?: unknown;
@@ -25,8 +26,10 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
*/
export interface DoctorCommandMetadata {
readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
readonly platformVersion: string;
/** Where the platform is installed: Core's migrations live beneath it, and so does the
* development default modules directory. Installed modules may live anywhere else. */
readonly repositoryRoot: string;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
@@ -49,6 +52,22 @@ function requiredString(value: unknown): string {
return value;
}
/**
* The modules directory exactly as `startApi` would choose it. An unusable configured value is not
* thrown here, because Doctor must still produce a report: it is handed on as a required directory
* for discovery to refuse, which reports it as a sanitized module-discovery failure.
*/
function doctorModulesDirectory(
configured: string | undefined,
repositoryRoot: string,
): ModulesDirectorySelection {
try {
return resolveModulesDirectory({ configured, repositoryRoot });
} catch {
return { modulesDirectory: configured ?? "", modulesDirectoryRequired: true };
}
}
/**
* Turns one deployment environment into Doctor's inputs.
*
@@ -69,6 +88,7 @@ export function createDoctorOptions(
const host = loadHostConfiguration(environment);
return {
clientAddress: host.clientAddress,
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
databaseEnvironment: host.environment,
databaseTransport: host.databaseTransport,
databaseUrl: host.databaseUrl,
@@ -76,7 +96,7 @@ export function createDoctorOptions(
mailConfiguration: host.mail,
moduleDiscovery: {
moduleSdkVersion: metadata.moduleSdkVersion,
modulesDirectory: metadata.modulesDirectory,
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
platformVersion: metadata.platformVersion,
},
moduleSecrets: host.moduleSecrets,
@@ -97,8 +117,8 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const report = await diagnoseEkklesia(
createDoctorOptions(process.env, {
moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion: requiredString(rootPackage.version),
repositoryRoot,
runtimeRange: requiredString(rootPackage.engines?.node),
runtimeVersion: process.versions.node,
}),
+10 -1
View File
@@ -1,7 +1,9 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { HostEnvironment } from "../host/index.js";
import { discoverAllMigrations } from "../migrations/index.js";
import type { ModuleMigrationAggregate } from "../modules/index.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
interface PackageVersion {
readonly version?: unknown;
@@ -15,9 +17,16 @@ async function readPackageVersion(packagePath: string): Promise<string> {
return packageData.version;
}
/**
* Core's migrations come from the platform (`<repositoryRoot>/core/drizzle`); installed modules'
* come from the configured modules directory, wherever it is (ADR 0048). Neither is ever derived
* from the other.
*/
export async function discoverRepositoryMigrations(
repositoryRoot = process.cwd(),
environment: HostEnvironment = process.env,
): Promise<ModuleMigrationAggregate> {
const modulesDirectory = resolveCommandModulesDirectory(environment, repositoryRoot);
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
@@ -27,7 +36,7 @@ export async function discoverRepositoryMigrations(
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
moduleDiscovery: {
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...modulesDirectory,
platformVersion,
},
});
+21 -3
View File
@@ -1,5 +1,9 @@
import type { ModuleMigrationAggregate } from "../modules/index.js";
import type { MigrationApplyResult, MigrationPlanResult } from "../migrations/index.js";
import type {
MigrationApplyResult,
MigrationPlanResult,
RetainedMigrationHistory,
} from "../migrations/index.js";
function plural(count: number, singular: string): string {
return count === 1 ? singular : `${singular}s`;
@@ -28,10 +32,19 @@ export function formatMigrationValidationReport(
return Object.freeze(lines);
}
/** An absent module's applied history (ADR 0048): kept, reported, and never a reason to stop. */
function retainedLines(retained: readonly RetainedMigrationHistory[]): readonly string[] {
return retained.map(
(module) =>
`- [retained] ${module.moduleId}: ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`,
);
}
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
const retainedSummary = plan.retained.length === 0 ? "" : `, ${plan.retained.length} retained`;
const lines = [
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked):`,
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked${retainedSummary}):`,
];
for (const entry of plan.entries) {
@@ -40,6 +53,7 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
if (plan.entries.length === 0) {
lines.push("- No installed module migrations.");
}
lines.push(...retainedLines(plan.retained));
lines.push(
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
);
@@ -49,7 +63,10 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
if (result.noOp) {
return Object.freeze(["Migration apply completed: database is already current (no changes)."]);
return Object.freeze([
"Migration apply completed: database is already current (no changes).",
...retainedLines(result.retained),
]);
}
const lines = [
@@ -58,5 +75,6 @@ export function formatMigrationApplyReport(result: MigrationApplyResult): readon
for (const migration of result.applied) {
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
}
lines.push(...retainedLines(result.retained));
return Object.freeze(lines);
}
@@ -0,0 +1,17 @@
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
/**
* The modules directory an operator command discovers, read through the same host configuration
* `startApi` and Doctor use and resolved by the one Core resolver (ADR 0048). Throws the resolver's
* sanitized `ModuleDiscoveryError` for an unusable value, which every command already reports.
*/
export function resolveCommandModulesDirectory(
environment: HostEnvironment,
repositoryRoot: string,
): ModulesDirectorySelection {
return resolveModulesDirectory({
configured: loadHostConfiguration(environment).modulesDirectory,
repositoryRoot,
});
}
+2 -1
View File
@@ -12,6 +12,7 @@ import {
resolveModuleEligibility,
} from "../modules/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
/**
* Enables or disables one installed module, out of band from the running API.
@@ -99,7 +100,7 @@ async function main(): Promise<void> {
// configurable here, which is the case this command exists for.
const discovered = await discoverModules({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...resolveCommandModulesDirectory(process.env, repositoryRoot),
platformVersion,
});
if (!discovered.some((module) => module.manifest.id === moduleId)) {
+2 -1
View File
@@ -9,6 +9,7 @@ import {
ModulePermissionCompositionError,
ModuleSchemaDiscoveryError,
} from "../modules/index.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
interface PackageVersion {
version?: unknown;
@@ -30,7 +31,7 @@ async function main(): Promise<void> {
]);
const schemaAggregate = await discoverModuleSchemas({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...resolveCommandModulesDirectory(process.env, repositoryRoot),
platformVersion,
});
const modules = schemaAggregate.discoveredModules;
+35 -17
View File
@@ -1,4 +1,3 @@
import path from "node:path";
import {
capabilityIdSchema,
EMPTY_MODULE_SECRET_SOURCE,
@@ -43,6 +42,7 @@ import {
} from "../mail/index.js";
import {
createPostgresMigrationDatabase,
type DiscoverAllMigrationsOptions,
discoverAllMigrations,
type MigrationDatabase,
type MigrationPlanResult,
@@ -64,6 +64,7 @@ import {
isConfigurationBlockedDetail,
isStorageBlockedDetail,
type ModuleConfigurationSettings,
ModuleDiscoveryError,
type ModuleDiscoveryOptions,
type ModuleEligibility,
type ModuleLifecycleConfiguration,
@@ -126,6 +127,9 @@ export interface DoctorIdentityConfigurationOptions {
export interface DoctorOptions {
readonly clientAddress?: ClientAddressConfigInput | undefined;
/** Core's own migrations, where the platform is installed. Never derived from the modules
* directory: installed modules live in installation-owned storage, anywhere at all (ADR 0048). */
readonly coreMigrationsDirectory: string;
/** The environment the executable resolved, used only for transport policy evaluation; identity
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
@@ -170,7 +174,7 @@ export interface DoctorDependencies {
options: PostgresMigrationDatabaseOptions,
) => MigrationDatabase;
readonly discoverMigrations: (
options: ModuleDiscoveryOptions,
options: DiscoverAllMigrationsOptions,
) => Promise<ModuleMigrationAggregate>;
readonly discoverModules: (
options: ModuleDiscoveryOptions,
@@ -207,15 +211,6 @@ const checksumPattern = /^[a-f0-9]{64}$/u;
const migrationIdentityPattern =
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
async function discoverAllMigrationsForRuntime(
options: ModuleDiscoveryOptions,
): Promise<ModuleMigrationAggregate> {
return discoverAllMigrations({
coreMigrationsDirectory: path.join(path.dirname(options.modulesDirectory), "core", "drizzle"),
moduleDiscovery: options,
});
}
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
async function readModuleLifecycleForRuntime(
@@ -273,7 +268,7 @@ async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase,
discoverMigrations: discoverAllMigrationsForRuntime,
discoverMigrations: discoverAllMigrations,
discoverModules,
discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations,
@@ -633,11 +628,23 @@ function storageBindingsCheck(
details,
);
}
function moduleDiscoveryFailureDetail(error: unknown): string {
if (!(error instanceof ModuleDiscoveryError)) {
return "Static module discovery could not be completed.";
}
// The modules directory itself, rather than anything in it (ADR 0048). Fixed wording, never the
// configured value: a path is machine detail.
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_INVALID")) {
return "The configured modules directory is not usable.";
}
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_UNREADABLE")) {
return "The configured modules directory could not be read.";
}
return "The installed module inventory is invalid.";
}
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
const details =
error instanceof Error && error.name === "ModuleDiscoveryError"
? ["The installed module inventory is invalid."]
: ["Static module discovery could not be completed."];
const details = [moduleDiscoveryFailureDetail(error)];
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
}
@@ -733,6 +740,14 @@ function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
}
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
}),
// An absent module's applied history (ADR 0048) is information, never attention: removing a
// module's code keeps its data, and nothing here is pending.
...plan.retained.map((module) => {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(module.moduleId)) {
throw new TypeError("Unsafe migration plan diagnostic value.");
}
return `retained · ${module.moduleId} · ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`;
}),
];
if (pendingCount + blockedCount === 0) {
@@ -1132,7 +1147,10 @@ export async function diagnoseEkklesia(
}
try {
migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
migrations = await dependencies.discoverMigrations({
coreMigrationsDirectory: options.coreMigrationsDirectory,
moduleDiscovery: options.moduleDiscovery,
});
const inventory = migrations.migrations.map((migration) =>
safeMigrationReference(migration.identity, migration.checksum),
);
+5
View File
@@ -59,6 +59,10 @@ export interface HostConfiguration {
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
* declared keys — there is no arbitrary variable lookup. */
readonly moduleSecrets: ModuleSecretSource;
/** `EKKLESIA_MODULES_DIR`, raw and unvalidated: `undefined` only when the variable is unset. Every
* executable passes it to `resolveModulesDirectory`, the one place it is interpreted (ADR 0048),
* so a relative or blank value is refused there rather than repaired here. */
readonly modulesDirectory: string | undefined;
readonly storage: StorageProviderConfigInput;
}
@@ -144,6 +148,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
username: environment.EKKLESIA_SMTP_USERNAME,
},
moduleSecrets: createEnvironmentModuleSecretSource(environment),
modulesDirectory: environment.EKKLESIA_MODULES_DIR,
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
// endpoint means this installation has no object storage, which is a complete deployment
// (ADR 0030).
+71 -9
View File
@@ -2,6 +2,7 @@ import type {
DiscoveredModuleMigration,
ModuleMigrationAggregate,
} from "../modules/migration-discovery.js";
import { CORE_MIGRATION_SOURCE_ID } from "./core-source.js";
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
@@ -86,11 +87,24 @@ export interface MigrationPlanEntry {
readonly tag: string;
}
/**
* Applied history kept for a module that is not installed (ADR 0048): the module id and how many of
* its migrations were applied. It is reported, never matched against an inventory, and never a
* reason to refuse planning or applying anything else.
*/
export interface RetainedMigrationHistory {
readonly appliedCount: number;
readonly moduleId: string;
}
export interface MigrationPlanResult {
readonly appliedCount: number;
readonly approvalRequiredCount: number;
readonly entries: readonly MigrationPlanEntry[];
readonly pendingCount: number;
/** Absent modules' applied history, ordered by module id; empty when every applied migration
* belongs to Core or an installed module. */
readonly retained: readonly RetainedMigrationHistory[];
}
export interface AppliedMigrationResult {
@@ -105,6 +119,8 @@ export interface MigrationApplyResult {
readonly applied: readonly AppliedMigrationResult[];
readonly appliedCount: number;
readonly noOp: boolean;
/** The same report as `MigrationPlanResult.retained`, read under the migration lock. */
readonly retained: readonly RetainedMigrationHistory[];
}
export interface PlanModuleMigrationsOptions {
@@ -204,10 +220,38 @@ function validateHistoryRecordShape(record: AppliedMigrationRecord): void {
}
}
interface ValidatedAppliedHistory {
/** Identities of applied migrations that belong to Core or an installed module. */
readonly applied: ReadonlySet<string>;
readonly retained: readonly RetainedMigrationHistory[];
}
/**
* Every module id this inventory treats as installed: each discovered module, whether or not it
* owns migrations, every module that contributed migrations, and always Core. A module counts as
* absent only when it is none of these, so a module that is installed but no longer ships its old
* migrations stays strict, and Core's history can never be classified as an absent module's.
*/
function installedMigrationSources(aggregate: ModuleMigrationAggregate): ReadonlySet<string> {
return new Set<string>([
CORE_MIGRATION_SOURCE_ID,
...aggregate.discoveredModules.map((module) => module.manifest.id),
...aggregate.modules.map((module) => module.moduleId),
]);
}
function freezeRetained(counts: ReadonlyMap<string, number>): readonly RetainedMigrationHistory[] {
return Object.freeze(
[...counts.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([moduleId, appliedCount]) => Object.freeze({ appliedCount, moduleId })),
);
}
function validateAppliedHistoryUnchecked(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): ReadonlySet<string> {
): ValidatedAppliedHistory {
if (!Array.isArray(history)) {
throw runnerError("MIGRATION_HISTORY_INVALID");
}
@@ -215,6 +259,9 @@ function validateAppliedHistoryUnchecked(
const inventory = new Map(
aggregate.migrations.map((migration) => [migration.identity, migration]),
);
const installed = installedMigrationSources(aggregate);
const applied = new Set<string>();
const retained = new Map<string, number>();
const seenOrders = new Set<number>();
const seenIdentities = new Set<string>();
const seenModuleIndexes = new Set<string>();
@@ -240,7 +287,19 @@ function validateAppliedHistoryUnchecked(
const migration = knownMigration(inventory, record.identity);
if (!migration) {
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
if (installed.has(record.moduleId)) {
// Core, or a module that is installed now: its history is strict, exactly as ADR 0009
// established. A migration it no longer ships is a downgrade or a rewritten package.
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
}
// An absent module's history (ADR 0048): retained and reported, never repaired, deleted, or
// matched against anything. It must still name its own module and tag, so a corrupt row is
// never mistaken for one.
if (record.identity !== `${record.moduleId}:${record.tag}`) {
throw runnerError("MIGRATION_HISTORY_INVALID");
}
retained.set(record.moduleId, (retained.get(record.moduleId) ?? 0) + 1);
continue;
}
if (
record.moduleId !== migration.moduleId ||
@@ -252,11 +311,12 @@ function validateAppliedHistoryUnchecked(
if (record.checksum !== migration.checksum) {
throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity);
}
applied.add(record.identity);
}
for (const module of aggregate.modules) {
const applied = history.filter((record) => record.moduleId === module.moduleId);
for (const [position, record] of applied.entries()) {
const moduleHistory = history.filter((record) => record.moduleId === module.moduleId);
for (const [position, record] of moduleHistory.entries()) {
if (record.identity !== module.migrations[position]?.identity) {
const migration = knownMigration(inventory, record.identity);
throw runnerError("MIGRATION_HISTORY_INVALID", migration?.identity);
@@ -264,13 +324,13 @@ function validateAppliedHistoryUnchecked(
}
}
return seenIdentities;
return Object.freeze({ applied, retained: freezeRetained(retained) });
}
function validateAppliedHistory(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): ReadonlySet<string> {
): ValidatedAppliedHistory {
try {
return validateAppliedHistoryUnchecked(aggregate, history);
} catch (error) {
@@ -285,7 +345,7 @@ function planFromHistory(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): MigrationPlanResult {
const applied = validateAppliedHistory(aggregate, history);
const { applied, retained } = validateAppliedHistory(aggregate, history);
const entries = Object.freeze(
aggregate.migrations.map((migration): MigrationPlanEntry => {
const isApplied = applied.has(migration.identity);
@@ -314,6 +374,7 @@ function planFromHistory(
approvalRequiredCount,
entries,
pendingCount: entries.length - appliedCount,
retained,
});
}
@@ -472,14 +533,14 @@ export async function applyModuleMigrations(
} catch {
throw runnerError("MIGRATION_HISTORY_READ_FAILED");
}
const applied = validateAppliedHistory(options.aggregate, history);
const { applied, retained } = validateAppliedHistory(options.aggregate, history);
const pending = Object.freeze(
options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)),
);
await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals);
if (pending.length === 0) {
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true });
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained });
}
let executingIdentity: string | undefined;
@@ -508,6 +569,7 @@ export async function applyModuleMigrations(
applied: resultMigrations,
appliedCount: resultMigrations.length,
noOp: false,
retained,
});
} catch (error) {
primaryError = error;
+35 -1
View File
@@ -47,6 +47,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_PLATFORM_INCOMPATIBLE"
| "MODULE_SDK_INCOMPATIBLE"
| "MODULE_VERSION_MISMATCH"
| "MODULES_DIRECTORY_INVALID"
| "MODULES_DIRECTORY_UNREADABLE";
export interface ModuleDiscoveryIssue {
@@ -117,9 +118,35 @@ export interface DiscoveredModule {
export interface ModuleDiscoveryOptions {
readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
/**
* True when an operator configured the directory explicitly (`EKKLESIA_MODULES_DIR`, ADR 0048).
* A configured directory that is missing, not a directory, or not an absolute path then fails
* discovery closed rather than reading as zero modules: a mistyped mount must never quietly start
* the platform with nothing installed. False or omitted keeps the development default lenient,
* where a checkout with no `modules/` simply has no modules.
*/
readonly modulesDirectoryRequired?: boolean;
readonly platformVersion: string;
}
/** The one sanitized message for an unusable configured modules directory. It never carries the
* value: a path is machine detail, and a misplaced value could be anything at all. */
export const MODULES_DIRECTORY_INVALID_MESSAGE = "EKKLESIA_MODULES_DIR must be an absolute path.";
/**
* Whether a configured modules directory is usable as given: a non-empty absolute path with no
* surrounding whitespace and no NUL byte. Deliberately strict rather than forgiving, because
* trimming or resolving a near-miss would silently pick a directory the operator did not name.
*/
export function isUsableModulesDirectoryPath(value: string): boolean {
return (
value.length > 0 &&
value.trim() === value &&
!value.includes("\u0000") &&
path.isAbsolute(value)
);
}
interface ModulePackageData {
name: string;
version: string;
@@ -695,6 +722,13 @@ export async function discoverModules(
});
}
}
if (options.modulesDirectoryRequired && !isUsableModulesDirectoryPath(options.modulesDirectory)) {
issues.push({
code: "MODULES_DIRECTORY_INVALID",
message: MODULES_DIRECTORY_INVALID_MESSAGE,
moduleDirectory: ".",
});
}
if (issues.length > 0) {
throw new ModuleDiscoveryError(issues);
}
@@ -703,7 +737,7 @@ export async function discoverModules(
try {
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
} catch (error) {
if (errorCode(error) === "ENOENT") {
if (errorCode(error) === "ENOENT" && !options.modulesDirectoryRequired) {
return Object.freeze([]);
}
throw new ModuleDiscoveryError([
+1
View File
@@ -7,6 +7,7 @@ export * from "./lifecycle.js";
export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js";
export * from "./migration-discovery.js";
export * from "./modules-directory.js";
export * from "./permissions.js";
export * from "./realtime.js";
export * from "./runtime.js";
+66
View File
@@ -0,0 +1,66 @@
import path from "node:path";
import {
isUsableModulesDirectoryPath,
MODULES_DIRECTORY_INVALID_MESSAGE,
ModuleDiscoveryError,
} from "./discovery.js";
/** The host environment variable that selects the installed-modules directory (ADR 0048). */
export const MODULES_DIRECTORY_ENVIRONMENT_VARIABLE = "EKKLESIA_MODULES_DIR";
export interface ResolveModulesDirectoryOptions {
/** The raw configured value, exactly as the host environment supplied it; `undefined` when the
* variable is unset. */
readonly configured: string | undefined;
/** Where the platform itself is installed. Only consulted for the development default. */
readonly repositoryRoot: string;
}
/** Spreads directly into `ModuleDiscoveryOptions`, so every consumer discovers through the same
* selection, including whether a missing directory is an error. */
export interface ModulesDirectorySelection {
readonly modulesDirectory: string;
readonly modulesDirectoryRequired: boolean;
}
/**
* The one place an installation's modules directory is chosen (ADR 0048).
*
* - Unset: `<repositoryRoot>/modules`, the development checkout's own directory. A checkout without
* one has no modules, exactly as before.
* - Set: an absolute path, normalized and used as given — never resolved against the repository or
* the working directory — and required to exist when discovery reads it. The shipping image sets
* it to `/var/lib/ekklesia/modules`.
*
* Anything else throws a `ModuleDiscoveryError` carrying `MODULES_DIRECTORY_INVALID` and a fixed
* message, so every executable already reports it through the discovery error path it has, and the
* supplied value never reaches an error, a log, or a report.
*
* Nothing here — and nothing that calls it — locates the platform's own files relative to the
* result: Core's migrations and package metadata live under `repositoryRoot`, wherever modules are.
*/
export function resolveModulesDirectory(
options: ResolveModulesDirectoryOptions,
): ModulesDirectorySelection {
if (options.configured === undefined) {
return Object.freeze({
modulesDirectory: path.join(options.repositoryRoot, "modules"),
modulesDirectoryRequired: false,
});
}
if (!isUsableModulesDirectoryPath(options.configured)) {
throw new ModuleDiscoveryError([
{
code: "MODULES_DIRECTORY_INVALID",
message: MODULES_DIRECTORY_INVALID_MESSAGE,
moduleDirectory: ".",
},
]);
}
const normalized = path.normalize(options.configured);
return Object.freeze({
modulesDirectory:
normalized.length > 1 && normalized.endsWith(path.sep) ? normalized.slice(0, -1) : normalized,
modulesDirectoryRequired: true,
});
}
+36 -5
View File
@@ -8,10 +8,11 @@ import {
diagnoseEkklesia,
formatDoctorReport,
} from "../src/diagnostics/index.js";
import type {
DiscoveredModule,
ModuleMigrationAggregate,
ModuleSchemaAggregate,
import {
type DiscoveredModule,
discoverModules,
type ModuleMigrationAggregate,
type ModuleSchemaAggregate,
} from "../src/modules/index.js";
/**
@@ -34,8 +35,8 @@ const moduleSecret = "module-secret-material";
const metadata = Object.freeze({
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
repositoryRoot: "/not-rendered",
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
});
@@ -454,3 +455,33 @@ describe("Doctor host-configuration parity — sanitization", () => {
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY");
});
});
describe("Doctor reads the modules directory from the same host configuration (ADR 0048)", () => {
it("uses the repository's own modules directory when EKKLESIA_MODULES_DIR is unset", () => {
const doctorOptions = createDoctorOptions(environment(), metadata);
expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/not-rendered/modules");
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(false);
expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle");
});
it("uses a configured directory, and still finds Core's migrations under the platform", () => {
const doctorOptions = createDoctorOptions(
environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }),
metadata,
);
expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/var/lib/ekklesia/modules");
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true);
expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle");
});
it("hands an unusable value to discovery to report, rather than throwing before a report exists", async () => {
const doctorOptions = createDoctorOptions(
environment({ EKKLESIA_MODULES_DIR: "relative/private-mount-91aa" }),
metadata,
);
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true);
const report = await diagnoseEkklesia(doctorOptions, dependencies({ discoverModules }));
expect(report.checks.find((entry) => entry.id === "modules.discovery")?.status).toBe("failure");
expect(JSON.stringify(report)).not.toContain("private-mount-91aa");
});
});
+4
View File
@@ -68,6 +68,7 @@ describe("Ekklesia Doctor terminal report", () => {
it("renders the real zero-module and no-database shape exactly", async () => {
const report = await diagnoseEkklesia(
{
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
@@ -135,6 +136,7 @@ Result: READY WITH WARNINGS`);
it("renders every check the report contains, so none can silently affect the result", async () => {
const report = await diagnoseEkklesia(
{
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
@@ -159,6 +161,7 @@ Result: READY WITH WARNINGS`);
it("does not depend on NO_COLOR or terminal interactivity", async () => {
const report = await diagnoseEkklesia(
{
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
@@ -207,6 +210,7 @@ Result: READY WITH WARNINGS`);
const report = await diagnoseEkklesia(
{
databaseUrl: "postgresql://another:[email protected]/db",
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
+121 -2
View File
@@ -1,5 +1,9 @@
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
import {
type DoctorCheck,
@@ -114,7 +118,10 @@ function emptyDatabase(): MigrationDatabase {
});
}
function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult {
function plan(
entries: MigrationPlanResult["entries"] = [],
retained: MigrationPlanResult["retained"] = [],
): MigrationPlanResult {
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
const approvalRequiredCount = entries.filter(
(entry) => entry.status === "approval-blocked",
@@ -124,6 +131,7 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult
approvalRequiredCount,
entries: Object.freeze([...entries]),
pendingCount: entries.length - appliedCount,
retained: Object.freeze([...retained]),
});
}
@@ -151,6 +159,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
function options(databaseUrl?: string): DoctorOptions {
return {
...(databaseUrl === undefined ? {} : { databaseUrl }),
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
@@ -1657,3 +1666,113 @@ describe("Doctor reports declared browser contributions", () => {
]);
});
});
describe("Doctor with installation-owned module storage (ADR 0048)", () => {
const platformCoreMigrations = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
it("finds Core's migrations where the platform is installed, not beside the modules", async () => {
// A modules directory far from the platform, as the image mounts /var/lib/ekklesia/modules. Its
// parent has no `core/drizzle`, so a Doctor that still derived Core's migrations from the
// modules directory would fail migration discovery here.
const external = await mkdtemp(path.join(tmpdir(), "ekklesia-external-modules-"));
temporaryDirectories.push(external);
const report = await diagnoseEkklesia({
coreMigrationsDirectory: platformCoreMigrations,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: external,
modulesDirectoryRequired: true,
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
});
expect(check(report, "modules.discovery").status).toBe("pass");
const migrations = check(report, "migrations.discovery");
expect(migrations.status).toBe("pass");
expect(migrations.details.some((detail) => detail.startsWith("core:0000_"))).toBe(true);
expect(JSON.stringify(report)).not.toContain(external);
});
it("reports an unusable configured modules directory as a discovery failure, naming no path", async () => {
const report = await diagnoseEkklesia({
coreMigrationsDirectory: platformCoreMigrations,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "relative/private-mount-3c9d",
modulesDirectoryRequired: true,
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
});
const discovery = check(report, "modules.discovery");
expect(discovery.status).toBe("failure");
expect(discovery.details).toEqual(["The configured modules directory is not usable."]);
expect(check(report, "migrations.discovery").status).toBe("skipped");
expect(JSON.stringify(report)).not.toContain("private-mount-3c9d");
});
it("reports a configured modules directory that does not exist, naming no path", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "ekklesia-missing-modules-"));
temporaryDirectories.push(parent);
const report = await diagnoseEkklesia({
coreMigrationsDirectory: platformCoreMigrations,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: path.join(parent, "absent"),
modulesDirectoryRequired: true,
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
});
const discovery = check(report, "modules.discovery");
expect(discovery.status).toBe("failure");
expect(discovery.details).toEqual(["The configured modules directory could not be read."]);
expect(JSON.stringify(report)).not.toContain(parent);
});
it("reports an absent module's retained history without blocking readiness", async () => {
const report = await diagnoseEkklesia(
options("postgres://[email protected]/ekklesia"),
dependencies({
planMigrations: vi.fn(async () =>
plan(
[planEntry("core:0000_identity_core_tables", "a".repeat(64), "applied")],
[Object.freeze({ appliedCount: 2, moduleId: "calendar" })],
),
),
}),
);
const planCheck = check(report, "postgresql.plan");
expect(planCheck.status).toBe("pass");
expect(planCheck.details).toContain(
"retained · calendar · 2 applied migrations kept; the module is not installed",
);
});
it("passes Core's own migration directory to migration discovery", async () => {
const discoverMigrations = vi.fn(async () => migrationAggregate());
await diagnoseEkklesia(options(), dependencies({ discoverMigrations }));
expect(discoverMigrations).toHaveBeenCalledWith({
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: options().moduleDiscovery,
});
});
});
@@ -68,6 +68,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
"EKKLESIA_IDENTITY_BASE_URL",
"EKKLESIA_IDENTITY_SECRET",
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
"EKKLESIA_MODULES_DIR",
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
"EKKLESIA_SMTP_FROM_ADDRESS",
"EKKLESIA_SMTP_FROM_NAME",
+13
View File
@@ -48,6 +48,19 @@ describe("host configuration loading", () => {
expect(host.identity.secret).toBeUndefined();
});
it("carries the configured modules directory verbatim, and nothing when it is unset", () => {
expect(loadHostConfiguration({}).modulesDirectory).toBeUndefined();
expect(
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }).modulesDirectory,
).toBe("/var/lib/ekklesia/modules");
// Validation is the resolver's job (ADR 0048). The loader neither repairs a relative or blank
// value nor treats a set-but-empty variable as unset, so the resolver can refuse it.
expect(
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "relative/modules" }).modulesDirectory,
).toBe("relative/modules");
expect(loadHostConfiguration({ EKKLESIA_MODULES_DIR: "" }).modulesDirectory).toBe("");
});
it("carries every object-storage field the storage passes established", () => {
const host = loadHostConfiguration(productionEnvironment);
+67 -1
View File
@@ -40,6 +40,7 @@ describe("migration command reports", () => {
}),
]),
pendingCount: 2,
retained: Object.freeze([]),
});
expect(formatMigrationPlanReport(plan)).toEqual([
@@ -64,6 +65,7 @@ describe("migration command reports", () => {
]),
appliedCount: 1,
noOp: false,
retained: Object.freeze([]),
});
expect(formatMigrationApplyReport(applied)).toEqual([
@@ -72,7 +74,12 @@ describe("migration command reports", () => {
]);
expect(
formatMigrationApplyReport(
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true }),
Object.freeze({
applied: Object.freeze([]),
appliedCount: 0,
noOp: true,
retained: Object.freeze([]),
}),
),
).toEqual(["Migration apply completed: database is already current (no changes)."]);
});
@@ -85,6 +92,7 @@ describe("migration command reports", () => {
approvalRequiredCount: 0,
entries: Object.freeze([]),
pendingCount: 0,
retained: Object.freeze([]),
}),
),
).toEqual([
@@ -93,4 +101,62 @@ describe("migration command reports", () => {
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
]);
});
it("reports an absent module's retained history without treating it as work (ADR 0048)", () => {
const retained = Object.freeze([
Object.freeze({ appliedCount: 1, moduleId: "calendar" }),
Object.freeze({ appliedCount: 3, moduleId: "signage" }),
]);
const retainedLines = [
"- [retained] calendar: 1 applied migration kept; the module is not installed",
"- [retained] signage: 3 applied migrations kept; the module is not installed",
];
expect(
formatMigrationPlanReport(
Object.freeze({
appliedCount: 0,
approvalRequiredCount: 0,
entries: Object.freeze([]),
pendingCount: 0,
retained,
}),
),
).toEqual([
"Migration plan (0 applied, 0 pending, 0 approval-blocked, 2 retained):",
"- No installed module migrations.",
...retainedLines,
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
]);
expect(
formatMigrationApplyReport(
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained }),
),
).toEqual([
"Migration apply completed: database is already current (no changes).",
...retainedLines,
]);
expect(
formatMigrationApplyReport(
Object.freeze({
applied: Object.freeze([
Object.freeze({
checksum: "e".repeat(64),
identity: "core:0007_later",
index: 7,
moduleId: "core",
tag: "0007_later",
}),
]),
appliedCount: 1,
noOp: false,
retained,
}),
),
).toEqual([
"Migration apply completed (1 migration applied):",
`- core:0007_later sha256=${"e".repeat(64)}`,
...retainedLines,
]);
});
});
+254 -3
View File
@@ -13,6 +13,7 @@ import {
type MigrationTransaction,
planModuleMigrations,
} from "../src/migrations/runner.js";
import type { DiscoveredModule } from "../src/modules/discovery.js";
import type {
DiscoveredModuleMigration,
ModuleMigrationAggregate,
@@ -323,6 +324,7 @@ describe("module migration planning", () => {
},
],
pendingCount: 1,
retained: [],
});
expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
expect(database.historyTableExists).toBe(false);
@@ -344,10 +346,11 @@ describe("module migration planning", () => {
approvalRequiredCount: 0,
entries: [],
pendingCount: 0,
retained: [],
});
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.entries)).toBe(true);
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
expect(database.historyTableExists).toBe(false);
expect(database.events).not.toContain("transaction:begin");
});
@@ -548,6 +551,7 @@ describe("module migration apply", () => {
],
appliedCount: 1,
noOp: false,
retained: [],
});
});
@@ -580,7 +584,7 @@ describe("module migration apply", () => {
database,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.applied)).toBe(true);
expectNoPersistentMutation(database);
@@ -601,7 +605,7 @@ describe("module migration apply", () => {
database,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
expect(database.historyTableExists).toBe(false);
expect(database.events).toEqual([
"session:open",
@@ -980,3 +984,250 @@ describe("module migration failures and side effects", () => {
expect(database.committedHistory).toEqual([]);
});
});
/**
* Marks modules as present in the discovered inventory without giving them migrations, the way a
* real aggregate lists every discovered module whether or not it owns migration history. Only the
* manifest id is read by the runner.
*/
function withDiscovered(
inventory: ModuleMigrationAggregate,
...moduleIds: readonly string[]
): ModuleMigrationAggregate {
return Object.freeze({
...inventory,
discoveredModules: Object.freeze(
moduleIds.map((id) => ({ manifest: { id } }) as unknown as DiscoveredModule),
),
});
}
describe("retained history for absent modules (ADR 0048)", () => {
it("retains and reports an absent module's applied history without failing the plan", async () => {
const coreInitial = migration("core", 0);
const calendarInitial = migration("calendar", 0);
const calendarLater = migration("calendar", 1);
const history = [
historyRecord(coreInitial, 1),
historyRecord(calendarInitial, 2),
historyRecord(calendarLater, 3),
];
const database = new FakeMigrationDatabase({ history });
const result = await planModuleMigrations({
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
database,
});
expect(result.appliedCount).toBe(1);
expect(result.pendingCount).toBe(0);
expect(result.entries.map((entry) => entry.identity)).toEqual([coreInitial.identity]);
expect(result.retained).toEqual([{ appliedCount: 2, moduleId: "calendar" }]);
expect(Object.isFrozen(result.retained)).toBe(true);
expect(Object.isFrozen(result.retained[0])).toBe(true);
expect(database.committedHistory).toEqual(history);
expectNoPersistentMutation(database);
});
it("applies a later Core migration while a module is absent, leaving its history intact", async () => {
const coreInitial = migration("core", 0);
const coreLater = migration("core", 1);
const calendarInitial = migration("calendar", 0);
const history = [historyRecord(coreInitial, 1), historyRecord(calendarInitial, 2)];
const database = new FakeMigrationDatabase({ history });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "core", migrations: [coreInitial, coreLater] }),
database,
});
expect(result.applied.map((applied) => applied.identity)).toEqual([coreLater.identity]);
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
expect(database.executedStatements).toEqual([coreLater.sql]);
expect(database.committedHistory.map((record) => record.identity)).toEqual([
coreInitial.identity,
calendarInitial.identity,
coreLater.identity,
]);
});
it("applies a present module's migrations while another module is absent", async () => {
const peopleInitial = migration("people", 0);
const calendarInitial = migration("calendar", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(calendarInitial, 1)] });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
database,
});
expect(result.applied.map((applied) => applied.identity)).toEqual([peopleInitial.identity]);
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
});
it("reports retained modules in a stable order with exact counts", async () => {
const zeta = [migration("zeta", 0), migration("zeta", 1), migration("zeta", 2)];
const alpha = migration("alpha", 0);
const database = new FakeMigrationDatabase({
history: [
historyRecord(zeta[0] as DiscoveredModuleMigration, 1),
historyRecord(alpha, 2),
historyRecord(zeta[1] as DiscoveredModuleMigration, 3),
historyRecord(zeta[2] as DiscoveredModuleMigration, 4),
],
});
const result = await planModuleMigrations({ aggregate: aggregate(), database });
expect(result.retained).toEqual([
{ appliedCount: 1, moduleId: "alpha" },
{ appliedCount: 3, moduleId: "zeta" },
]);
});
it("returns a module to strict validation: valid history plans cleanly and nothing is retained", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
const result = await planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
database,
});
expect(result.retained).toEqual([]);
expect(result.entries.map((entry) => [entry.identity, entry.status])).toEqual([
[initial.identity, "applied"],
[later.identity, "pending"],
]);
});
it("fails when a returning module no longer ships a previously applied migration", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({
history: [historyRecord(initial, 1), historyRecord(later, 2)],
});
const downgraded = aggregate({ id: "people", migrations: [initial] });
await captureRunnerError(
planModuleMigrations({ aggregate: downgraded, database }),
"MIGRATION_UNKNOWN_APPLIED",
);
await captureRunnerError(
applyModuleMigrations({ aggregate: downgraded, database }),
"MIGRATION_UNKNOWN_APPLIED",
);
expect(database.executedStatements).toEqual([]);
});
it("fails when a returning module's applied migration has different contents", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
const edited = aggregate({
id: "people",
migrations: [migration("people", 0, { sql: "SELECT 'edited';\n" })],
});
const error = await captureRunnerError(
planModuleMigrations({ aggregate: edited, database }),
"MIGRATION_CHECKSUM_MISMATCH",
);
expect(error.migrationIdentity).toBe(initial.identity);
});
it("fails when a returning module's history is no longer a prefix of its journal", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({ history: [historyRecord(later, 1)] });
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
database,
}),
"MIGRATION_HISTORY_INVALID",
);
});
it("keeps a present module that no longer ships any migrations strict", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
await captureRunnerError(
planModuleMigrations({ aggregate: withDiscovered(aggregate(), "people"), database }),
"MIGRATION_UNKNOWN_APPLIED",
);
});
it("never treats Core's history as an absent module's", async () => {
const coreInitial = migration("core", 0);
const coreGhost = migration("core", 1);
// An inventory with no Core source at all: Core's rows are still strict, never retained.
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [] }),
database: new FakeMigrationDatabase({ history: [historyRecord(coreInitial, 1)] }),
}),
"MIGRATION_UNKNOWN_APPLIED",
);
// A Core row missing from Core's own inventory.
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
database: new FakeMigrationDatabase({
history: [historyRecord(coreInitial, 1), historyRecord(coreGhost, 2)],
}),
}),
"MIGRATION_UNKNOWN_APPLIED",
);
});
it.each([
[
"an identity that does not name its own module and tag",
(source: DiscoveredModuleMigration) => [
historyRecord(source, 1, { identity: "calendar:0099_elsewhere" }),
],
],
[
"a duplicate applied order",
(source: DiscoveredModuleMigration) => [
historyRecord(source, 1),
historyRecord(migration("calendar", 1), 1),
],
],
[
"a duplicate index",
(source: DiscoveredModuleMigration) => [
historyRecord(source, 1),
historyRecord(migration("calendar", 1), 2, { index: 0 }),
],
],
])("still rejects retained rows with %s", async (_label, makeHistory) => {
const database = new FakeMigrationDatabase({
history: makeHistory(migration("calendar", 0)),
});
await captureRunnerError(
planModuleMigrations({ aggregate: aggregate(), database }),
"MIGRATION_HISTORY_INVALID",
);
});
it("rejects a retained row that claims a present module's migration identity", async () => {
const peopleInitial = migration("people", 0);
const database = new FakeMigrationDatabase({
history: [historyRecord(peopleInitial, 1, { moduleId: "calendar" })],
});
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
database,
}),
"MIGRATION_HISTORY_INVALID",
);
});
});
@@ -0,0 +1,116 @@
import { cp, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js";
import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js";
import { ModuleDiscoveryError } from "../src/modules/index.js";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const templateModule = path.join(repositoryRoot, "templates", "module");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function externalModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-installed-modules-"));
temporaryDirectories.push(directory);
return directory;
}
/** Installs the module template's migration-bearing package files as module `example`. Migration
* discovery imports nothing, so the template's manifest, package metadata, and migrations are all
* it reads; the compiled artifacts discovery checks for are copied too when they exist. */
async function installTemplate(modulesDirectory: string): Promise<void> {
const target = path.join(modulesDirectory, "example");
for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) {
await cp(path.join(templateModule, entry), path.join(target, entry), { recursive: true });
}
}
describe("operator commands select the modules directory through host configuration", () => {
it("defaults to the repository's own modules directory", () => {
expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({
modulesDirectory: "/opt/ekklesia/modules",
modulesDirectoryRequired: false,
});
});
it("honours EKKLESIA_MODULES_DIR", () => {
expect(
resolveCommandModulesDirectory(
{ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" },
"/opt/ekklesia",
),
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
});
it("refuses a relative EKKLESIA_MODULES_DIR without echoing it", () => {
expect(() =>
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"),
).toThrow(ModuleDiscoveryError);
try {
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia");
} catch (error) {
expect(JSON.stringify((error as ModuleDiscoveryError).issues)).not.toContain("9e1f");
}
});
it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => {
const modulesDirectory = await externalModulesDirectory();
await installTemplate(modulesDirectory);
const aggregate = await discoverRepositoryMigrations(repositoryRoot, {
EKKLESIA_MODULES_DIR: modulesDirectory,
});
const sources = aggregate.modules.map((module) => module.moduleId);
expect(sources[0]).toBe("core");
expect(sources).toContain("example");
expect(
aggregate.migrations.some((migration) => migration.identity === "example:0000_initial"),
).toBe(true);
const coreTags = (await readdir(path.join(repositoryRoot, "core", "drizzle"))).filter((name) =>
name.endsWith(".sql"),
);
expect(aggregate.modules[0]?.migrations).toHaveLength(coreTags.length);
});
it("fails closed when the configured directory does not exist", async () => {
const parent = await externalModulesDirectory();
await expect(
discoverRepositoryMigrations(repositoryRoot, {
EKKLESIA_MODULES_DIR: path.join(parent, "absent"),
}),
).rejects.toBeInstanceOf(ModuleDiscoveryError);
});
});
describe("no source chooses a modules directory except the resolver", () => {
async function sourceFiles(directory: string): Promise<readonly string[]> {
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
.map((entry) => path.join(entry.parentPath, entry.name));
}
it("never joins `modules` onto a root anywhere else in Core or the API", async () => {
const files = [
...(await sourceFiles(path.join(repositoryRoot, "core", "src"))),
...(await sourceFiles(path.join(repositoryRoot, "apps", "api", "src"))),
];
const resolver = path.join(repositoryRoot, "core", "src", "modules", "modules-directory.ts");
for (const file of files) {
if (file === resolver) {
continue;
}
const text = await readFile(file, "utf8");
expect(text, path.relative(repositoryRoot, file)).not.toMatch(/,\s*"modules"\s*\)/u);
}
});
});
+159
View File
@@ -0,0 +1,159 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverModules,
ModuleDiscoveryError,
type ModuleDiscoveryIssueCode,
resolveModulesDirectory,
} from "../src/modules/index.js";
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
const repositoryRoot = path.join(path.sep, "opt", "ekklesia");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-modules-directory-"));
temporaryDirectories.push(directory);
return directory;
}
function captureDiscoveryError(operation: () => unknown): ModuleDiscoveryError {
try {
operation();
} catch (error) {
expect(error).toBeInstanceOf(ModuleDiscoveryError);
return error as ModuleDiscoveryError;
}
throw new Error("Expected a ModuleDiscoveryError.");
}
async function captureAsyncDiscoveryError(
operation: Promise<unknown>,
): Promise<ModuleDiscoveryError> {
try {
await operation;
} catch (error) {
expect(error).toBeInstanceOf(ModuleDiscoveryError);
return error as ModuleDiscoveryError;
}
throw new Error("Expected a ModuleDiscoveryError.");
}
function codes(error: ModuleDiscoveryError): readonly ModuleDiscoveryIssueCode[] {
return error.issues.map((issue) => issue.code);
}
/** Everything an operator, a log, or a test report could ever see from this error. */
function rendered(error: ModuleDiscoveryError): string {
return JSON.stringify({ issues: error.issues, message: error.message, stack: error.stack });
}
describe("resolveModulesDirectory", () => {
it("defaults to the repository's own modules directory when nothing is configured", () => {
expect(resolveModulesDirectory({ configured: undefined, repositoryRoot })).toEqual({
modulesDirectory: path.join(repositoryRoot, "modules"),
modulesDirectoryRequired: false,
});
});
it("accepts an explicit absolute path, normalized, and requires it to exist", () => {
expect(
resolveModulesDirectory({ configured: "/var/lib/ekklesia/modules/", repositoryRoot }),
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
expect(
resolveModulesDirectory({ configured: "/var/lib/./ekklesia//modules", repositoryRoot }),
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
});
it("never resolves a configured path against the repository or the working directory", () => {
const resolved = resolveModulesDirectory({ configured: "/srv/modules", repositoryRoot });
expect(resolved.modulesDirectory).toBe("/srv/modules");
});
it.each([
["a relative name", "modules"],
["a dot-relative path", "./modules"],
["a parent-relative path", "../private/modules"],
["a home-relative path", "~/modules"],
["the empty string", ""],
["whitespace only", " "],
["an absolute path with surrounding whitespace", " /var/lib/ekklesia/modules"],
["a NUL byte", "/var/lib/ekklesia/\u0000modules"],
])("rejects %s with a sanitized MODULES_DIRECTORY_INVALID", (_label, configured) => {
const error = captureDiscoveryError(() =>
resolveModulesDirectory({ configured, repositoryRoot }),
);
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
expect(error.issues[0]?.message).toBe("EKKLESIA_MODULES_DIR must be an absolute path.");
});
it("never echoes the supplied value in the error", () => {
const configured = "relative/secret-token-a1b2c3/postgresql://admin:hunter2@db/modules";
const error = captureDiscoveryError(() =>
resolveModulesDirectory({ configured, repositoryRoot }),
);
const text = rendered(error);
expect(text).not.toContain("secret-token-a1b2c3");
expect(text).not.toContain("hunter2");
expect(text).not.toContain("relative/");
});
});
describe("module discovery against a configured modules directory", () => {
it("fails closed, without the path, when a configured directory does not exist", async () => {
const parent = await temporaryDirectory();
const missing = path.join(parent, "private-mount-name-7f3e");
const error = await captureAsyncDiscoveryError(
discoverModules({ ...versions, modulesDirectory: missing, modulesDirectoryRequired: true }),
);
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
expect(rendered(error)).not.toContain("private-mount-name-7f3e");
expect(rendered(error)).not.toContain(parent);
});
it("fails closed when a configured path is a file rather than a directory", async () => {
const parent = await temporaryDirectory();
const file = path.join(parent, "modules");
await writeFile(file, "not a directory");
const error = await captureAsyncDiscoveryError(
discoverModules({ ...versions, modulesDirectory: file, modulesDirectoryRequired: true }),
);
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
expect(rendered(error)).not.toContain(parent);
});
it("rejects a relative configured path even when it bypasses the resolver", async () => {
const error = await captureAsyncDiscoveryError(
discoverModules({ ...versions, modulesDirectory: "modules", modulesDirectoryRequired: true }),
);
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
});
it("treats an existing, empty configured directory as zero installed modules", async () => {
const directory = await temporaryDirectory();
await expect(
discoverModules({ ...versions, modulesDirectory: directory, modulesDirectoryRequired: true }),
).resolves.toEqual([]);
});
it("keeps the development default lenient: a missing default directory is zero modules", async () => {
const parent = await temporaryDirectory();
await expect(
discoverModules({ ...versions, modulesDirectory: path.join(parent, "modules") }),
).resolves.toEqual([]);
});
it("discovers through the resolver's own selection unchanged", async () => {
const directory = await temporaryDirectory();
const selection = resolveModulesDirectory({ configured: directory, repositoryRoot });
await expect(discoverModules({ ...versions, ...selection })).resolves.toEqual([]);
});
});
+197 -5
View File
@@ -1,5 +1,7 @@
import { createHash, randomBytes } from "node:crypto";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Client, type QueryResult, type QueryResultRow } from "pg";
@@ -8,6 +10,7 @@ import { reportMigrationCommandError } from "../src/cli/migration-command-errors
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
discoverCoreMigrations,
type MigrationApproval,
type MigrationApprovalProvider,
@@ -764,7 +767,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
database: migrationDatabase,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
expect(await databaseState(database.client)).toEqual(before);
const afterData = await safeQuery<QueryResultRow>(
database.client,
@@ -834,7 +837,48 @@ describe.sequential("live PostgreSQL module migration execution", () => {
});
});
it("rejects an unknown applied identity without repair or further execution", async () => {
it("retains an absent module's history and still applies later work (ADR 0048)", async () => {
await withIsolatedDatabase("absent_history", async (database) => {
const initial = peopleInitial();
const later = peopleLater();
const initialInventory = aggregate(moduleFixture("people", [initial]));
const completeInventory = aggregate(moduleFixture("people", [initial, later]));
const migrationDatabase = createPostgresMigrationDatabase(database.url);
await applyModuleMigrations({ aggregate: initialInventory, database: migrationDatabase });
// History left by a module that is no longer installed: kept, reported, never fatal.
await safeQuery(
database.client,
`
INSERT INTO public.ekklesia_core_migrations
(identity, module_id, migration_tag, migration_index, checksum)
VALUES ($1, $2, $3, $4, $5)
`,
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
);
const plan = await planModuleMigrations({
aggregate: completeInventory,
database: migrationDatabase,
});
expect(plan.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
expect(plan.pendingCount).toBe(1);
const result = await applyModuleMigrations({
aggregate: completeInventory,
database: migrationDatabase,
});
expect(result.applied.map((migration) => migration.identity)).toEqual([later.identity]);
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
expect(await columnExists(database.client, "people_records", "email")).toBe(true);
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
initial.identity,
"ghost:0000_unknown",
later.identity,
]);
});
});
it("rejects an unknown applied identity of an installed module without repair or further execution", async () => {
await withIsolatedDatabase("unknown_history", async (database) => {
const initial = peopleInitial();
const later = peopleLater();
@@ -849,7 +893,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
(identity, module_id, migration_tag, migration_index, checksum)
VALUES ($1, $2, $3, $4, $5)
`,
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
["people:0099_unknown", "people", "0099_unknown", 99, "f".repeat(64)],
);
const corrupted = await databaseState(database.client);
@@ -866,7 +910,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
expect(await columnExists(database.client, "people_records", "email")).toBe(false);
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
initial.identity,
"ghost:0000_unknown",
"people:0099_unknown",
]);
});
});
@@ -1358,7 +1402,7 @@ describe.sequential("live PostgreSQL Core identity migration execution", () => {
database: migrationDatabase,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
expect(await databaseState(database.client)).toEqual(before);
});
});
@@ -1851,3 +1895,151 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
});
});
});
/**
* ADR 0048 end to end, with real discovery: Core's committed migrations, the module template
* installed in a modules directory outside the repository, and a real PostgreSQL. Core's newest
* migration is withheld from a copy of `core/drizzle` at first, so a genuinely later Core migration
* can be applied while the module is absent.
*/
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const templateModule = path.join(repositoryRoot, "templates", "module");
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
async function coreWithoutNewestMigration(directory: string): Promise<string> {
const target = path.join(directory, "core-drizzle");
await cp(coreMigrationsDirectory, target, { recursive: true });
const journalPath = path.join(target, "meta", "_journal.json");
const policyPath = path.join(target, "meta", "_ekklesia.json");
const journal = JSON.parse(await readFile(journalPath, "utf8")) as {
entries: { idx: number; tag: string }[];
};
const policy = JSON.parse(await readFile(policyPath, "utf8")) as { entries: unknown[] };
const newest = journal.entries.pop();
policy.entries.pop();
if (!newest) {
throw harnessError("Core has no migration to withhold.");
}
await writeFile(journalPath, `${JSON.stringify(journal, null, 2)}\n`);
await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`);
await rm(path.join(target, `${newest.tag}.sql`));
await rm(path.join(target, "meta", `${String(newest.idx).padStart(4, "0")}_snapshot.json`));
return target;
}
async function installExample(modulesDirectory: string): Promise<void> {
for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) {
await cp(path.join(templateModule, entry), path.join(modulesDirectory, "example", entry), {
recursive: true,
});
}
}
async function inventory(coreDirectory: string, modulesDirectory: string) {
return discoverAllMigrations({
coreMigrationsDirectory: coreDirectory,
moduleDiscovery: { ...versions, modulesDirectory, modulesDirectoryRequired: true },
});
}
async function exampleValues(client: Client): Promise<readonly string[]> {
const result = await safeQuery<{ readonly value: string } & QueryResultRow>(
client,
"SELECT value FROM public.example_records ORDER BY value",
);
return result.rows.map((row) => row.value);
}
it("keeps an uninstalled module's data and history, applies later Core work, and restores it", async () => {
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-retained-history-"));
try {
const earlyCore = await coreWithoutNewestMigration(scratch);
const modulesDirectory = path.join(scratch, "modules");
await mkdir(modulesDirectory);
await installExample(modulesDirectory);
await withIsolatedDatabase("retained_module", async (database) => {
const migrationDatabase = createPostgresMigrationDatabase(database.url);
// 1. Core (minus its newest migration) and the installed module, applied together.
const first = await applyModuleMigrations({
aggregate: await inventory(earlyCore, modulesDirectory),
database: migrationDatabase,
});
expect(first.applied.map((migration) => migration.identity)).toContain(
"example:0000_initial",
);
expect(first.retained).toEqual([]);
// 2. Module-owned data.
await safeQuery(
database.client,
"INSERT INTO public.example_records (id, value) VALUES (gen_random_uuid(), 'kept')",
);
// 3. The module is uninstalled: its directory is gone from module storage.
await rm(path.join(modulesDirectory, "example"), { recursive: true });
// 4. A later Core migration plans and applies while the module is absent.
const absentInventory = await inventory(coreMigrationsDirectory, modulesDirectory);
const absentPlan = await planModuleMigrations({
aggregate: absentInventory,
database: migrationDatabase,
});
expect(absentPlan.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
expect(
absentPlan.entries.filter((entry) => entry.status === "pending").map((e) => e.moduleId),
).toEqual(["core"]);
const later = await applyModuleMigrations({
aggregate: absentInventory,
database: migrationDatabase,
});
expect(later.applied).toHaveLength(1);
expect(later.applied[0]?.moduleId).toBe("core");
expect(later.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
// 5. The module's table and rows are untouched, and so is its history.
expect(await exampleValues(database.client)).toEqual(["kept"]);
expect((await readHistory(database.client)).map((row) => row.identity)).toContain(
"example:0000_initial",
);
// 6. The module is reinstalled.
await installExample(modulesDirectory);
const restored = await inventory(coreMigrationsDirectory, modulesDirectory);
// 7. Its history is strict again, and valid; its data is where it left it.
const restoredPlan = await planModuleMigrations({
aggregate: restored,
database: migrationDatabase,
});
expect(restoredPlan.retained).toEqual([]);
expect(restoredPlan.pendingCount).toBe(0);
expect(
restoredPlan.entries.find((entry) => entry.identity === "example:0000_initial")?.status,
).toBe("applied");
const noOp = await applyModuleMigrations({
aggregate: restored,
database: migrationDatabase,
});
expect(noOp.noOp).toBe(true);
expect(await exampleValues(database.client)).toEqual(["kept"]);
// A returning package whose applied migration changed is refused, exactly as before.
const sqlPath = path.join(modulesDirectory, "example", "drizzle", "0000_initial.sql");
await writeFile(sqlPath, `${await readFile(sqlPath, "utf8")}-- edited\n`);
await captureRunnerError(
planModuleMigrations({
aggregate: await inventory(coreMigrationsDirectory, modulesDirectory),
database: migrationDatabase,
}),
"MIGRATION_CHECKSUM_MISMATCH",
);
expect(await exampleValues(database.client)).toEqual(["kept"]);
});
} finally {
await rm(scratch, { recursive: true, force: true });
}
});
});