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 }); }