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 (
|
||||
|
||||
Reference in New Issue
Block a user