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:
@@ -13,5 +13,7 @@ export * from "./module-package.js";
|
||||
export * from "./module-realtime.js";
|
||||
export * from "./module-runtime.js";
|
||||
export * from "./module-schema.js";
|
||||
export * from "./module-schema-metadata.js";
|
||||
export * from "./module-schema-names.js";
|
||||
export * from "./module-storage.js";
|
||||
export * from "./versions.js";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
||||
|
||||
/**
|
||||
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
|
||||
@@ -45,6 +46,14 @@ export type ModuleDatabase = Pick<
|
||||
* remain out of scope until a later phase explicitly adds them here, one at a time, the same way
|
||||
* `database` was added.
|
||||
*/
|
||||
export interface ModuleRuntimeServices {
|
||||
readonly database: ModuleDatabase;
|
||||
export interface ModuleRuntimeServices<
|
||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
||||
> {
|
||||
readonly database: Database;
|
||||
}
|
||||
|
||||
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
|
||||
export type ModuleDatabaseAbiRuntimeServices = ModuleRuntimeServices<ModuleDatabaseExecutor>;
|
||||
export type AnyModuleRuntimeServices =
|
||||
| LegacyModuleRuntimeServices
|
||||
| ModuleDatabaseAbiRuntimeServices;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { z } from "zod";
|
||||
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
|
||||
import type { EkklesiaPrincipal } from "./identity.js";
|
||||
import type { ModuleConfiguration } from "./module-configuration.js";
|
||||
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||
import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
|
||||
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
||||
import type { ModuleRealtimePublisher } from "./module-realtime.js";
|
||||
import type { ModuleStorage } from "./module-storage.js";
|
||||
|
||||
@@ -271,7 +272,9 @@ export function moduleRoutesOverlap(
|
||||
* behavior, and uncaught-handler sanitization; permission enforcement always completes before a
|
||||
* handler — and therefore any parameter validation it performs — can run.
|
||||
*/
|
||||
export interface ModuleRouteHandlerContext {
|
||||
export interface ModuleRouteHandlerContext<
|
||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
||||
> {
|
||||
readonly moduleId: ModuleId;
|
||||
readonly principal: EkklesiaPrincipal;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
@@ -279,7 +282,7 @@ export interface ModuleRouteHandlerContext {
|
||||
* object, not a re-resolved one (ADR 0030). This is what lets a module stream an object straight
|
||||
* into a `Response` without the browser ever learning that object storage exists. */
|
||||
readonly storage: ModuleStorage;
|
||||
readonly services: ModuleRuntimeServices;
|
||||
readonly services: ModuleRuntimeServices<Database>;
|
||||
/** The identical module-scoped realtime publisher used by lifecycle hooks. */
|
||||
readonly realtime: ModuleRealtimePublisher;
|
||||
/** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the
|
||||
@@ -293,10 +296,9 @@ export interface ModuleRouteHandlerContext {
|
||||
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
||||
* query string remains available through `Request.url`; there is no competing query or body
|
||||
* abstraction. */
|
||||
export type ModuleRouteHandler = (
|
||||
request: Request,
|
||||
context: ModuleRouteHandlerContext,
|
||||
) => Awaitable<Response>;
|
||||
export type ModuleRouteHandler<
|
||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
||||
> = (request: Request, context: ModuleRouteHandlerContext<Database>) => Awaitable<Response>;
|
||||
|
||||
/**
|
||||
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
|
||||
@@ -311,7 +313,9 @@ export type ModuleRouteHandler = (
|
||||
* path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit
|
||||
* metadata, another module's namespace, or an unprotected/no-permission route.
|
||||
*/
|
||||
export interface ModuleRouteDefinitionInput {
|
||||
export interface ModuleRouteDefinitionInput<
|
||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
||||
> {
|
||||
readonly method: ModuleHttpMethod;
|
||||
readonly path: string;
|
||||
readonly permission: string;
|
||||
@@ -322,12 +326,14 @@ export interface ModuleRouteDefinitionInput {
|
||||
* existed meant, so adding this field changed no existing route's behaviour.
|
||||
*/
|
||||
readonly bodyMode?: ModuleRouteBodyMode;
|
||||
readonly handler: ModuleRouteHandler;
|
||||
readonly handler: ModuleRouteHandler<Database>;
|
||||
}
|
||||
|
||||
/** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces
|
||||
* only after every field passes its own grammar. */
|
||||
export interface ModuleRouteDefinition {
|
||||
export interface ModuleRouteDefinition<
|
||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
||||
> {
|
||||
readonly method: ModuleHttpMethod;
|
||||
readonly path: ModuleRoutePath;
|
||||
readonly permission: PermissionId;
|
||||
@@ -335,13 +341,15 @@ export interface ModuleRouteDefinition {
|
||||
/** Always present in the canonical form, defaulted to `buffered`, so no consumer downstream has
|
||||
* to re-apply the default and none of them can disagree about it. */
|
||||
readonly bodyMode: ModuleRouteBodyMode;
|
||||
readonly handler: ModuleRouteHandler;
|
||||
readonly handler: ModuleRouteHandler<Database>;
|
||||
}
|
||||
|
||||
/** The Fetch-based registrar exposed as `context.http` during `register` only — never on
|
||||
* `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */
|
||||
export interface ModuleHttpRouteRegistrar {
|
||||
route(definition: ModuleRouteDefinitionInput): void;
|
||||
export interface ModuleHttpRouteRegistrar<
|
||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
||||
> {
|
||||
route(definition: ModuleRouteDefinitionInput<Database>): void;
|
||||
}
|
||||
|
||||
/** One published route in `StartedModuleRuntime`'s immutable route snapshot — the same validated
|
||||
@@ -355,6 +363,8 @@ export interface RegisteredModuleHttpRoute {
|
||||
readonly permission: PermissionId;
|
||||
readonly maxBodyBytes: number;
|
||||
readonly bodyMode: ModuleRouteBodyMode;
|
||||
/** Runtime-erased callback. Module authors get the database-mode-specific handler type through
|
||||
* `ModuleHttpRouteRegistrar`; Core stores the validated callback opaquely in this snapshot. */
|
||||
readonly handler: ModuleRouteHandler;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,14 @@ const moduleStorageSchema = z
|
||||
.strict()
|
||||
.default(() => ({ buckets: [] }));
|
||||
|
||||
/** The database contract Core delivers to this module. Omitted means the temporary ADR 0016
|
||||
* legacy facade; ABI v1 is the stable SQL boundary from ADR 0050. */
|
||||
export const moduleDatabaseDeclarationSchema = z
|
||||
.object({
|
||||
abi: z.literal(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* A module's optional full-viewport display UI. The artifact is kept beside its access policy so a
|
||||
* display can never be declared without saying which one of the module's own permissions protects
|
||||
@@ -122,9 +130,9 @@ function reportDuplicates(
|
||||
}
|
||||
|
||||
/**
|
||||
* The one manifest shape Core accepts. Version 6's strict top-level object rejects `contributions`,
|
||||
* so the static browser-contribution declaration is an incompatible shape change and the canonical
|
||||
* manifest moves to `manifestVersion: 7` rather than silently reinterpreting version 6.
|
||||
* The one manifest shape Core accepts. Version 7's strict top-level object rejects `database`, so
|
||||
* the static ABI declaration is an incompatible shape change and the canonical manifest moves to
|
||||
* `manifestVersion: 8` without silently reinterpreting version 7.
|
||||
*
|
||||
* Configuration is declared **statically, in this inert document**, rather than registered by
|
||||
* executable module code, for one decisive reason: the platform validates a module's configuration
|
||||
@@ -133,14 +141,13 @@ function reportDuplicates(
|
||||
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
||||
* importing a single line of module code, exactly as they always have.
|
||||
*
|
||||
* Version 6, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* Version 7, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 5
|
||||
* by version 6 (ADR 0033). One manifest document can therefore never be read as two different
|
||||
* versions. Upgrading a version 6 manifest is a one-character edit for a module that contributes
|
||||
* nothing: the new field defaults to empty.
|
||||
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 7
|
||||
* by version 8 (ADR 0050). One manifest document can therefore never be read as two different
|
||||
* versions. A version 7 package must be rebuilt; omitting `database` selects the legacy facade.
|
||||
*/
|
||||
export const MODULE_MANIFEST_VERSION = 7;
|
||||
export const MODULE_MANIFEST_VERSION = 8;
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
.object({
|
||||
@@ -163,6 +170,7 @@ export const moduleManifestSchema = z
|
||||
* module code during discovery. A module that needs nothing configured omits the field. */
|
||||
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
||||
storage: moduleStorageSchema,
|
||||
database: moduleDatabaseDeclarationSchema.optional(),
|
||||
/** Compiled browser renderers this module offers to capabilities **other** modules provide
|
||||
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
|
||||
* never learns what a contribution renders. A module that contributes nothing omits the field. */
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||
import { moduleIdSchema } from "./identifiers.js";
|
||||
import type { ModuleConfiguration } from "./module-configuration.js";
|
||||
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||
import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
|
||||
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
||||
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
||||
import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js";
|
||||
import type { ModuleStorage } from "./module-storage.js";
|
||||
@@ -24,12 +25,14 @@ export interface CapabilityRegistrar extends CapabilityResolver {
|
||||
provide(capabilityId: CapabilityId, implementation: unknown): void;
|
||||
}
|
||||
|
||||
export interface ModuleRegistrationContext {
|
||||
export interface ModuleRegistrationContext<
|
||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
||||
> {
|
||||
readonly capabilities: CapabilityRegistrar;
|
||||
/** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever
|
||||
* be declared while Core is still building the immutable startup snapshot, never added or removed
|
||||
* afterward from `start`, `stop`, or any other post-registration code path. */
|
||||
readonly http: ModuleHttpRouteRegistrar;
|
||||
readonly http: ModuleHttpRouteRegistrar<Database>;
|
||||
/** Declarative, registration-only channel publication. Live publishing is available only from
|
||||
* runtime and request-handler contexts. */
|
||||
readonly realtime: ModuleRealtimeChannelRegistrar;
|
||||
@@ -49,10 +52,12 @@ export interface ModuleRegistrationContext {
|
||||
* same frozen `services` object identity as the other, for a given API runtime — never a fresh
|
||||
* `ModuleRuntimeServices` built per hook invocation. Unlike `ModuleRegistrationContext`, this never
|
||||
* carries `http`: a route can only ever be declared during `register`. */
|
||||
export interface ModuleRuntimeContext {
|
||||
export interface ModuleRuntimeContext<
|
||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
||||
> {
|
||||
readonly capabilities: CapabilityResolver;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly services: ModuleRuntimeServices;
|
||||
readonly services: ModuleRuntimeServices<Database>;
|
||||
/**
|
||||
* This module's own scoped object storage (ADR 0030).
|
||||
*
|
||||
@@ -70,8 +75,8 @@ export interface ModuleRuntimeContext {
|
||||
/**
|
||||
* This module's own validated configuration, frozen for the lifetime of the process.
|
||||
*
|
||||
* Deliberately **not** a field on `ModuleRuntimeServices`: that object is one shared instance
|
||||
* every module receives, and configuration is per-module by definition. It sits beside `moduleId`
|
||||
* Deliberately **not** a field on `ModuleRuntimeServices`: that object holds live platform
|
||||
* services while configuration is a separately resolved snapshot. It sits beside `moduleId`
|
||||
* — the context's other per-module fact — so the scoping is visible in the shape rather than
|
||||
* asserted in a comment.
|
||||
*
|
||||
@@ -82,15 +87,28 @@ export interface ModuleRuntimeContext {
|
||||
readonly config: ModuleConfiguration;
|
||||
}
|
||||
|
||||
export interface ModuleRuntimeDefinition {
|
||||
interface ModuleRuntimeDefinitionBase<Database extends ModuleDatabase | ModuleDatabaseExecutor> {
|
||||
readonly id: ModuleId;
|
||||
readonly register?: (context: ModuleRegistrationContext) => Awaitable<void>;
|
||||
readonly start?: (context: ModuleRuntimeContext) => Awaitable<void>;
|
||||
readonly stop?: (context: ModuleRuntimeContext) => Awaitable<void>;
|
||||
readonly register?: (context: ModuleRegistrationContext<Database>) => Awaitable<void>;
|
||||
readonly start?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
|
||||
readonly stop?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
const runtimeDefinitionKeys = new Set(["id", "register", "start", "stop", "version"]);
|
||||
export interface LegacyModuleRuntimeDefinition extends ModuleRuntimeDefinitionBase<ModuleDatabase> {
|
||||
readonly database?: never;
|
||||
}
|
||||
|
||||
export interface ModuleDatabaseAbiRuntimeDefinition
|
||||
extends ModuleRuntimeDefinitionBase<ModuleDatabaseExecutor> {
|
||||
readonly database: { readonly abi: 1 };
|
||||
}
|
||||
|
||||
export type ModuleRuntimeDefinition =
|
||||
| LegacyModuleRuntimeDefinition
|
||||
| ModuleDatabaseAbiRuntimeDefinition;
|
||||
|
||||
const runtimeDefinitionKeys = new Set(["database", "id", "register", "start", "stop", "version"]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -107,6 +125,14 @@ export function isModuleRuntimeDefinition(value: unknown): value is ModuleRuntim
|
||||
if (Object.keys(value).some((key) => !runtimeDefinitionKeys.has(key))) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.database !== undefined &&
|
||||
(!isRecord(value.database) ||
|
||||
Reflect.ownKeys(value.database).length !== 1 ||
|
||||
value.database.abi !== 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return [value.register, value.start, value.stop].every(
|
||||
(hook) => hook === undefined || typeof hook === "function",
|
||||
);
|
||||
@@ -116,11 +142,22 @@ export function parseModuleRuntimeDefinition(value: unknown): ModuleRuntimeDefin
|
||||
if (!isModuleRuntimeDefinition(value)) {
|
||||
throw new TypeError("Invalid Ekklesia module runtime definition.");
|
||||
}
|
||||
return Object.freeze({
|
||||
id: value.id,
|
||||
version: value.version,
|
||||
const hooks = {
|
||||
...(value.register ? { register: value.register } : {}),
|
||||
...(value.start ? { start: value.start } : {}),
|
||||
...(value.stop ? { stop: value.stop } : {}),
|
||||
});
|
||||
};
|
||||
if (value.database) {
|
||||
return Object.freeze({
|
||||
database: Object.freeze({ abi: 1 as const }),
|
||||
id: value.id,
|
||||
version: value.version,
|
||||
...hooks,
|
||||
}) as ModuleDatabaseAbiRuntimeDefinition;
|
||||
}
|
||||
return Object.freeze({
|
||||
id: value.id,
|
||||
version: value.version,
|
||||
...hooks,
|
||||
}) as LegacyModuleRuntimeDefinition;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
import { moduleIdSchema } from "./identifiers.js";
|
||||
import { physicalModuleTableNameSchema } from "./module-schema-names.js";
|
||||
|
||||
export const moduleSchemaForeignKeyMetadataSchema = z
|
||||
.object({ table: physicalModuleTableNameSchema })
|
||||
.strict();
|
||||
|
||||
export const moduleSchemaTableMetadataSchema = z
|
||||
.object({
|
||||
name: physicalModuleTableNameSchema,
|
||||
foreignKeys: z.array(moduleSchemaForeignKeyMetadataSchema).default(() => []),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** The ABI-v1 schema entrypoint: inert Ekklesia-owned data, with no ORM object or executable
|
||||
* callback anywhere in its shape. Ownership and foreign-key targets are validated by Core against
|
||||
* the manifest module id and the complete table list. */
|
||||
export const moduleSchemaMetadataDefinitionSchema = z
|
||||
.object({
|
||||
moduleId: moduleIdSchema,
|
||||
tables: z.array(moduleSchemaTableMetadataSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export interface ModuleSchemaForeignKeyMetadata {
|
||||
readonly table: string;
|
||||
}
|
||||
export interface ModuleSchemaTableMetadata {
|
||||
readonly name: string;
|
||||
readonly foreignKeys: readonly ModuleSchemaForeignKeyMetadata[];
|
||||
}
|
||||
export interface ModuleSchemaMetadataDefinition {
|
||||
readonly moduleId: z.output<typeof moduleIdSchema>;
|
||||
readonly tables: readonly ModuleSchemaTableMetadata[];
|
||||
}
|
||||
|
||||
function isPlainData(value: unknown): boolean {
|
||||
if (value === null || typeof value === "string") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.every(isPlainData);
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return (
|
||||
(prototype === Object.prototype || prototype === null) &&
|
||||
Reflect.ownKeys(value).every(
|
||||
(key) => typeof key === "string" && isPlainData((value as Record<string, unknown>)[key]),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseModuleSchemaMetadataDefinition(
|
||||
value: unknown,
|
||||
): ModuleSchemaMetadataDefinition {
|
||||
if (!isPlainData(value)) {
|
||||
throw new TypeError("Invalid Ekklesia module schema metadata definition.");
|
||||
}
|
||||
const parsed = moduleSchemaMetadataDefinitionSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new TypeError("Invalid Ekklesia module schema metadata definition.");
|
||||
}
|
||||
return Object.freeze({
|
||||
moduleId: parsed.data.moduleId,
|
||||
tables: Object.freeze(
|
||||
parsed.data.tables.map((table) =>
|
||||
Object.freeze({
|
||||
foreignKeys: Object.freeze(
|
||||
table.foreignKeys.map((foreignKey) => Object.freeze(foreignKey)),
|
||||
),
|
||||
name: table.name,
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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 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;
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
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(
|
||||
export {
|
||||
moduleTableLocalNameSchema,
|
||||
moduleTableName,
|
||||
moduleTableNamespace,
|
||||
moduleTablePrefix,
|
||||
POSTGRES_IDENTIFIER_MAX_LENGTH,
|
||||
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`,
|
||||
);
|
||||
physicalModuleTableNameSchema,
|
||||
} from "./module-schema-names.js";
|
||||
|
||||
export interface ModuleSchemaDefinition<
|
||||
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
|
||||
@@ -35,23 +31,6 @@ function hasOnlyStringKeys(value: object, allowedKeys?: ReadonlySet<string>): bo
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
function validManifest(): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
version: "0.1.0",
|
||||
@@ -42,6 +42,24 @@ function validManifest(): Record<string, unknown> {
|
||||
}
|
||||
|
||||
describe("module manifest contract", () => {
|
||||
it("accepts both manifest-v8 database modes", () => {
|
||||
expect(parseModuleManifest(validManifest()).database).toBeUndefined();
|
||||
expect(parseModuleManifest({ ...validManifest(), database: { abi: 1 } }).database).toEqual({
|
||||
abi: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects manifest v7, unsupported ABIs, malformed declarations, and unknown database fields", () => {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 7 }).success).toBe(false);
|
||||
expect(safeParseModuleManifest({ ...validManifest(), database: { abi: 2 } }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(safeParseModuleManifest({ ...validManifest(), database: "abi-v1" }).success).toBe(false);
|
||||
expect(
|
||||
safeParseModuleManifest({ ...validManifest(), database: { abi: 1, driver: "drizzle" } })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
it("normalizes a valid manifest with safe defaults", () => {
|
||||
const manifest = parseModuleManifest(validManifest());
|
||||
|
||||
@@ -232,15 +250,15 @@ describe("module manifest contract", () => {
|
||||
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a version 6 manifest rather than silently interpreting it as version 7", () => {
|
||||
it("rejects a version 6 manifest rather than silently interpreting it as version 8", () => {
|
||||
const version6Manifest = { ...validManifest(), manifestVersion: 6 };
|
||||
|
||||
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(7);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 8, "7", 7.5, null]) {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(8);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 9, "8", 8.5, null]) {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||
}
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseModuleSchemaMetadataDefinition } from "../src/index.js";
|
||||
|
||||
describe("module schema metadata contract", () => {
|
||||
it("accepts and freezes plain ABI-v1 metadata", () => {
|
||||
const definition = parseModuleSchemaMetadataDefinition({
|
||||
moduleId: "calendar",
|
||||
tables: [
|
||||
{ name: "calendar_events" },
|
||||
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(definition).toEqual({
|
||||
moduleId: "calendar",
|
||||
tables: [
|
||||
{ name: "calendar_events", foreignKeys: [] },
|
||||
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] },
|
||||
],
|
||||
});
|
||||
expect(Object.isFrozen(definition)).toBe(true);
|
||||
expect(Object.isFrozen(definition.tables)).toBe(true);
|
||||
expect(Object.isFrozen(definition.tables[1]?.foreignKeys)).toBe(true);
|
||||
});
|
||||
|
||||
it("strictly rejects malformed, executable, and class-backed metadata", () => {
|
||||
class Metadata {
|
||||
moduleId = "calendar";
|
||||
tables = [{ name: "calendar_events" }];
|
||||
}
|
||||
|
||||
for (const value of [
|
||||
{ moduleId: "calendar", tables: [] },
|
||||
{ moduleId: "calendar", tables: [{ name: "calendar_events", unknown: true }] },
|
||||
{ moduleId: "calendar", tables: [{ name: "calendar_events", foreignKeys: [{}] }] },
|
||||
{ moduleId: "calendar", tables: [{ name: "calendar_events", inspect: () => undefined }] },
|
||||
new Metadata(),
|
||||
]) {
|
||||
expect(() => parseModuleSchemaMetadataDefinition(value)).toThrow(
|
||||
"Invalid Ekklesia module schema metadata definition.",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,11 @@ import type {
|
||||
ModuleDatabaseParameter,
|
||||
ModuleDatabaseQueryable,
|
||||
ModuleDatabaseTransaction,
|
||||
ModuleId,
|
||||
ModuleSchemaMetadataDefinition,
|
||||
} from "@ekklesia/contracts";
|
||||
import { parseModuleSchemaMetadataDefinition } from "@ekklesia/contracts";
|
||||
import { type AnyPgTable, getTableConfig } from "drizzle-orm/pg-core";
|
||||
import { drizzle, type PgRemoteDatabase } from "drizzle-orm/pg-proxy";
|
||||
import type { DrizzleConfig } from "drizzle-orm/utils";
|
||||
|
||||
@@ -83,3 +87,26 @@ export function moduleDrizzle<TSchema extends Record<string, unknown> = Record<s
|
||||
executor.transaction((transaction) => callback(transactionDatabase(transaction, config))),
|
||||
);
|
||||
}
|
||||
|
||||
/** Derives the plain ABI-v1 schema entrypoint from a module's own Drizzle tables. Drizzle stays on
|
||||
* this module-side adapter path; Core consumes only the returned strings and arrays. */
|
||||
export function moduleDrizzleSchemaMetadata(
|
||||
moduleId: ModuleId,
|
||||
tables: Readonly<Record<string, AnyPgTable>>,
|
||||
): ModuleSchemaMetadataDefinition {
|
||||
const metadata = Object.entries(tables)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([_exportName, table]) => {
|
||||
const config = getTableConfig(table);
|
||||
if (config.schema !== undefined && config.schema !== "public") {
|
||||
throw new TypeError("Module schema metadata supports only the public PostgreSQL schema.");
|
||||
}
|
||||
return {
|
||||
name: config.name,
|
||||
foreignKeys: config.foreignKeys.map((foreignKey) => ({
|
||||
table: getTableConfig(foreignKey.reference().foreignTable).name,
|
||||
})),
|
||||
};
|
||||
});
|
||||
return parseModuleSchemaMetadataDefinition({ moduleId, tables: metadata });
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@ describe("the module-side database architecture", () => {
|
||||
});
|
||||
|
||||
it("keeps ABI v1 contracts free of ORM imports", async () => {
|
||||
const source = await text("packages/contracts/src/module-database-abi.ts");
|
||||
expect(source).not.toMatch(/(?:import|export).*drizzle-orm/u);
|
||||
expect(source).not.toMatch(/(?:import|export).*kysely/u);
|
||||
expect(source).not.toMatch(/(?:import|export).*\bpg\b/u);
|
||||
for (const file of ["module-database-abi.ts", "module-schema-metadata.ts"]) {
|
||||
const source = await text(`packages/contracts/src/${file}`);
|
||||
expect(source).not.toMatch(/(?:import|export).*drizzle-orm/u);
|
||||
expect(source).not.toMatch(/(?:import|export).*kysely/u);
|
||||
expect(source).not.toMatch(/(?:import|export).*\bpg\b/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("makes Drizzle an optional ranged peer and keeps raw SQL independent from it", async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ModuleDatabaseQueryResult,
|
||||
type ModuleDatabaseTransaction,
|
||||
} from "@ekklesia/contracts";
|
||||
import { and, eq, sql as drizzleSql } from "drizzle-orm";
|
||||
import { and, sql as drizzleSql, eq } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
customType,
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import { moduleDrizzle, type ModuleDrizzleDatabase } from "../src/drizzle.js";
|
||||
import {
|
||||
type ModuleDrizzleDatabase,
|
||||
moduleDrizzle,
|
||||
moduleDrizzleSchemaMetadata,
|
||||
} from "../src/drizzle.js";
|
||||
import { findEkklesiaDatabaseError } from "../src/index.js";
|
||||
|
||||
const bytea = customType<{ data: Uint8Array; driverData: Uint8Array }>({
|
||||
@@ -159,6 +163,24 @@ function setup(...outcomes: Outcome[]) {
|
||||
}
|
||||
|
||||
describe("moduleDrizzle query compilation and ABI mapping", () => {
|
||||
it("derives plain Ekklesia schema metadata with foreign-key targets", () => {
|
||||
const child = pgTable("calendar_children", {
|
||||
id: integer("id").primaryKey(),
|
||||
recordId: integer("record_id").references(() => records.id),
|
||||
});
|
||||
|
||||
const metadata = moduleDrizzleSchemaMetadata("calendar", { records, child });
|
||||
|
||||
expect(metadata).toEqual({
|
||||
moduleId: "calendar",
|
||||
tables: [
|
||||
{ name: "calendar_children", foreignKeys: [{ table: "calendar_records" }] },
|
||||
{ name: "calendar_records", foreignKeys: [] },
|
||||
],
|
||||
});
|
||||
expect(JSON.parse(JSON.stringify(metadata))).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("compiles a select and decodes its positional result on the module side", async () => {
|
||||
const { db, executor } = setup(result([[7, "Launch"]]));
|
||||
await expect(
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
|
||||
const tableName = `${id.replaceAll("-", "_")}_records`;
|
||||
|
||||
const baseManifest: Record<string, unknown> = {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version,
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { type ModuleRuntimeDefinition, parseModuleRuntimeDefinition } from "@ekklesia/contracts";
|
||||
import {
|
||||
type LegacyModuleRuntimeDefinition,
|
||||
type ModuleDatabaseAbiRuntimeDefinition,
|
||||
type ModuleRuntimeDefinition,
|
||||
parseModuleRuntimeDefinition,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
export function defineModuleRuntime<const Definition extends ModuleDatabaseAbiRuntimeDefinition>(
|
||||
definition: Definition,
|
||||
): Readonly<Definition>;
|
||||
export function defineModuleRuntime<const Definition extends LegacyModuleRuntimeDefinition>(
|
||||
definition: Definition,
|
||||
): Readonly<Definition>;
|
||||
export function defineModuleRuntime<const Definition extends ModuleRuntimeDefinition>(
|
||||
definition: Definition,
|
||||
): Readonly<Definition> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
defineModuleRuntime,
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
isModuleHttpMethod,
|
||||
MODULE_HTTP_METHODS,
|
||||
type ModuleHttpRouteRegistrar,
|
||||
@@ -9,6 +9,42 @@ import {
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("defineModuleRuntime", () => {
|
||||
it("types each database mode without exposing a legacy-or-ABI union", () => {
|
||||
defineModuleRuntime({
|
||||
database: { abi: 1 },
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
start({ services }) {
|
||||
expectTypeOf(services.database.query).toBeFunction();
|
||||
expectTypeOf(services.database.abiVersion).toEqualTypeOf<1>();
|
||||
// @ts-expect-error ABI-v1 modules never receive the legacy Drizzle facade.
|
||||
services.database.select();
|
||||
},
|
||||
register({ http }) {
|
||||
http.route({
|
||||
handler: (_request, { services }) => {
|
||||
expectTypeOf(services.database.abiVersion).toEqualTypeOf<1>();
|
||||
return new Response();
|
||||
},
|
||||
maxBodyBytes: 0,
|
||||
method: "GET",
|
||||
path: "/",
|
||||
permission: "example.read",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
defineModuleRuntime({
|
||||
id: "legacy",
|
||||
version: "0.1.0",
|
||||
start({ services }) {
|
||||
expectTypeOf(services.database.select).toBeFunction();
|
||||
// @ts-expect-error Legacy modules do not receive the ABI executor.
|
||||
services.database.abiVersion;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("defines a frozen server runtime without duplicating the static manifest", async () => {
|
||||
const start = vi.fn();
|
||||
const runtime = defineModuleRuntime({
|
||||
|
||||
@@ -34,7 +34,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
|
||||
const manifest =
|
||||
options.manifest ??
|
||||
JSON.stringify({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "example",
|
||||
name: "Example",
|
||||
version: "0.1.0",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 7,
|
||||
"manifestVersion": 8,
|
||||
"id": "example",
|
||||
"name": "Example",
|
||||
"version": "0.1.0",
|
||||
|
||||
Reference in New Issue
Block a user