import { asc, eq } from "drizzle-orm"; import type { Pool } from "pg"; import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import { dashboardSchema, ekklesiaCoreDashboardGroup, ekklesiaCoreDashboardPlacement, } from "./schema.js"; export type DashboardDrizzleDatabase = NodePgDatabase; /** * A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`: * it never constructs, registers an error listener on, or closes a pool. The caller owns the pool * once, however many typed views it creates over it. */ export function createDashboardDrizzleView(pool: Pool): DashboardDrizzleDatabase { return drizzle(pool, { schema: dashboardSchema }); } /** Bounds every stored ordering value. Wide enough that an administrator never runs out of room * between two entries, small enough that a client cannot store a value that surprises a later * integer column. Enforced identically by the store and by the HTTP surface, so there is one bound * rather than two that could drift. */ export const DASHBOARD_SORT_ORDER_MIN = 0; export const DASHBOARD_SORT_ORDER_MAX = 1_000_000; /** The label an administrator sees. Bounded like every other operator-facing string in the * platform; `moduleManifestSchema.name` uses the same ceiling. */ export const DASHBOARD_GROUP_LABEL_MAX_LENGTH = 120; /** Lowercase kebab-case, matching `moduleIdSchema`'s grammar so the platform has one identifier * shape rather than two. Opaque: never displayed, never parsed for meaning, never re-derived from a * label. */ const GROUP_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; const GROUP_ID_MAX_LENGTH = 64; /** The one identifier grammar for both a group id and a placement's module id, exported so the HTTP * boundary rejects malformed input with a request error *before* reaching the store rather than * relying on mapping a store failure back to a status code — and so there is one grammar rather * than two that could drift. */ export function isValidDashboardIdentifier(value: unknown): value is string { return ( typeof value === "string" && value.length <= GROUP_ID_MAX_LENGTH && GROUP_ID_PATTERN.test(value) ); } /** The one label rule, exported for the same reason. Returns the trimmed value the store would * persist, or `undefined` when the label is empty, whitespace-only, or too long. */ export function normalizeDashboardGroupLabel(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length === 0 || trimmed.length > DASHBOARD_GROUP_LABEL_MAX_LENGTH ? undefined : trimmed; } export type DashboardStoreErrorCode = | "DASHBOARD_GROUP_ID_INVALID" | "DASHBOARD_GROUP_LABEL_INVALID" | "DASHBOARD_GROUP_NOT_FOUND" | "DASHBOARD_MODULE_ID_INVALID" | "DASHBOARD_SORT_ORDER_INVALID" | "DASHBOARD_READ_FAILED" | "DASHBOARD_WRITE_FAILED"; const storeErrorMessages: Readonly> = Object.freeze({ DASHBOARD_GROUP_ID_INVALID: "A Dashboard group identifier must be lowercase kebab-case.", DASHBOARD_GROUP_LABEL_INVALID: "A Dashboard group label must be non-empty and bounded.", DASHBOARD_GROUP_NOT_FOUND: "The Dashboard group was not found.", DASHBOARD_MODULE_ID_INVALID: "A Dashboard placement module identifier is invalid.", DASHBOARD_READ_FAILED: "The Dashboard navigation configuration could not be read.", DASHBOARD_SORT_ORDER_INVALID: "A Dashboard ordering value is out of range.", DASHBOARD_WRITE_FAILED: "The Dashboard navigation configuration could not be saved.", }); /** Never carries the underlying database error, SQL text, host, or stack — matching * `AuthorizationStoreError` exactly. */ export class DashboardStoreError extends Error { readonly code: DashboardStoreErrorCode; constructor(code: DashboardStoreErrorCode) { super(storeErrorMessages[code]); this.name = "DashboardStoreError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } /** A stored group. `label` is what an administrator sees; `id` is the stable key placements point * at, and a rename never touches it. */ export interface DashboardGroupRecord { readonly id: string; readonly label: string; readonly sortOrder: number; } /** Where one module sits. `groupId: null` is a real, chosen state — deliberately ungrouped — and is * distinct from having no record at all, which means never configured. */ export interface DashboardPlacementRecord { readonly moduleId: string; readonly groupId: string | null; readonly sortOrder: number; } /** The complete stored arrangement. Contains no module facts whatsoever: resolving it against the * modules that actually exist is `resolveDashboardNavigation`'s job, not the store's. */ export interface DashboardConfiguration { readonly groups: readonly DashboardGroupRecord[]; readonly placements: readonly DashboardPlacementRecord[]; } export interface UpsertDashboardGroupInput { readonly id: string; readonly label: string; readonly sortOrder: number; } export interface SetDashboardPlacementInput { readonly moduleId: string; /** `null` places the module in the ungrouped area deliberately. A group id that does not exist is * rejected rather than stored, so a placement can never point at a group that was never created. */ readonly groupId: string | null; readonly sortOrder: number; } export interface DashboardStore { /** The whole stored arrangement, in deterministic order. Small by nature — one row per group and * at most one per module — so it is read whole rather than paginated. */ readConfiguration(): Promise; /** Creates the group or replaces its label and order. Idempotent: the same input twice leaves the * same single row, and placements pointing at it are untouched either way. */ upsertGroup(input: UpsertDashboardGroupInput): Promise; /** Deletes a group. Its placements become ungrouped through the schema's `on delete set null`, so * no dangling reference is possible and no module is lost. */ deleteGroup(groupId: string): Promise; /** Creates or replaces one module's placement. */ setPlacement(input: SetDashboardPlacementInput): Promise; } function assertValidGroupId(value: string): void { if (!isValidDashboardIdentifier(value)) { throw new DashboardStoreError("DASHBOARD_GROUP_ID_INVALID"); } } function assertValidModuleId(value: string): void { if (!isValidDashboardIdentifier(value)) { throw new DashboardStoreError("DASHBOARD_MODULE_ID_INVALID"); } } function normalizedLabel(value: string): string { const trimmed = normalizeDashboardGroupLabel(value); if (trimmed === undefined) { throw new DashboardStoreError("DASHBOARD_GROUP_LABEL_INVALID"); } return trimmed; } function assertValidSortOrder(value: number): void { if ( !Number.isSafeInteger(value) || value < DASHBOARD_SORT_ORDER_MIN || value > DASHBOARD_SORT_ORDER_MAX ) { throw new DashboardStoreError("DASHBOARD_SORT_ORDER_INVALID"); } } function rethrowAsStoreError(error: unknown, code: DashboardStoreErrorCode): never { if (error instanceof DashboardStoreError) { throw error; } throw new DashboardStoreError(code); } /** * Core's persistence for Dashboard navigation configuration. * * Validation lives here rather than only at the HTTP boundary, so the invariants hold for any * caller: identifiers are checked against one grammar, labels are trimmed and bounded, ordering is * a bounded safe integer, and a placement may only name a group that exists. Nothing here reads, * imports, or reasons about a module — a placement is a `module_id` string and no more — which is * what keeps grouping structurally incapable of influencing discovery, dependency resolution, or * startup order. */ export function createDashboardStore(db: DashboardDrizzleDatabase): DashboardStore { return Object.freeze({ async readConfiguration(): Promise { try { const [groups, placements] = await Promise.all([ db .select({ id: ekklesiaCoreDashboardGroup.id, label: ekklesiaCoreDashboardGroup.label, sortOrder: ekklesiaCoreDashboardGroup.sortOrder, }) .from(ekklesiaCoreDashboardGroup) .orderBy(asc(ekklesiaCoreDashboardGroup.sortOrder), asc(ekklesiaCoreDashboardGroup.id)), db .select({ groupId: ekklesiaCoreDashboardPlacement.groupId, moduleId: ekklesiaCoreDashboardPlacement.moduleId, sortOrder: ekklesiaCoreDashboardPlacement.sortOrder, }) .from(ekklesiaCoreDashboardPlacement) .orderBy( asc(ekklesiaCoreDashboardPlacement.sortOrder), asc(ekklesiaCoreDashboardPlacement.moduleId), ), ]); return Object.freeze({ groups: Object.freeze(groups.map((group) => Object.freeze({ ...group }))), placements: Object.freeze(placements.map((placement) => Object.freeze({ ...placement }))), }); } catch (error) { rethrowAsStoreError(error, "DASHBOARD_READ_FAILED"); } }, async upsertGroup(input: UpsertDashboardGroupInput): Promise { assertValidGroupId(input.id); const label = normalizedLabel(input.label); assertValidSortOrder(input.sortOrder); try { // A rename updates `label` and `sort_order` only. `id` is the conflict target, so it is // never rewritten and every placement pointing at this group keeps pointing at it. const [row] = await db .insert(ekklesiaCoreDashboardGroup) .values({ id: input.id, label, sortOrder: input.sortOrder }) .onConflictDoUpdate({ set: { label, sortOrder: input.sortOrder, updatedAt: new Date() }, target: ekklesiaCoreDashboardGroup.id, }) .returning({ id: ekklesiaCoreDashboardGroup.id, label: ekklesiaCoreDashboardGroup.label, sortOrder: ekklesiaCoreDashboardGroup.sortOrder, }); if (!row) { throw new DashboardStoreError("DASHBOARD_WRITE_FAILED"); } return Object.freeze(row); } catch (error) { rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED"); } }, async deleteGroup(groupId: string): Promise { assertValidGroupId(groupId); try { const deleted = await db .delete(ekklesiaCoreDashboardGroup) .where(eq(ekklesiaCoreDashboardGroup.id, groupId)) .returning({ id: ekklesiaCoreDashboardGroup.id }); if (deleted.length === 0) { throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND"); } } catch (error) { rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED"); } }, async setPlacement(input: SetDashboardPlacementInput): Promise { assertValidModuleId(input.moduleId); assertValidSortOrder(input.sortOrder); if (input.groupId !== null) { assertValidGroupId(input.groupId); } try { // Checked in the application rather than left to the foreign key so the caller gets // `DASHBOARD_GROUP_NOT_FOUND` instead of a driver error. The foreign key remains the // authority: a group deleted concurrently still fails the insert rather than storing a // dangling reference. if (input.groupId !== null) { const existing = await db .select({ id: ekklesiaCoreDashboardGroup.id }) .from(ekklesiaCoreDashboardGroup) .where(eq(ekklesiaCoreDashboardGroup.id, input.groupId)) .limit(1); if (existing.length === 0) { throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND"); } } const [row] = await db .insert(ekklesiaCoreDashboardPlacement) .values({ groupId: input.groupId, moduleId: input.moduleId, sortOrder: input.sortOrder, }) .onConflictDoUpdate({ set: { groupId: input.groupId, sortOrder: input.sortOrder, updatedAt: new Date() }, target: ekklesiaCoreDashboardPlacement.moduleId, }) .returning({ groupId: ekklesiaCoreDashboardPlacement.groupId, moduleId: ekklesiaCoreDashboardPlacement.moduleId, sortOrder: ekklesiaCoreDashboardPlacement.sortOrder, }); if (!row) { throw new DashboardStoreError("DASHBOARD_WRITE_FAILED"); } return Object.freeze(row); } catch (error) { rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED"); } }, }); }