feat: deliver module database ABI by manifest
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
This commit is contained in:
@@ -91,7 +91,7 @@ async function createModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
: {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -83,7 +83,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
||||
import {
|
||||
@@ -32,7 +32,11 @@ const checksumA = "a".repeat(64);
|
||||
const checksumB = "b".repeat(64);
|
||||
const checksumC = "c".repeat(64);
|
||||
|
||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
function discoveredModule(
|
||||
id: string,
|
||||
version = "0.1.0",
|
||||
database?: { readonly abi: 1 },
|
||||
): DiscoveredModule {
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/unpublished/${id}`,
|
||||
@@ -41,13 +45,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
configuration: [],
|
||||
...(database ? { database } : {}),
|
||||
dependencies: {
|
||||
capabilities: { optional: {}, required: {} },
|
||||
modules: { optional: {}, required: {} },
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
@@ -294,7 +299,7 @@ describe("Ekklesia Doctor outbound email reporting", () => {
|
||||
describe("Ekklesia Doctor diagnostics", () => {
|
||||
it("publishes a healthy deterministic static report without database configuration", async () => {
|
||||
const people = discoveredModule("people");
|
||||
const calendar = discoveredModule("calendar", "0.2.0");
|
||||
const calendar = discoveredModule("calendar", "0.2.0", { abi: 1 });
|
||||
const inventory = migrationAggregate(
|
||||
[migration("people", "0000_initial", checksumA)],
|
||||
[people, calendar],
|
||||
@@ -327,6 +332,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
]);
|
||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
||||
expect(check(report, "schemas.discovery")).toMatchObject({ status: "pass" });
|
||||
expect(check(report, "modules.permissions")).toMatchObject({
|
||||
details: ["0 permissions registered"],
|
||||
status: "pass",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 7,
|
||||
"manifestVersion": 8,
|
||||
"id": "db-facade",
|
||||
"name": "Database Facade Integration Test Module",
|
||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||
|
||||
@@ -17,7 +17,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -194,7 +194,7 @@ afterAll(async () => {
|
||||
|
||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -21,7 +21,7 @@ function manifestWith(
|
||||
id = "announcements",
|
||||
) {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
|
||||
],
|
||||
])("rejects %s", (_label, declaration) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
|
||||
"valueOf",
|
||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
|
||||
"",
|
||||
])("rejects the malformed configuration key %s", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(id: string): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -200,7 +200,7 @@ afterAll(async () => {
|
||||
* by eligibility resolution. */
|
||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -22,7 +22,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -344,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -28,7 +28,7 @@ interface PermissionInput {
|
||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -31,7 +31,7 @@ function manifest(
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -2,20 +2,23 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
AnyModuleRuntimeServices,
|
||||
ModuleConfiguration,
|
||||
ModuleDatabase,
|
||||
ModuleDatabaseExecutor,
|
||||
ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { discoverModules } from "../src/modules/discovery.js";
|
||||
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
||||
import {
|
||||
ModuleDiscoveryError,
|
||||
ModuleRuntimeError,
|
||||
type ModuleRuntimeIssueCode,
|
||||
type ModuleRuntimeServicesLookup,
|
||||
startDiscoveredModuleRuntime,
|
||||
startModuleRuntime,
|
||||
} from "../src/modules/index.js";
|
||||
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
||||
|
||||
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
|
||||
@@ -52,12 +55,13 @@ function manifestFor(
|
||||
id: string,
|
||||
options: {
|
||||
capabilities?: Array<{ id: string; version: string }>;
|
||||
database?: { abi: 1 };
|
||||
dependencies?: Record<string, unknown>;
|
||||
permissions?: Array<{ id: string; baseline: string; description: string }>;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -67,6 +71,7 @@ function manifestFor(
|
||||
},
|
||||
capabilities: options.capabilities ?? [],
|
||||
permissions: options.permissions ?? [],
|
||||
...(options.database ? { database: options.database } : {}),
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
@@ -145,13 +150,29 @@ function fakeModuleDatabase(): ModuleDatabase {
|
||||
}) as unknown as ModuleDatabase;
|
||||
}
|
||||
|
||||
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: fakeModuleDatabase() });
|
||||
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
|
||||
const services = new Map<string, ModuleRuntimeServices>();
|
||||
const database = fakeModuleDatabase();
|
||||
return new Proxy(services, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "get") {
|
||||
return (moduleId: string) => {
|
||||
let value = target.get(moduleId);
|
||||
if (!value) {
|
||||
value = Object.freeze({ database });
|
||||
target.set(moduleId, value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function runtimeOptions(
|
||||
modulesDirectory: string,
|
||||
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
|
||||
services: ModuleRuntimeServicesLookup = fakeModuleRuntimeServices(),
|
||||
) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
@@ -1067,6 +1088,54 @@ describe("already-discovered module runtime", () => {
|
||||
});
|
||||
|
||||
describe("module runtime services", () => {
|
||||
it("delivers only the manifest-selected database mode and distinct ABI executors", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createReferenceRecorder();
|
||||
const legacyDatabase = fakeModuleDatabase();
|
||||
const alphaExecutor = Object.freeze({
|
||||
abiVersion: 1,
|
||||
query: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}) as unknown as ModuleDatabaseExecutor;
|
||||
const betaExecutor = Object.freeze({
|
||||
abiVersion: 1,
|
||||
query: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}) as unknown as ModuleDatabaseExecutor;
|
||||
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>([
|
||||
["legacy", Object.freeze({ database: legacyDatabase })],
|
||||
["alpha", Object.freeze({ database: alphaExecutor })],
|
||||
["beta", Object.freeze({ database: betaExecutor })],
|
||||
]);
|
||||
await createRuntimeModule(modulesDirectory, "legacy", {
|
||||
serverSource: `export default { id: "legacy", version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
|
||||
});
|
||||
for (const id of ["alpha", "beta"]) {
|
||||
await createRuntimeModule(modulesDirectory, id, {
|
||||
manifest: manifestFor(id, { database: { abi: 1 } }),
|
||||
serverSource: `export default { database: { abi: 1 }, id: ${JSON.stringify(id)}, version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
|
||||
});
|
||||
}
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
|
||||
expect(recorder.values).toEqual([alphaExecutor, betaExecutor, legacyDatabase]);
|
||||
expect("select" in alphaExecutor).toBe(false);
|
||||
expect(alphaExecutor).not.toBe(betaExecutor);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("rejects a runtime database declaration that disagrees with its manifest", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createRuntimeModule(modulesDirectory, "mismatch", {
|
||||
manifest: manifestFor("mismatch", { database: { abi: 1 } }),
|
||||
serverSource: `export default { id: "mismatch", version: "0.1.0" };`,
|
||||
});
|
||||
|
||||
const error = await captureRuntimeError(modulesDirectory);
|
||||
expect(runtimeIssueCodes(error)).toContain("MODULE_RUNTIME_DATABASE_MISMATCH");
|
||||
});
|
||||
|
||||
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
@@ -1086,11 +1155,11 @@ describe("module runtime services", () => {
|
||||
|
||||
expect(registerServices.values).toEqual([undefined]);
|
||||
expect(lifecycleServices.values).toHaveLength(2);
|
||||
expect(lifecycleServices.values[0]).toBe(services);
|
||||
expect(lifecycleServices.values[1]).toBe(services);
|
||||
expect(lifecycleServices.values[0]).toBe(services.get("wired"));
|
||||
expect(lifecycleServices.values[1]).toBe(services.get("wired"));
|
||||
});
|
||||
|
||||
it("shares the exact same services object identity across every module's start hook", async () => {
|
||||
it("selects a stable services object per module rather than sharing one across modules", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
@@ -1112,7 +1181,8 @@ describe("module runtime services", () => {
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
expect(recorder.values).toEqual([services.get("alpha"), services.get("beta")]);
|
||||
expect(recorder.values[0]).not.toBe(recorder.values[1]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
@@ -1141,23 +1211,26 @@ describe("module runtime services", () => {
|
||||
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
|
||||
).rejects.toBeInstanceOf(ModuleRuntimeError);
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
expect(recorder.values).toEqual([services.get("started"), services.get("started")]);
|
||||
});
|
||||
|
||||
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const database = fakeModuleDatabase();
|
||||
const services: ModuleRuntimeServicesLookup = new Map([
|
||||
["unused", Object.freeze({ database })],
|
||||
]);
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
await runtime.stop();
|
||||
|
||||
expect(runtime.modules).toEqual([]);
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.transaction)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1463,7 +1536,7 @@ describe("module configuration delivery (ADR 0029)", () => {
|
||||
expect(recorder.values).toEqual([["object", []]]);
|
||||
});
|
||||
|
||||
it("keeps the services object one shared identity even though configuration is per-module", async () => {
|
||||
it("keeps each module's services object stable while configuration remains per-module", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
@@ -1492,11 +1565,11 @@ describe("module configuration delivery (ADR 0029)", () => {
|
||||
await runtime.stop();
|
||||
|
||||
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
|
||||
// This is the reason configuration is a sibling of `services` rather than a field inside it:
|
||||
// `services` is deliberately one shared object for the whole runtime, while configuration must
|
||||
// differ per module. Both properties hold simultaneously.
|
||||
expect(alphaServices).toBe(services);
|
||||
expect(bravoServices).toBe(services);
|
||||
// Configuration remains a sibling of `services`: both are selected per module, but they are
|
||||
// independently composed snapshots with separate contracts and lifetimes.
|
||||
expect(alphaServices).toBe(services.get("alpha"));
|
||||
expect(bravoServices).toBe(services.get("bravo"));
|
||||
expect(alphaServices).not.toBe(bravoServices);
|
||||
expect(alphaConfig).not.toBe(bravoConfig);
|
||||
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
|
||||
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
|
||||
|
||||
@@ -38,10 +38,14 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(
|
||||
id: string,
|
||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||
options: {
|
||||
database?: { abi: 1 };
|
||||
dependencies?: Record<string, unknown>;
|
||||
schema?: string | false;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -49,6 +53,7 @@ function manifestFor(
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
capabilities: [],
|
||||
permissions: [],
|
||||
...(options.database ? { database: options.database } : {}),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }),
|
||||
@@ -126,6 +131,62 @@ function issueCodes(error: ModuleSchemaDiscoveryError): ModuleSchemaDiscoveryIss
|
||||
}
|
||||
|
||||
describe("module schema discovery", () => {
|
||||
it("loads ABI-v1 schema entrypoints as plain metadata without importing an ORM", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createSchemaModule(modulesDirectory, "calendar", {
|
||||
manifest: manifestFor("calendar", { database: { abi: 1 } }),
|
||||
schemaSource: `
|
||||
export default {
|
||||
moduleId: "calendar",
|
||||
tables: [
|
||||
{ name: "calendar_events", foreignKeys: [] },
|
||||
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] }
|
||||
]
|
||||
};
|
||||
`,
|
||||
});
|
||||
|
||||
const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory));
|
||||
|
||||
expect(aggregate.tables.map((table) => table.name)).toEqual([
|
||||
"calendar_attendees",
|
||||
"calendar_events",
|
||||
]);
|
||||
expect(aggregate.tables.every((table) => table.table === undefined)).toBe(true);
|
||||
expect(aggregate.drizzleSchema).toEqual({});
|
||||
});
|
||||
|
||||
it("aggregates ABI-v1 prefix, duplicate, cross-module FK, missing target, and malformed errors", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createSchemaModule(modulesDirectory, "alpha", {
|
||||
manifest: manifestFor("alpha", { database: { abi: 1 } }),
|
||||
schemaSource: `export default {
|
||||
moduleId: "alpha",
|
||||
tables: [
|
||||
{ name: "wrong_records", foreignKeys: [] },
|
||||
{ name: "alpha_records", foreignKeys: [{ table: "beta_records" }] },
|
||||
{ name: "alpha_records", foreignKeys: [{ table: "alpha_missing" }] }
|
||||
]
|
||||
};`,
|
||||
});
|
||||
await createSchemaModule(modulesDirectory, "malformed", {
|
||||
manifest: manifestFor("malformed", { database: { abi: 1 } }),
|
||||
schemaSource: `export default { moduleId: "malformed", tables: [{ name: "malformed_rows", unknown: true }] };`,
|
||||
});
|
||||
|
||||
const error = await captureSchemaError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
|
||||
"MODULE_SCHEMA_EXPORT_INVALID",
|
||||
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
|
||||
"MODULE_SCHEMA_TABLE_DUPLICATE",
|
||||
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a frozen empty aggregate without importing module servers", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createGlobalEventRecorder();
|
||||
|
||||
@@ -18,7 +18,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
version: contribution.version,
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
permissions:
|
||||
fixture.display === undefined
|
||||
|
||||
@@ -50,7 +50,7 @@ export function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
Reference in New Issue
Block a user