diff --git a/README.md b/README.md index 9690392..db3b17a 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,12 @@ surfaces. ## Current status -Phase 2 is underway. The repository now includes public module contracts, independently versioned +Phase 3 is underway. The repository now includes public module contracts, independently versioned capabilities, static module manifests, deterministic automatic discovery, compiled server loading, -a staged capability registry, dependency-ordered lifecycle with rollback, and a cloneable -backend-only module template. Drizzle, identity, HTTP routing, jobs, physical storage provisioning, -surfaces, and feature modules remain later vertical slices. +a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table +contracts, deterministic schema aggregation, and a cloneable backend-only module template. Migration +execution, identity, HTTP routing, jobs, physical storage provisioning, surfaces, and feature modules +remain later vertical slices. ## Quick start diff --git a/core/package.json b/core/package.json index 1411592..e99d991 100644 --- a/core/package.json +++ b/core/package.json @@ -23,7 +23,8 @@ "typecheck": "tsc --noEmit --project tsconfig.json" }, "dependencies": { - "@ekklesia/contracts": "workspace:*" + "@ekklesia/contracts": "workspace:*", + "drizzle-orm": "0.45.2" }, "devDependencies": { "@biomejs/biome": "2.5.3", diff --git a/core/src/cli/validate-modules.ts b/core/src/cli/validate-modules.ts index 4a444d8..5e7b7ba 100644 --- a/core/src/cli/validate-modules.ts +++ b/core/src/cli/validate-modules.ts @@ -1,6 +1,10 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { discoverModules, ModuleDiscoveryError } from "../modules/index.js"; +import { + discoverModuleSchemas, + ModuleDiscoveryError, + ModuleSchemaDiscoveryError, +} from "../modules/index.js"; interface PackageVersion { version?: unknown; @@ -20,18 +24,21 @@ async function main(): Promise { readPackageVersion(path.join(repositoryRoot, "package.json")), readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")), ]); - const modules = await discoverModules({ + const schemaAggregate = await discoverModuleSchemas({ moduleSdkVersion, modulesDirectory: path.join(repositoryRoot, "modules"), platformVersion, }); + const modules = schemaAggregate.discoveredModules; if (modules.length === 0) { console.log("Module validation passed (0 modules installed)."); return; } - console.log(`Module validation passed (${modules.length} modules):`); + console.log( + `Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`, + ); for (const discoveredModule of modules) { console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`); } @@ -44,6 +51,12 @@ main().catch((error: unknown) => { const issuePath = issue.path ? ` (${issue.path})` : ""; console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`); } + } else if (error instanceof ModuleSchemaDiscoveryError) { + console.error(error.message); + for (const issue of error.issues) { + const issuePath = issue.path ? ` (${issue.path})` : ""; + console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`); + } } else { console.error(error instanceof Error ? error.message : String(error)); } diff --git a/core/src/modules/discovery.ts b/core/src/modules/discovery.ts index 3b72b3f..ca497ed 100644 --- a/core/src/modules/discovery.ts +++ b/core/src/modules/discovery.ts @@ -238,15 +238,15 @@ async function resolveEntrypoints( continue; } if ( - entrypointName === "server" && + ["schema", "server"].includes(entrypointName) && ![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase()) ) { issues.push({ code: "MODULE_ENTRYPOINT_INVALID", - message: "server must reference compiled ESM JavaScript with a .js or .mjs extension.", + message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`, moduleDirectory: directoryName, moduleId: manifest.id, - path: "entrypoints.server", + path: `entrypoints.${entrypointName}`, }); continue; } diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index aa15594..0fc5e5f 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -1,2 +1,3 @@ export * from "./discovery.js"; export * from "./runtime.js"; +export * from "./schema-discovery.js"; diff --git a/core/src/modules/schema-discovery.ts b/core/src/modules/schema-discovery.ts new file mode 100644 index 0000000..dc21c18 --- /dev/null +++ b/core/src/modules/schema-discovery.ts @@ -0,0 +1,480 @@ +import { pathToFileURL } from "node:url"; +import { + type ModuleId, + type ModuleSchemaDefinition, + moduleTableLocalNameSchema, + moduleTablePrefix, + parseModuleSchemaDefinition, + physicalModuleTableNameSchema, +} from "@ekklesia/contracts"; +import { is } from "drizzle-orm"; +import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core"; +import { + type DiscoveredModule, + discoverModules, + type ModuleDiscoveryOptions, +} from "./discovery.js"; + +export type ModuleSchemaDiscoveryIssueCode = + | "MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY" + | "MODULE_SCHEMA_EXPORT_INVALID" + | "MODULE_SCHEMA_FOREIGN_KEY_INVALID" + | "MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING" + | "MODULE_SCHEMA_IMPORT_FAILED" + | "MODULE_SCHEMA_MODULE_ID_MISMATCH" + | "MODULE_SCHEMA_POSTGRES_SCHEMA_UNSUPPORTED" + | "MODULE_SCHEMA_TABLE_DUPLICATE" + | "MODULE_SCHEMA_TABLE_INVALID" + | "MODULE_SCHEMA_TABLE_NAME_INVALID" + | "MODULE_SCHEMA_TABLE_NAMESPACE_INVALID"; + +export interface ModuleSchemaDiscoveryIssue { + readonly code: ModuleSchemaDiscoveryIssueCode; + readonly message: string; + readonly moduleId: ModuleId; + readonly path?: string; + readonly tableName?: string; +} + +export interface DiscoveredModuleTable { + readonly exportName: string; + readonly moduleId: ModuleId; + readonly name: string; + readonly table: AnyPgTable; +} + +export interface DiscoveredModuleSchema { + readonly moduleId: ModuleId; + readonly tables: readonly DiscoveredModuleTable[]; +} + +export interface ModuleSchemaAggregate { + readonly discoveredModules: readonly DiscoveredModule[]; + readonly drizzleSchema: Readonly>; + readonly modules: readonly DiscoveredModuleSchema[]; + readonly tables: readonly DiscoveredModuleTable[]; +} + +interface LoadedModuleSchema { + readonly definition: ModuleSchemaDefinition; + readonly discovered: DiscoveredModule; +} + +interface InspectedModuleSchema { + readonly discovered: DiscoveredModule; + readonly tables: ModuleTableCandidate[]; +} + +interface ModuleTableCandidate extends DiscoveredModuleTable { + readonly foreignKeys: ReturnType["foreignKeys"]; + readonly path: string; +} + +const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias"); + +export class ModuleSchemaDiscoveryError extends Error { + readonly issues: readonly ModuleSchemaDiscoveryIssue[]; + + constructor(issues: readonly ModuleSchemaDiscoveryIssue[]) { + const sortedIssues = [...issues].sort(compareIssues); + super(`Module schema discovery failed with ${sortedIssues.length} issue(s).`); + this.name = "ModuleSchemaDiscoveryError"; + this.stack = `${this.name}: ${this.message}`; + this.issues = Object.freeze(sortedIssues.map((issue) => Object.freeze({ ...issue }))); + } +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function compareIssues( + left: ModuleSchemaDiscoveryIssue, + right: ModuleSchemaDiscoveryIssue, +): number { + return ( + compareText(left.moduleId, right.moduleId) || + compareText(left.tableName ?? "", right.tableName ?? "") || + compareText(left.path ?? "", right.path ?? "") || + compareText(left.code, right.code) || + compareText(left.message, right.message) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isUnaliasedPgTable(value: unknown): value is AnyPgTable { + try { + return ( + is(value, PgTable) && + (value as unknown as Record)[drizzleTableIsAlias] === false + ); + } catch { + return false; + } +} + +function defaultExport(moduleNamespace: unknown): unknown { + return isRecord(moduleNamespace) ? moduleNamespace.default : undefined; +} + +function issue( + code: ModuleSchemaDiscoveryIssueCode, + moduleId: ModuleId, + message: string, + details: Pick = {}, +): ModuleSchemaDiscoveryIssue { + return { code, message, moduleId, ...details }; +} + +async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModule[]): Promise<{ + readonly issues: readonly ModuleSchemaDiscoveryIssue[]; + readonly loaded: readonly LoadedModuleSchema[]; +}> { + const issues: ModuleSchemaDiscoveryIssue[] = []; + const loaded: LoadedModuleSchema[] = []; + + for (const discovered of discoveredModules) { + const schemaEntrypoint = discovered.entrypoints.schema; + if (!schemaEntrypoint) { + continue; + } + + let moduleNamespace: unknown; + try { + moduleNamespace = (await import(pathToFileURL(schemaEntrypoint).href)) as unknown; + } catch { + issues.push( + issue( + "MODULE_SCHEMA_IMPORT_FAILED", + discovered.manifest.id, + `Module ${discovered.manifest.id} schema entrypoint could not be imported.`, + ), + ); + continue; + } + + let definition: ModuleSchemaDefinition; + try { + definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace)); + } catch { + issues.push( + issue( + "MODULE_SCHEMA_EXPORT_INVALID", + discovered.manifest.id, + `Module ${discovered.manifest.id} schema entrypoint has an invalid default export.`, + ), + ); + continue; + } + + if (definition.moduleId !== discovered.manifest.id) { + issues.push( + issue( + "MODULE_SCHEMA_MODULE_ID_MISMATCH", + discovered.manifest.id, + `Module ${discovered.manifest.id} schema definition does not match its manifest ID.`, + { path: "moduleId" }, + ), + ); + continue; + } + + loaded.push({ definition, discovered }); + } + + return { issues, loaded }; +} + +function inspectTables( + loadedSchemas: readonly LoadedModuleSchema[], + issues: ModuleSchemaDiscoveryIssue[], +): readonly InspectedModuleSchema[] { + const inspectedModules: InspectedModuleSchema[] = []; + const firstTableByName = new Map(); + + for (const loaded of loadedSchemas) { + const moduleId = loaded.discovered.manifest.id; + const seenTables = new Set(); + const candidates: ModuleTableCandidate[] = []; + const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) => + compareText(left, right), + ); + + entries.forEach(([exportName, table], index) => { + const path = `tables.${index}`; + if (!isUnaliasedPgTable(table)) { + issues.push( + issue( + "MODULE_SCHEMA_TABLE_INVALID", + moduleId, + `Module ${moduleId} schema contains a non-PostgreSQL table.`, + { path }, + ), + ); + return; + } + if (seenTables.has(table)) { + issues.push( + issue( + "MODULE_SCHEMA_TABLE_DUPLICATE", + moduleId, + `Module ${moduleId} schema exports the same table more than once.`, + { path }, + ), + ); + return; + } + seenTables.add(table); + + let config: ReturnType; + try { + config = getTableConfig(table); + } catch { + issues.push( + issue( + "MODULE_SCHEMA_TABLE_INVALID", + moduleId, + `Module ${moduleId} table metadata could not be inspected.`, + { path }, + ), + ); + return; + } + + if (config.schema !== undefined && config.schema !== "public") { + issues.push( + issue( + "MODULE_SCHEMA_POSTGRES_SCHEMA_UNSUPPORTED", + moduleId, + `Module ${moduleId} tables must use the platform-managed public PostgreSQL schema.`, + { path }, + ), + ); + return; + } + if (!physicalModuleTableNameSchema.safeParse(config.name).success) { + issues.push( + issue( + "MODULE_SCHEMA_TABLE_NAME_INVALID", + moduleId, + `Module ${moduleId} has an invalid PostgreSQL table name.`, + { path }, + ), + ); + return; + } + const tablePrefix = moduleTablePrefix(moduleId); + if (!config.name.startsWith(tablePrefix)) { + issues.push( + issue( + "MODULE_SCHEMA_TABLE_NAMESPACE_INVALID", + moduleId, + `Table ${config.name} is not namespaced for module ${moduleId}.`, + { path, tableName: config.name }, + ), + ); + return; + } + if (!moduleTableLocalNameSchema.safeParse(config.name.slice(tablePrefix.length)).success) { + issues.push( + issue( + "MODULE_SCHEMA_TABLE_NAME_INVALID", + moduleId, + `Module ${moduleId} has an invalid local table name.`, + { path }, + ), + ); + return; + } + + const candidate: ModuleTableCandidate = { + exportName, + foreignKeys: config.foreignKeys, + moduleId, + name: config.name, + path, + table, + }; + const existing = firstTableByName.get(config.name); + if (existing) { + issues.push( + issue( + "MODULE_SCHEMA_TABLE_DUPLICATE", + moduleId, + `Table ${config.name} is declared more than once.`, + { path, tableName: config.name }, + ), + ); + } else { + firstTableByName.set(config.name, candidate); + } + candidates.push(candidate); + }); + + inspectedModules.push({ discovered: loaded.discovered, tables: candidates }); + } + + return inspectedModules; +} + +function validForeignKeyTarget( + foreignKey: ModuleTableCandidate["foreignKeys"][number], + candidate: ModuleTableCandidate, +): AnyPgTable | undefined { + try { + const reference = foreignKey.reference(); + const foreignTable = reference.foreignTable; + if ( + foreignKey.table !== candidate.table || + !Array.isArray(reference.columns) || + !Array.isArray(reference.foreignColumns) || + reference.columns.length === 0 || + reference.columns.length !== reference.foreignColumns.length || + !isUnaliasedPgTable(foreignTable) || + !reference.columns.every( + (column) => is(column, PgColumn) && column.table === candidate.table, + ) || + !reference.foreignColumns.every( + (column) => is(column, PgColumn) && column.table === foreignTable, + ) + ) { + return undefined; + } + return foreignTable; + } catch { + return undefined; + } +} + +function inspectForeignKeys( + inspectedModules: readonly InspectedModuleSchema[], + issues: ModuleSchemaDiscoveryIssue[], +): void { + const ownerByTable = new Map(); + for (const inspected of inspectedModules) { + for (const candidate of inspected.tables) { + ownerByTable.set(candidate.table, candidate.moduleId); + } + } + + for (const inspected of inspectedModules) { + const moduleId = inspected.discovered.manifest.id; + const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table)); + for (const candidate of inspected.tables) { + candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => { + const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`; + const foreignTable = validForeignKeyTarget(foreignKey, candidate); + if (!foreignTable) { + issues.push( + issue( + "MODULE_SCHEMA_FOREIGN_KEY_INVALID", + moduleId, + `Table ${candidate.name} has a foreign key that could not be inspected.`, + { path, tableName: candidate.name }, + ), + ); + return; + } + if (ownedTables.has(foreignTable)) { + return; + } + + const knownOwner = ownerByTable.get(foreignTable); + if (knownOwner && knownOwner !== moduleId) { + issues.push( + issue( + "MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY", + moduleId, + `Table ${candidate.name} has a foreign key outside module ${moduleId}.`, + { path, tableName: candidate.name }, + ), + ); + return; + } + + try { + const targetName = getTableConfig(foreignTable).name; + if ( + physicalModuleTableNameSchema.safeParse(targetName).success && + !targetName.startsWith(moduleTablePrefix(moduleId)) + ) { + issues.push( + issue( + "MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY", + moduleId, + `Table ${candidate.name} has a foreign key outside module ${moduleId}.`, + { path, tableName: candidate.name }, + ), + ); + return; + } + } catch { + issues.push( + issue( + "MODULE_SCHEMA_FOREIGN_KEY_INVALID", + moduleId, + `Table ${candidate.name} has a foreign key that could not be inspected.`, + { path, tableName: candidate.name }, + ), + ); + return; + } + + issues.push( + issue( + "MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING", + moduleId, + `Table ${candidate.name} references a table not exported by module ${moduleId}.`, + { path, tableName: candidate.name }, + ), + ); + }); + } + } +} + +function publishAggregate( + discoveredModules: readonly DiscoveredModule[], + inspectedModules: readonly InspectedModuleSchema[], +): ModuleSchemaAggregate { + const modules = Object.freeze( + inspectedModules.map((inspected) => + Object.freeze({ + moduleId: inspected.discovered.manifest.id, + tables: Object.freeze( + inspected.tables + .map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table)) + .sort((left, right) => compareText(left.exportName, right.exportName)), + ), + }), + ), + ); + const tables = Object.freeze( + modules + .flatMap((module) => module.tables) + .sort((left, right) => compareText(left.name, right.name)), + ); + const drizzleSchema = Object.freeze( + Object.fromEntries(tables.map((table) => [table.name, table.table] as const)), + ); + + return Object.freeze({ discoveredModules, drizzleSchema, modules, tables }); +} + +export async function discoverModuleSchemas( + options: ModuleDiscoveryOptions, +): Promise { + const discoveredModules = await discoverModules(options); + const loaded = await loadSchemaDefinitions(discoveredModules); + const issues = [...loaded.issues]; + const inspectedModules = inspectTables(loaded.loaded, issues); + inspectForeignKeys(inspectedModules, issues); + + if (issues.length > 0) { + throw new ModuleSchemaDiscoveryError(issues); + } + return publishAggregate(discoveredModules, inspectedModules); +} diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index 4a8ac91..2db18e9 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -397,12 +397,17 @@ describe("module discovery", () => { await createModule(modulesDirectory, "missing-server", { manifest: { ...manifestFor("missing-server"), - entrypoints: { server: "./dist/server/missing.js" }, + entrypoints: { + server: "./dist/server/missing.js", + schema: "./dist/schema/missing.js", + }, }, }); let error = await discoveryError(modulesDirectory); - expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING"); + expect(error.issues.filter((issue) => issue.code === "MODULE_ENTRYPOINT_MISSING")).toHaveLength( + 2, + ); error = await discoveryError(modulesDirectory, { platformVersion: "invalid" }); expect(issueCodes(error)).toContain("DISCOVERY_CONFIGURATION_INVALID"); @@ -427,21 +432,100 @@ describe("module discovery", () => { ); }); + it.each([ + "./src/schema/index.ts", + "./dist/schema/index.cjs", + ])("rejects non-ESM schema entrypoint %s", async (schemaEntrypoint) => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "schema-source", { + manifest: { + ...manifestFor("schema-source"), + entrypoints: { + server: "./dist/server/index.js", + schema: schemaEntrypoint, + }, + }, + }); + + const error = await discoveryError(modulesDirectory); + expect(error.issues).toContainEqual( + expect.objectContaining({ + code: "MODULE_ENTRYPOINT_INVALID", + message: "schema must reference compiled ESM JavaScript with a .js or .mjs extension.", + path: "entrypoints.schema", + }), + ); + }); + + it("accepts an optional compiled ESM schema entrypoint", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "compiled-schema", { + manifest: { + ...manifestFor("compiled-schema"), + entrypoints: { + server: "./dist/server/index.js", + schema: "./dist/schema/index.mjs", + }, + }, + }); + const schemaDirectory = path.join(modulesDirectory, "compiled-schema", "dist/schema"); + await mkdir(schemaDirectory, { recursive: true }); + await writeFile(path.join(schemaDirectory, "index.mjs"), "export default {};\n"); + + const modules = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + expect(modules[0]?.entrypoints.schema).toBe(path.join(schemaDirectory, "index.mjs")); + }); + it("requires executable entrypoints to be files and migrations to be a directory", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "wrong-entrypoint-kind", { manifest: { ...manifestFor("wrong-entrypoint-kind"), entrypoints: { - server: "./dist/server", + server: "./dist/server.js", + schema: "./dist/schema.js", migrations: "./dist/server/index.js", }, }, }); + await Promise.all([ + mkdir(path.join(modulesDirectory, "wrong-entrypoint-kind", "dist/server.js")), + mkdir(path.join(modulesDirectory, "wrong-entrypoint-kind", "dist/schema.js")), + ]); const error = await discoveryError(modulesDirectory); expect(error.issues.filter((issue) => issue.code === "MODULE_ENTRYPOINT_INVALID")).toHaveLength( - 2, + 3, + ); + }); + + it("rejects schema entrypoint links that escape the module directory", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "linked-schema", { + manifest: { + ...manifestFor("linked-schema"), + entrypoints: { + server: "./dist/server/index.js", + schema: "./dist/schema/index.js", + }, + }, + }); + const schemaDirectory = path.join(modulesDirectory, "linked-schema", "dist/schema"); + const externalSchema = path.join(modulesDirectory, "external-schema.js"); + await mkdir(schemaDirectory, { recursive: true }); + await writeFile(externalSchema, "export default {};\n"); + await symlink("../../../external-schema.js", path.join(schemaDirectory, "index.js")); + + const error = await discoveryError(modulesDirectory); + expect(error.issues).toContainEqual( + expect.objectContaining({ + code: "MODULE_ENTRYPOINT_INVALID", + path: "entrypoints.schema", + }), ); }); diff --git a/core/tests/module-schema-discovery.test.ts b/core/tests/module-schema-discovery.test.ts new file mode 100644 index 0000000..e7374df --- /dev/null +++ b/core/tests/module-schema-discovery.test.ts @@ -0,0 +1,489 @@ +import { mkdir, 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 { + discoverModuleSchemas, + ModuleDiscoveryError, + ModuleSchemaDiscoveryError, + type ModuleSchemaDiscoveryIssueCode, +} from "../src/modules/index.js"; + +interface SchemaModuleFixture { + readonly manifest?: Record; + readonly packageJson?: Record; + readonly schemaSource?: string; + readonly serverSource?: string; +} + +const drizzlePgCoreUrl = import.meta.resolve("drizzle-orm/pg-core"); +const temporaryDirectories: string[] = []; +const eventSymbols: symbol[] = []; + +afterEach(async () => { + const globalValues = globalThis as unknown as Record; + for (const symbol of eventSymbols.splice(0)) { + delete globalValues[symbol]; + } + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function createModulesDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-schema-")); + temporaryDirectories.push(directory); + return directory; +} + +function manifestFor( + id: string, + options: { dependencies?: Record; schema?: string | false } = {}, +): Record { + return { + manifestVersion: 2, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + ...(options.dependencies ? { dependencies: options.dependencies } : {}), + capabilities: [], + permissions: [], + entrypoints: { + server: "./dist/server/index.js", + ...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }), + }, + }; +} + +async function createSchemaModule( + modulesDirectory: string, + id: string, + fixture: SchemaModuleFixture = {}, +): Promise { + const moduleDirectory = path.join(modulesDirectory, id); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + if (fixture.schemaSource !== undefined) { + await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true }); + } + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify( + fixture.packageJson ?? { + name: `@test/module-${id}`, + version: "0.1.0", + type: "module", + ekklesia: { layer: "module" }, + }, + ), + ), + writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify(fixture.manifest ?? manifestFor(id)), + ), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`, + ), + ...(fixture.schemaSource === undefined + ? [] + : [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]), + ]); +} + +function createGlobalEventRecorder(): { events: string[]; expression: string } { + const key = `ekklesia.schema.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`; + const symbol = Symbol.for(key); + const events: string[] = []; + (globalThis as unknown as Record)[symbol] = events; + eventSymbols.push(symbol); + return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` }; +} + +function discoveryOptions(modulesDirectory: string) { + return { + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }; +} + +async function captureSchemaError(modulesDirectory: string): Promise { + try { + await discoverModuleSchemas(discoveryOptions(modulesDirectory)); + } catch (error) { + if (error instanceof ModuleSchemaDiscoveryError) { + return error; + } + throw error; + } + throw new Error("Expected module schema discovery to fail."); +} + +function issueCodes(error: ModuleSchemaDiscoveryError): ModuleSchemaDiscoveryIssueCode[] { + return error.issues.map((issue) => issue.code); +} + +describe("module schema discovery", () => { + it("returns a frozen empty aggregate without importing module servers", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createGlobalEventRecorder(); + await createSchemaModule(modulesDirectory, "server-only", { + manifest: manifestFor("server-only", { schema: false }), + serverSource: `${recorder.expression}.push("server-imported"); export default { id: "server-only", version: "0.1.0" };`, + }); + + const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory)); + + expect(aggregate.discoveredModules).toHaveLength(1); + expect(aggregate.modules).toEqual([]); + expect(aggregate.tables).toEqual([]); + expect(aggregate.drizzleSchema).toEqual({}); + expect(recorder.events).toEqual([]); + expect(Object.isFrozen(aggregate)).toBe(true); + expect(Object.isFrozen(aggregate.tables)).toBe(true); + }); + + it("completes all static module discovery before importing any schema code", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createGlobalEventRecorder(); + await createSchemaModule(modulesDirectory, "valid", { + schemaSource: `${recorder.expression}.push("schema-imported"); export default {};`, + }); + await createSchemaModule(modulesDirectory, "missing-schema"); + + let discoveryError: ModuleDiscoveryError | undefined; + try { + await discoverModuleSchemas(discoveryOptions(modulesDirectory)); + } catch (error) { + if (error instanceof ModuleDiscoveryError) { + discoveryError = error; + } else { + throw error; + } + } + expect(discoveryError?.issues).toContainEqual( + expect.objectContaining({ + code: "MODULE_ENTRYPOINT_MISSING", + moduleId: "missing-schema", + path: "entrypoints.schema", + }), + ); + expect(recorder.events).toEqual([]); + }); + + it("loads and aggregates module tables deterministically", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createGlobalEventRecorder(); + await createSchemaModule(modulesDirectory, "calendar", { + manifest: manifestFor("calendar", { + dependencies: { modules: { required: { people: "^0.1.0" } } }, + }), + schemaSource: ` + import { integer, pgTable, text } from ${JSON.stringify(drizzlePgCoreUrl)}; + ${recorder.expression}.push("calendar:schema"); + const events = pgTable("calendar_events", { id: integer("id").primaryKey() }); + const locations = pgTable("calendar_locations", { + id: integer("id").primaryKey(), + eventId: integer("event_id").references(() => events.id) + }); + export default { moduleId: "calendar", tables: { locations, events } }; + `, + serverSource: 'throw new Error("server entrypoint must remain unloaded");', + }); + await createSchemaModule(modulesDirectory, "people", { + schemaSource: ` + import { integer, pgTable, text } from ${JSON.stringify(drizzlePgCoreUrl)}; + ${recorder.expression}.push("people:schema"); + const records = pgTable("people_records", { id: integer("id").primaryKey() }); + const notes = pgTable("people_notes", { + id: integer("id").primaryKey(), + recordId: integer("record_id").references(() => records.id), + body: text("body") + }); + export default { moduleId: "people", tables: { records, notes } }; + `, + }); + + const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory)); + + expect(recorder.events).toEqual(["people:schema", "calendar:schema"]); + expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["people", "calendar"]); + expect(aggregate.modules[0]?.tables.map((table) => table.exportName)).toEqual([ + "notes", + "records", + ]); + expect(aggregate.modules[1]?.tables.map((table) => table.exportName)).toEqual([ + "events", + "locations", + ]); + expect(aggregate.tables.map((table) => table.name)).toEqual([ + "calendar_events", + "calendar_locations", + "people_notes", + "people_records", + ]); + expect(Object.keys(aggregate.drizzleSchema)).toEqual( + aggregate.tables.map((table) => table.name), + ); + expect(Object.isFrozen(aggregate.modules)).toBe(true); + expect(Object.isFrozen(aggregate.drizzleSchema)).toBe(true); + expect(Object.isFrozen(aggregate.modules[0])).toBe(true); + expect(Object.isFrozen(aggregate.modules[0]?.tables)).toBe(true); + expect(Object.isFrozen(aggregate.tables[0])).toBe(true); + }); + + it("aggregates import, export, and ownership-marker failures without leaking details", async () => { + const modulesDirectory = await createModulesDirectory(); + await createSchemaModule(modulesDirectory, "alpha", { + schemaSource: 'throw new Error("database-password at /machine/private/path");', + }); + await createSchemaModule(modulesDirectory, "beta", { + schemaSource: 'export default { moduleId: "beta", tables: { rows: {} } };', + }); + await createSchemaModule(modulesDirectory, "gamma", { + schemaSource: ` + import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)}; + const rows = pgTable("gamma_rows", { id: integer("id") }); + export default { moduleId: "wrong", tables: { rows } }; + `, + }); + + const error = await captureSchemaError(modulesDirectory); + + expect(issueCodes(error)).toEqual([ + "MODULE_SCHEMA_IMPORT_FAILED", + "MODULE_SCHEMA_EXPORT_INVALID", + "MODULE_SCHEMA_MODULE_ID_MISMATCH", + ]); + const report = `${error.message}\n${error.stack}\n${JSON.stringify(error.issues)}`; + expect(report).not.toContain("database-password"); + expect(report).not.toContain("/machine/private/path"); + expect(report).not.toContain(modulesDirectory); + }); + + it("rejects malformed, unnamespaced, duplicate, and custom-schema tables", async () => { + const modulesDirectory = await createModulesDirectory(); + await createSchemaModule(modulesDirectory, "example", { + schemaSource: ` + import { integer, pgSchema, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)}; + const duplicateA = pgTable("example_records", { id: integer("id") }); + const duplicateB = pgTable("example_records", { id: integer("id") }); + const invalid = pgTable("secret at /machine/path", { id: integer("id") }); + const invalidLocal = pgTable("example_1records", { id: integer("id") }); + const other = pgTable("other_records", { id: integer("id") }); + const privateTable = pgSchema("private").table("example_private", { id: integer("id") }); + export default { + moduleId: "example", + tables: { + repeated: duplicateA, + privateTable, + other, + invalid, + invalidLocal, + duplicateB, + duplicateA + } + }; + `, + }); + + const error = await captureSchemaError(modulesDirectory); + + expect(issueCodes(error)).toEqual( + expect.arrayContaining([ + "MODULE_SCHEMA_POSTGRES_SCHEMA_UNSUPPORTED", + "MODULE_SCHEMA_TABLE_DUPLICATE", + "MODULE_SCHEMA_TABLE_NAME_INVALID", + "MODULE_SCHEMA_TABLE_NAMESPACE_INVALID", + ]), + ); + expect( + issueCodes(error).filter((code) => code === "MODULE_SCHEMA_TABLE_DUPLICATE"), + ).toHaveLength(2); + expect( + issueCodes(error).filter((code) => code === "MODULE_SCHEMA_TABLE_NAME_INVALID"), + ).toHaveLength(2); + expect(JSON.stringify(error.issues)).not.toContain("secret at /machine/path"); + }); + + it("rejects duplicate physical names across module namespaces", async () => { + const modulesDirectory = await createModulesDirectory(); + for (const [moduleId, exportName] of [ + ["foo", "barRows"], + ["foo-bar", "rows"], + ] as const) { + await createSchemaModule(modulesDirectory, moduleId, { + schemaSource: ` + import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)}; + const table = pgTable("foo_bar_rows", { id: integer("id") }); + export default { moduleId: ${JSON.stringify(moduleId)}, tables: { ${exportName}: table } }; + `, + }); + } + + const error = await captureSchemaError(modulesDirectory); + expect(error.issues).toEqual([ + expect.objectContaining({ + code: "MODULE_SCHEMA_TABLE_DUPLICATE", + moduleId: "foo-bar", + tableName: "foo_bar_rows", + }), + ]); + }); + + it("rejects cross-module and omitted local foreign-key targets", async () => { + const modulesDirectory = await createModulesDirectory(); + await createSchemaModule(modulesDirectory, "people", { + schemaSource: ` + import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)}; + export const records = pgTable("people_records", { id: integer("id").primaryKey() }); + export default { moduleId: "people", tables: { records } }; + `, + }); + await createSchemaModule(modulesDirectory, "calendar", { + manifest: manifestFor("calendar", { + dependencies: { modules: { required: { people: "^0.1.0" } } }, + }), + schemaSource: ` + import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)}; + import { records } from "../../../people/dist/schema/index.js"; + const privateTable = pgTable("calendar_private", { id: integer("id").primaryKey() }); + const events = pgTable("calendar_events", { + id: integer("id"), + personId: integer("person_id").references(() => records.id) + }); + const attendees = pgTable("calendar_attendees", { + id: integer("id"), + privateId: integer("private_id").references(() => privateTable.id) + }); + export default { moduleId: "calendar", tables: { events, attendees } }; + `, + }); + + const error = await captureSchemaError(modulesDirectory); + + expect(issueCodes(error)).toEqual([ + "MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING", + "MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY", + ]); + }); + + it("rejects compound foreign keys that mix target-table ownership", async () => { + const modulesDirectory = await createModulesDirectory(); + await createSchemaModule(modulesDirectory, "people", { + schemaSource: ` + import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)}; + export const records = pgTable("people_records", { + id: integer("id").notNull(), + alternateId: integer("alternate_id").notNull() + }); + export default { moduleId: "people", tables: { records } }; + `, + }); + await createSchemaModule(modulesDirectory, "calendar", { + manifest: manifestFor("calendar", { + dependencies: { modules: { required: { people: "^0.1.0" } } }, + }), + schemaSource: ` + import { foreignKey, integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)}; + import { records } from "../../../people/dist/schema/index.js"; + const targets = pgTable("calendar_targets", { + id: integer("id").notNull(), + alternateId: integer("alternate_id").notNull() + }); + const links = pgTable("calendar_links", { + targetId: integer("target_id").notNull(), + alternateId: integer("alternate_id").notNull() + }, (table) => [ + foreignKey({ + columns: [table.targetId, table.alternateId], + foreignColumns: [targets.id, records.alternateId] + }) + ]); + export default { moduleId: "calendar", tables: { links, targets } }; + `, + }); + + const error = await captureSchemaError(modulesDirectory); + expect(issueCodes(error)).toEqual(["MODULE_SCHEMA_FOREIGN_KEY_INVALID"]); + }); + + it("rejects table aliases and sanitizes hostile metadata proxies", async () => { + const modulesDirectory = await createModulesDirectory(); + await createSchemaModule(modulesDirectory, "alias-table", { + schemaSource: ` + import { alias, integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)}; + const base = pgTable("private_records", { id: integer("id") }); + const records = alias(base, "alias_table_records"); + export default { moduleId: "alias-table", tables: { records } }; + `, + }); + await createSchemaModule(modulesDirectory, "proxy-table", { + schemaSource: ` + import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)}; + const base = pgTable("proxy_table_records", { id: integer("id") }); + const aliasSymbol = Symbol.for("drizzle:IsAlias"); + let aliasReads = 0; + const records = new Proxy(base, { + get(target, key, receiver) { + if (key === aliasSymbol && ++aliasReads > 1) { + throw new Error("proxy-secret at /machine/private/schema"); + } + return Reflect.get(target, key, receiver); + } + }); + export default { moduleId: "proxy-table", tables: { records } }; + `, + }); + + const error = await captureSchemaError(modulesDirectory); + expect(issueCodes(error)).toEqual([ + "MODULE_SCHEMA_EXPORT_INVALID", + "MODULE_SCHEMA_TABLE_INVALID", + ]); + const report = `${error.stack}\n${JSON.stringify(error.issues)}`; + expect(report).not.toContain("proxy-secret"); + expect(report).not.toContain("/machine/private/schema"); + }); + + it("sanitizes errors thrown while resolving lazy foreign-key metadata", async () => { + const modulesDirectory = await createModulesDirectory(); + await createSchemaModule(modulesDirectory, "unsafe", { + schemaSource: ` + import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)}; + const rows = pgTable("unsafe_rows", { + id: integer("id"), + ownerId: integer("owner_id").references(() => { + throw new Error("foreign-key-secret at /machine/schema/path"); + }) + }); + export default { moduleId: "unsafe", tables: { rows } }; + `, + }); + + const error = await captureSchemaError(modulesDirectory); + expect(issueCodes(error)).toEqual(["MODULE_SCHEMA_FOREIGN_KEY_INVALID"]); + expect(JSON.stringify(error.issues)).not.toContain("foreign-key-secret"); + expect(JSON.stringify(error.issues)).not.toContain("/machine/schema/path"); + }); + + it("produces schema diagnostics independent of the absolute modules path", async () => { + const firstModulesDirectory = await createModulesDirectory(); + const secondModulesDirectory = await createModulesDirectory(); + for (const modulesDirectory of [firstModulesDirectory, secondModulesDirectory]) { + await createSchemaModule(modulesDirectory, "broken", { + schemaSource: 'throw new Error("secret at /machine/schema/path");', + }); + } + + const [firstError, secondError] = await Promise.all([ + captureSchemaError(firstModulesDirectory), + captureSchemaError(secondModulesDirectory), + ]); + + expect(firstError.issues).toEqual(secondError.issues); + expect(firstError.stack).toBe(secondError.stack); + }); +}); diff --git a/docs/architecture/decisions/0007-module-owned-drizzle-schemas.md b/docs/architecture/decisions/0007-module-owned-drizzle-schemas.md new file mode 100644 index 0000000..956480b --- /dev/null +++ b/docs/architecture/decisions/0007-module-owned-drizzle-schemas.md @@ -0,0 +1,88 @@ +# 0007: Module-owned Drizzle schemas and table isolation + +- Status: Accepted +- Date: 2026-07-13 + +## Context + +Ekklesia needs to collect database declarations from independently installed modules without +giving Core feature-specific knowledge or allowing modules to couple through private database +structures. Schema declarations must be inspectable before any future migration or database +startup work, and their physical names must remain globally collision-resistant in a shared +PostgreSQL deployment. + +The module manifest already has an optional `entrypoints.schema` field. Production module loading +already requires compiled ESM artifacts and complete static discovery before executable code is +imported. + +## Decision + +A module that owns tables declares a compiled ESM `.js` or `.mjs` schema entrypoint in +`ekklesia.module.json`, normally `./dist/schema/index.js`. The entrypoint default-exports a +`defineModuleSchema` definition containing only a module ID ownership marker and a table record. +The ID must match the already-discovered manifest. The schema entrypoint contains declarations +only, performs no I/O, opens no database connection, and has no top-level side effects beyond +constructing Drizzle metadata. + +All module tables use Drizzle's PostgreSQL dialect in the platform-managed default/public +PostgreSQL schema. Modules cannot select custom PostgreSQL schemas. A module owns only the table +objects exported by its own schema definition. Schema definitions contain physical base tables, +not query aliases that could disguise another table under an owned name. + +Physical table names use this mandatory namespace: + +```text +_ +``` + +For example, module `care-team` creates local table `members` as `care_team_members`. +Identifiers must be lowercase ASCII snake case and no longer than PostgreSQL's 63-character +identifier limit. `createModuleTable(moduleId)` applies this transformation for authors, while Core +validates the resulting physical names independently. Every table object and every physical table +name may appear only once in the aggregate. + +Foreign keys may target self-referential or other exported tables owned by the same module. Every +source column must belong to the declaring table, and every target column in a simple or compound +key must belong to one exported target table. A foreign key may not target a table owned by another +module, even when a module or capability dependency exists. A target with the same namespace that +is omitted from the owning module's schema definition is also invalid. + +Modules may not import another module's schema entrypoint, tables, columns, relations, or other +private code. Cross-module data relationships use opaque identifiers and public capabilities, +events, or APIs. The repository boundary checker enforces literal source/package imports, and Core +schema validation independently rejects cross-module foreign-key metadata. + +Core schema discovery first completes ordinary module discovery for the entire installed graph. If +that fails, no schema entrypoint is imported. It then imports optional schema entrypoints in +dependency-first module order, validates every definition and table, and publishes a frozen +aggregate only if all modules succeed. Module contributions and table records are explicitly +sorted, and failures are structured, deterministic, and stripped of caught exception messages, +stacks, URLs, causes, and machine-specific paths. + +Atomicity here means no schema code runs before complete static discovery and no partial aggregate +is published. ESM evaluation itself cannot be undone, which is why schema entrypoints must remain +side-effect-free. + +## Alternatives + +- Source TypeScript entrypoints were rejected because production schema discovery must consume the + same built artifacts that deployment uses. +- PostgreSQL schema-per-module isolation was rejected for this slice because table prefixes already + provide explicit ownership without adding schema search-path and migration-filtering complexity. +- Cross-module foreign keys were rejected because they couple module installation, upgrades, + deletion, and migration ordering through private storage details. +- Inferring tables from all named module exports was rejected because an explicit default + definition provides one reviewable ownership boundary and deterministic aggregation. +- A central table registry was rejected because cloning a module into `modules/` must remain enough + to install its declarations. + +## Consequences + +Module authors must use names short enough for the complete namespaced identifier to fit within 63 +characters. Modules with no tables omit `entrypoints.schema`. Custom PostgreSQL schemas, views, +enums, sequences, and richer relation metadata can receive explicit contracts later rather than +being accepted implicitly now. + +This decision discovers and validates Drizzle table metadata only. It does not create a database, +execute migrations, compare live schema state, or define upgrade, downgrade, uninstall, or +destructive-change policy. Those migration decisions remain a later Phase 3 slice. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 5a2160b..b18d55b 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -15,3 +15,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.md) — Accepted - [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Superseded - [0006: Versioned capabilities and compiled module runtime](0006-versioned-capabilities-and-compiled-module-runtime.md) — Accepted +- [0007: Module-owned Drizzle schemas and table isolation](0007-module-owned-drizzle-schemas.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index f55cfe3..aed06f5 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -62,8 +62,9 @@ Implemented now: the Phase 1 repository and infrastructure foundation plus publi static versioned manifests, independent capability versions and dependency ranges, automatic filesystem discovery, compiled server loading, runtime-definition verification, staged capability registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module -template. +template. Optional compiled schema entrypoints now contribute validated, namespaced module-owned +Drizzle tables to a deterministic aggregate without connecting to a database. -Target architecture, not yet implemented: Drizzle schema aggregation and migrations, Better Auth, +Target architecture, not yet implemented: migration generation or execution, Better Auth, permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource and action contracts, API server, dashboard, Builder, and reference feature modules. diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index 07ed8ff..ec53af3 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -32,7 +32,8 @@ These rules map platform ownership to filesystem and package boundaries. The exe - Workspace package names are unique and shared packages declare a recognized architectural role. - Circular source and workspace-package dependencies are rejected. - Cross-module data access uses services, events, capabilities, or opaque identifiers rather than - direct table imports and foreign keys. + direct table imports and foreign keys. Modules may not import another module's Drizzle tables, + columns, relations, schema entrypoint, or migration internals. The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard rejects every currently machine-checkable repository rule even before production packages fill @@ -40,8 +41,8 @@ every directory. The UI guard rejects component, view, template, stylesheet, sha common UI-framework dependencies inside modules. This deny list is defense in depth; package review remains required when adopting a new frontend technology. -Cross-module table ownership, foreign keys, and schema namespace rules require Drizzle metadata -and are intentionally deferred to the Phase 3 schema validator. +The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key +targets, and physical table namespaces in compiled Drizzle metadata. ## Generated code diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 11379e7..b884021 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -31,7 +31,9 @@ the root verification command discovers them automatically. `pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A missing or empty modules directory is valid and reports zero installed modules. Installed modules -must produce the compiled JavaScript entrypoints declared by their static manifests. +must produce the compiled JavaScript entrypoints declared by their static manifests. Optional +schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and +foreign-key isolation; this command does not connect to PostgreSQL or run migrations. CI runs `pnpm ci:verify`, which adds Compose configuration validation to `pnpm verify`. A separate CI job runs the live infrastructure smoke test so code-only local verification does not require diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 5ed5819..b981a06 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -10,12 +10,16 @@ strict semantic version, and the module layer marker: ```text modules/example/ +├── dist/schema/index.js # when the module owns tables ├── dist/server/index.js ├── ekklesia.module.json ├── package.json +├── src/schema/index.ts # when the module owns tables └── src/server/index.ts ``` +The schema paths are optional. A tableless module omits them and omits `entrypoints.schema`. + ```json { "type": "module", @@ -74,7 +78,8 @@ without executing module code: ] }, "entrypoints": { - "server": "./dist/server/index.js" + "server": "./dist/server/index.js", + "schema": "./dist/schema/index.js" } } ``` @@ -84,9 +89,10 @@ has its own exact semantic version because its public contract can evolve indepe module release. Capability dependencies use semantic-version ranges. Optional means the provider may be absent; if it is installed, its capability version must be compatible. -Server entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`, remain inside the -package, and exist. Production never imports source TypeScript directly. Unknown manifest -properties are rejected, including presentation fields such as components or renderers. +Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`, +remain inside the package, and exist. Production never imports source TypeScript directly. Modules +with no tables omit `entrypoints.schema`. Unknown manifest properties are rejected, including +presentation fields such as components or renderers. Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets. Core will later map them to deployment-specific physical S3 buckets and provide scoped access; @@ -127,6 +133,44 @@ Entrypoint imports must have no top-level side effects. Modules are trusted back evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and close them in `stop`. +## Drizzle schema definition + +A module with tables exports a declaration-only schema definition from `src/schema/index.ts`: + +```ts +import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk"; +import { integer, text } from "drizzle-orm/pg-core"; + +const moduleTable = createModuleTable("calendar"); + +export const events = moduleTable("events", { + id: integer("id").primaryKey(), + title: text("title").notNull() +}); + +export default defineModuleSchema({ + moduleId: "calendar", + tables: { events } +}); +``` + +`createModuleTable` converts the module ID to a snake-case namespace and prefixes every local table +name. Here the physical PostgreSQL name is `calendar_events`; `care-team` plus `members` becomes +`care_team_members`. Local names must be lowercase snake case, and the complete physical identifier +must not exceed 63 characters. Core validates the built metadata even when a module does not use +the helper. + +Schema definitions use PostgreSQL tables in the platform-managed default/public schema. They must +be side-effect-free: do not connect to a database, read configuration, or perform I/O during import. +The `moduleId` is an ownership marker and must match the static manifest. Export physical base +tables, never query aliases. + +Foreign keys may reference self or another exported table from the same module. They may not target +another module or an omitted private table. Every source column must belong to the declaring table, +and all columns in a compound target must belong to the same exported target table. Modules must +never import another module's tables, columns, relations, schema entrypoint, or migration files. +Store opaque identifiers and integrate through public capabilities, events, or APIs instead. + ## Discovery and lifecycle From the repository root, run: @@ -140,8 +184,14 @@ pnpm verify `pnpm modules:validate` builds the workspace and then atomically validates every installed module. It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible platform or SDK ranges, missing required modules or capabilities, incompatible installed -dependencies, cycles, and missing, escaping, or non-JavaScript server entrypoints. Missing optional -dependencies are allowed. Valid modules are ordered dependency-first with stable identifier sorting. +dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints. +Missing optional dependencies are allowed. Valid modules are ordered dependency-first with stable +identifier sorting. + +After complete static discovery succeeds, validation loads optional schema entrypoints +dependency-first and rejects invalid definitions, unnamespaced or duplicate tables, custom +PostgreSQL schemas, and foreign keys outside the owning module. The aggregate is published only when +every schema is valid. This slice does not connect to PostgreSQL or execute migrations. Core completes discovery before importing any server entrypoint. It then loads and verifies every runtime definition before running hooks, registers all modules dependency-first into an unpublished diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 400cab3..386c39a 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit --project tsconfig.json" }, "dependencies": { + "drizzle-orm": "0.45.2", "semver": "7.8.5", "zod": "4.4.3" }, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8815b08..298cb1c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -2,4 +2,5 @@ export * from "./identifiers.js"; export * from "./module-lifecycle.js"; export * from "./module-manifest.js"; export * from "./module-runtime.js"; +export * from "./module-schema.js"; export * from "./versions.js"; diff --git a/packages/contracts/src/module-schema.ts b/packages/contracts/src/module-schema.ts new file mode 100644 index 0000000..4fc65a0 --- /dev/null +++ b/packages/contracts/src/module-schema.ts @@ -0,0 +1,94 @@ +import { is } from "drizzle-orm"; +import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core"; +import { z } from "zod"; +import { type ModuleId, moduleIdSchema } from "./identifiers.js"; + +export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63; + +export const moduleTableLocalNameSchema = z + .string() + .min(1) + .regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name."); + +export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max( + POSTGRES_IDENTIFIER_MAX_LENGTH, + `Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`, +); + +export interface ModuleSchemaDefinition< + Tables extends Readonly> = Readonly>, +> { + readonly moduleId: ModuleId; + readonly tables: Tables; +} + +const schemaDefinitionKeys = new Set(["moduleId", "tables"]); +const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias"); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyStringKeys(value: object, allowedKeys?: ReadonlySet): boolean { + return Reflect.ownKeys(value).every( + (key) => typeof key === "string" && (!allowedKeys || allowedKeys.has(key)), + ); +} + +export function moduleTableNamespace(moduleId: ModuleId): string { + return moduleIdSchema.parse(moduleId).replaceAll("-", "_"); +} + +export function moduleTablePrefix(moduleId: ModuleId): string { + return `${moduleTableNamespace(moduleId)}_`; +} + +export function moduleTableName(moduleId: ModuleId, localName: string): string { + const parsedLocalName = moduleTableLocalNameSchema.parse(localName); + const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`; + if (!physicalModuleTableNameSchema.safeParse(tableName).success) { + throw new RangeError("The namespaced PostgreSQL table name is invalid or too long."); + } + return tableName; +} + +export function isModuleSchemaDefinition(value: unknown): value is ModuleSchemaDefinition { + try { + if ( + !isRecord(value) || + !hasOnlyStringKeys(value, schemaDefinitionKeys) || + !moduleIdSchema.safeParse(value.moduleId).success || + !isRecord(value.tables) || + !hasOnlyStringKeys(value.tables) + ) { + return false; + } + + const tables = Object.values(value.tables); + return ( + tables.length > 0 && + tables.every( + (table) => + is(table, PgTable) && + (table as unknown as Record)[drizzleTableIsAlias] === false, + ) + ); + } catch { + return false; + } +} + +export function parseModuleSchemaDefinition(value: unknown): ModuleSchemaDefinition { + if (!isModuleSchemaDefinition(value)) { + throw new TypeError("Invalid Ekklesia module schema definition."); + } + + const tables = Object.freeze( + Object.fromEntries( + Object.entries(value.tables).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ) as Record, + ); + return Object.freeze({ moduleId: value.moduleId, tables }); +} diff --git a/packages/contracts/tests/module-schema.test.ts b/packages/contracts/tests/module-schema.test.ts new file mode 100644 index 0000000..7e43179 --- /dev/null +++ b/packages/contracts/tests/module-schema.test.ts @@ -0,0 +1,54 @@ +import { alias, getTableConfig, integer } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { + moduleTableName, + parseModuleSchemaDefinition, + physicalModuleTableNameSchema, +} from "../src/index.js"; +import { pgTable } from "drizzle-orm/pg-core"; + +describe("module schema contract", () => { + it("creates deterministic physical names from module and local identifiers", () => { + expect(moduleTableName("calendar", "events")).toBe("calendar_events"); + expect(moduleTableName("care-team", "member_notes")).toBe("care_team_member_notes"); + }); + + it("rejects unsafe logical names and PostgreSQL identifiers beyond 63 characters", () => { + expect(() => moduleTableName("calendar", "EventItems")).toThrow(); + expect(() => moduleTableName("calendar", "event-items")).toThrow(); + expect(physicalModuleTableNameSchema.safeParse("a".repeat(63)).success).toBe(true); + expect(physicalModuleTableNameSchema.safeParse("a".repeat(64)).success).toBe(false); + expect(() => moduleTableName("a".repeat(60), "rows")).toThrow( + "The namespaced PostgreSQL table name is invalid or too long.", + ); + }); + + it("normalizes and freezes a typed schema definition without freezing its tables", () => { + const records = pgTable("example_records", { id: integer().primaryKey() }); + const definition = parseModuleSchemaDefinition({ + moduleId: "example", + tables: { records }, + }); + + expect(definition.tables.records).toBe(records); + expect(getTableConfig(records).name).toBe("example_records"); + expect(Object.isFrozen(definition)).toBe(true); + expect(Object.isFrozen(definition.tables)).toBe(true); + expect(Object.isFrozen(records)).toBe(false); + }); + + it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => { + const records = pgTable("example_records", { id: integer() }); + const disguisedRecords = alias(records, "example_disguised_records"); + expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow(); + expect(() => + parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }), + ).toThrow(); + expect(() => + parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }), + ).toThrow(); + expect(() => + parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }), + ).toThrow(); + }); +}); diff --git a/packages/module-sdk/package.json b/packages/module-sdk/package.json index 22d3273..7f26ab0 100644 --- a/packages/module-sdk/package.json +++ b/packages/module-sdk/package.json @@ -24,10 +24,14 @@ "devDependencies": { "@biomejs/biome": "2.5.3", "@types/node": "24.13.3", + "drizzle-orm": "0.45.2", "typescript": "7.0.2", "vitest": "4.1.10" }, "ekklesia": { "layer": "module-sdk" + }, + "peerDependencies": { + "drizzle-orm": "0.45.2" } } diff --git a/packages/module-sdk/src/define-module-schema.ts b/packages/module-sdk/src/define-module-schema.ts new file mode 100644 index 0000000..1e04bfd --- /dev/null +++ b/packages/module-sdk/src/define-module-schema.ts @@ -0,0 +1,19 @@ +import { + type ModuleId, + type ModuleSchemaDefinition, + moduleTableName, + moduleTablePrefix, + parseModuleSchemaDefinition, +} from "@ekklesia/contracts"; +import { pgTableCreator } from "drizzle-orm/pg-core"; + +export function createModuleTable(moduleId: ModuleId) { + moduleTablePrefix(moduleId); + return pgTableCreator((localName) => moduleTableName(moduleId, localName)); +} + +export function defineModuleSchema( + definition: Definition, +): Readonly { + return parseModuleSchemaDefinition(definition) as Readonly; +} diff --git a/packages/module-sdk/src/index.ts b/packages/module-sdk/src/index.ts index d1915cd..eb860e8 100644 --- a/packages/module-sdk/src/index.ts +++ b/packages/module-sdk/src/index.ts @@ -1,2 +1,3 @@ export * from "@ekklesia/contracts"; export * from "./define-module-runtime.js"; +export * from "./define-module-schema.js"; diff --git a/packages/module-sdk/tests/define-module-schema.test.ts b/packages/module-sdk/tests/define-module-schema.test.ts new file mode 100644 index 0000000..71ffc9f --- /dev/null +++ b/packages/module-sdk/tests/define-module-schema.test.ts @@ -0,0 +1,25 @@ +import { getTableConfig, integer } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { createModuleTable, defineModuleSchema } from "../src/index.js"; + +describe("module schema authoring", () => { + it("creates namespaced PostgreSQL tables and a typed frozen definition", () => { + const moduleTable = createModuleTable("care-team"); + const members = moduleTable("members", { id: integer().primaryKey() }); + const definition = defineModuleSchema({ + moduleId: "care-team", + tables: { members }, + }); + + expect(getTableConfig(members).name).toBe("care_team_members"); + expect(definition.tables.members).toBe(members); + expect(Object.isFrozen(definition)).toBe(true); + expect(Object.isFrozen(definition.tables)).toBe(true); + }); + + it("rejects invalid module and logical table names", () => { + expect(() => createModuleTable("Not Valid")).toThrow(); + const moduleTable = createModuleTable("example"); + expect(() => moduleTable("Not Valid", { id: integer() })).toThrow(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11ddb86..18d356d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@ekklesia/contracts': specifier: workspace:* version: link:../packages/contracts + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2 devDependencies: '@biomejs/biome': specifier: 2.5.3 @@ -48,6 +51,9 @@ importers: packages/contracts: dependencies: + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2 semver: specifier: 7.8.5 version: 7.8.5 @@ -83,6 +89,9 @@ importers: '@types/node': specifier: 24.13.3 version: 24.13.3 + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2 typescript: specifier: 7.0.2 version: 7.0.2 @@ -97,6 +106,9 @@ importers: '@ekklesia/module-sdk': specifier: workspace:* version: link:../../packages/module-sdk + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2 devDependencies: '@biomejs/biome': specifier: 2.5.3 @@ -507,6 +519,98 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} @@ -1045,6 +1149,8 @@ snapshots: detect-libc@2.1.2: {} + drizzle-orm@0.45.2: {} + es-module-lexer@2.3.0: {} estree-walker@3.0.3: diff --git a/templates/module/README.md b/templates/module/README.md index c587821..ffbcaf8 100644 --- a/templates/module/README.md +++ b/templates/module/README.md @@ -1,8 +1,9 @@ # Ekklesia module template This is the smallest supported backend-only Ekklesia module. It proves the static manifest, -compiled runtime entrypoint, capability registration, and logical storage-bucket declarations -without pretending that API, Drizzle, or storage provisioning already exist. +compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, and +logical storage-bucket declarations without pretending that migrations, API, or storage +provisioning already exist. To create a module inside this repository: @@ -14,9 +15,11 @@ To create a module inside this repository: module actually implements and consumes. 5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version must match the manifest; do not duplicate the rest of the manifest in TypeScript. -6. Run `pnpm install`, `pnpm build`, `pnpm modules:validate`, and `pnpm verify` from the repository +6. Replace `src/schema/index.ts` with declaration-only Drizzle tables. Keep `createModuleTable` and + `defineModuleSchema` aligned with the module ID, and never import another module's schema. +7. Run `pnpm install`, `pnpm build`, `pnpm modules:validate`, and `pnpm verify` from the repository root. The declared production entrypoint is the compiled `dist/server/index.js`, not source - TypeScript. + TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`. Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces. Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`, diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json index de30f9e..ab86c9b 100644 --- a/templates/module/ekklesia.module.json +++ b/templates/module/ekklesia.module.json @@ -24,6 +24,7 @@ ] }, "entrypoints": { - "server": "./dist/server/index.js" + "server": "./dist/server/index.js", + "schema": "./dist/schema/index.js" } } diff --git a/templates/module/package.json b/templates/module/package.json index c0d1cf6..5facaf6 100644 --- a/templates/module/package.json +++ b/templates/module/package.json @@ -25,12 +25,13 @@ "typecheck": "tsc --noEmit --project tsconfig.json" }, "dependencies": { - "@ekklesia/module-sdk": "workspace:*" + "@ekklesia/module-sdk": "workspace:*", + "drizzle-orm": "0.45.2" }, "devDependencies": { "@biomejs/biome": "2.5.3", - "@types/node": "24.13.3", "@ekklesia/typescript-config": "workspace:*", + "@types/node": "24.13.3", "typescript": "7.0.2", "vitest": "4.1.10" }, diff --git a/templates/module/src/schema/index.ts b/templates/module/src/schema/index.ts new file mode 100644 index 0000000..511e3a8 --- /dev/null +++ b/templates/module/src/schema/index.ts @@ -0,0 +1,14 @@ +import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk"; +import { text, uuid } from "drizzle-orm/pg-core"; + +const moduleTable = createModuleTable("example"); + +export const records = moduleTable("records", { + id: uuid("id").primaryKey(), + value: text("value").notNull(), +}); + +export default defineModuleSchema({ + moduleId: "example", + tables: { records }, +}); diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index b4a6f28..54f3791 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -4,7 +4,9 @@ import { parseModuleManifest, type RegisteredCapability, } from "@ekklesia/module-sdk"; +import { getTableConfig } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; +import moduleSchema, { records } from "../src/schema/index.js"; import moduleRuntime, { recordsCapability } from "../src/server/index.js"; describe("module template", () => { @@ -16,6 +18,7 @@ describe("module template", () => { expect(manifest.id).toBe("example"); expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]); expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]); + expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js"); }); it("exports the typed runtime definition used by the compiled server entrypoint", async () => { @@ -35,4 +38,10 @@ describe("module template", () => { expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" }); expect(provided.get("example.records")).toBe(recordsCapability); }); + + it("exports a namespaced module-owned Drizzle schema", () => { + expect(moduleSchema.moduleId).toBe("example"); + expect(moduleSchema.tables.records).toBe(records); + expect(getTableConfig(records)).toMatchObject({ name: "example_records", schema: undefined }); + }); }); diff --git a/tests/boundaries.test.ts b/tests/boundaries.test.ts index 921c4ef..161addf 100644 --- a/tests/boundaries.test.ts +++ b/tests/boundaries.test.ts @@ -29,6 +29,7 @@ describe("repository dependency boundaries", () => { ["module-imports-surface", "modules-use-public-platform-contracts"], ["package-boundary-direction", "modules-use-public-platform-contracts"], ["cross-module-import", "modules-do-not-import-other-modules"], + ["module-schema-imports-module", "modules-do-not-import-other-modules"], ["surface-imports-module", "surfaces-use-public-contracts"], ["cross-surface-import", "surfaces-are-independent"], ["builder-imports-module", "builder-composes-contracts"], diff --git a/tests/fixtures/boundaries/module-schema-imports-module/modules/calendar/src/schema.ts b/tests/fixtures/boundaries/module-schema-imports-module/modules/calendar/src/schema.ts new file mode 100644 index 0000000..921cc75 --- /dev/null +++ b/tests/fixtures/boundaries/module-schema-imports-module/modules/calendar/src/schema.ts @@ -0,0 +1,3 @@ +import { peopleRecords } from "../../../modules/people/src/schema.js"; + +export const invalidPrivateSchemaImport = peopleRecords; diff --git a/tests/fixtures/boundaries/module-schema-imports-module/modules/people/src/schema.ts b/tests/fixtures/boundaries/module-schema-imports-module/modules/people/src/schema.ts new file mode 100644 index 0000000..3ec231f --- /dev/null +++ b/tests/fixtures/boundaries/module-schema-imports-module/modules/people/src/schema.ts @@ -0,0 +1 @@ +export const peopleRecords = "private people schema";