import type { DashboardConfiguration } from "./store.js"; /** * One module as the Dashboard needs to present it: its identity, its administrator-visible name, * and nothing about where it lives on disk. * * `name` is the module's own `manifest.name` — its product identity, which every manifest has * already required since version 1. The Dashboard renders it rather than the module id, and a module * declaring it is *not* the module declaring its navigation: a name answers "what is this product * called", while a group answers "where does this church want it", and only the second is * configuration an administrator owns. */ export interface DashboardNavigationModule { readonly id: string; readonly name: string; } export interface DashboardNavigationGroupView { /** The stable key, unchanged by a rename. Callers use it to address the group, never to display. */ readonly id: string; readonly label: string; readonly order: number; readonly modules: readonly Module[]; } export interface DashboardNavigationView { readonly groups: readonly DashboardNavigationGroupView[]; /** * Modules that belong to no group: those an administrator deliberately placed outside every group, * and those never configured at all — a newly installed module, most importantly. Both render * here, which is what guarantees a new module is visible the moment it is discovered, with no * initialization write, no manual database edit, and no migration. */ readonly ungrouped: readonly Module[]; } /** Placed modules first, in the administrator's order; never-configured modules after them, by id. * A newcomer therefore appears without interrupting an ordering someone chose, and the module id is * always the final tie-break, so two modules sharing a `sortOrder` still order identically on every * request and in every process. */ interface SortKey { readonly configured: 0 | 1; readonly order: number; readonly id: string; } function compareSortKeys(left: SortKey, right: SortKey): number { if (left.configured !== right.configured) { return left.configured - right.configured; } if (left.order !== right.order) { return left.order - right.order; } return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; } /** * Resolves stored Dashboard configuration against the modules that are actually mountable right now. * * Pure and synchronous: no database, no filesystem, no clock, no module code. It is given the stored * arrangement and the current module set and returns what to render, so every rule below is * directly testable without PostgreSQL. * * ## The rules, exactly * * - **A module with no placement is ungrouped and visible.** Nothing is written to make that true. * Installing a module is enough to see it; an administrator moves it afterwards if they want to. * - **A stored placement for a module that is not installed renders nothing, and is retained.** The * store keeps the row, so uninstalling and reinstalling a module restores its position rather than * silently resetting it. A dead navigation entry never appears. * - **A placement naming a group that no longer exists cannot occur** — the schema's * `on delete set null` makes the module ungrouped when its group is deleted — but if one somehow * did, it is treated as ungrouped rather than dropped, so no module can be made invisible by a * configuration inconsistency. * - **Empty groups are kept.** A group an administrator created and has not filled yet, or whose * only module is temporarily uninstalled, still renders; hiding it would look like the group had * been deleted. * - **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)`. Colliding * order values never produce a nondeterministic result. * * ## What this function structurally cannot do * * It never reads a manifest, a dependency, a capability, an entrypoint, or a permission, and it * returns no edge between two modules. Two modules sharing a group are exactly as related as two * modules that do not: grouping is a rendering decision computed here, downstream of everything that * decides what a module *is* (ADR 0027). Nothing upstream — discovery, dependency ordering, runtime * startup, authorization — ever calls this. */ export function resolveDashboardNavigation( configuration: DashboardConfiguration, mountableModules: readonly Module[], ): DashboardNavigationView { const placementsByModuleId = new Map( configuration.placements.map((placement) => [placement.moduleId, placement] as const), ); const groupIds = new Set(configuration.groups.map((group) => group.id)); const modulesByGroupId = new Map>(); const ungrouped: Array<{ key: SortKey; module: Module }> = []; for (const module of mountableModules) { const placement = placementsByModuleId.get(module.id); const key: SortKey = { configured: placement ? 0 : 1, id: module.id, order: placement?.sortOrder ?? 0, }; const groupId = placement?.groupId !== undefined && placement.groupId !== null && groupIds.has(placement.groupId) ? placement.groupId : undefined; if (groupId === undefined) { ungrouped.push({ key, module }); continue; } const bucket = modulesByGroupId.get(groupId); if (bucket) { bucket.push({ key, module }); } else { modulesByGroupId.set(groupId, [{ key, module }]); } } const sortModules = (entries: Array<{ key: SortKey; module: Module }>): readonly Module[] => Object.freeze( [...entries] .sort((left, right) => compareSortKeys(left.key, right.key)) .map((entry) => entry.module), ); const groups = [...configuration.groups] .sort((left, right) => left.sortOrder !== right.sortOrder ? left.sortOrder - right.sortOrder : left.id < right.id ? -1 : left.id > right.id ? 1 : 0, ) .map((group) => Object.freeze({ id: group.id, label: group.label, modules: sortModules(modulesByGroupId.get(group.id) ?? []), order: group.sortOrder, }), ); return Object.freeze({ groups: Object.freeze(groups), ungrouped: sortModules(ungrouped), }); }