/** * Acting on every capability-declared table without naming any of them * (openspec/changes/capability-owned-tables, design D3). * * "This row dies with its consumer" had five implementations for one rule: an FK * cascade for `web_routes`, two functions core imported BY NAME from * `consumer-cleanup.ts` for `port_forwards` and `trusted_sources`, a second * table pruned on read for `dns_registrations`, a cascade on both provider and * consumer for `dns_internal_records`, and a hook rewriting a JSON blob for the * rest. `consumer-cleanup.ts` exists specifically to delete that kind of * per-capability special-casing. It succeeded for the hook dispatch and then * grew two hardcoded imports for the rows, because the rows had no generic rule. * * This is the generic rule. The declaration names the claim column, so core no * longer has to know that three tables spell it `module_id`, `registered_by` and * `consumer_module_id`. */ import { CAPABILITY_DECLARED_TABLES, allDeclaredTables } from '@celilo/capabilities'; import { type Column, eq, is } from 'drizzle-orm'; import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core'; import type { DbClient } from '../db/client'; import * as dbSchema from '../db/schema'; export interface ClaimedRowTarget { capability: string; /** Physical table name. */ table: string; /** The column holding the consumer's module id. */ claimColumn: string; } /** * Physical table name → the drizzle table object, for the tables celilo can * actually act on. Built by walking the schema rather than by a hand-written * map, so a table cannot be declared and then silently unreachable. */ function schemaTables(): Map { const out = new Map(); for (const value of Object.values(dbSchema)) { if (!is(value, SQLiteTable)) continue; out.set(getTableConfig(value).name, value); } return out; } /** * What a consumer's removal must clear. Pure — the list is a property of the * declarations, so it is worth being able to assert without a database. * * A declaration naming a table that does not exist is skipped here rather than * throwing. `policy/capability-shape-drift.test.ts` Scan A is what fails on * that, loudly and at build time; a removal is the wrong moment to discover it, * and refusing to clear the OTHER tables because one declaration is wrong would * strand real rows. */ export function planClaimedRowDeletion(): ClaimedRowTarget[] { const tables = schemaTables(); return allDeclaredTables() .filter(({ declaration }) => tables.has(declaration.table)) .map(({ capability, declaration }) => ({ capability, table: declaration.table, claimColumn: declaration.claim.column, })) .sort((a, b) => a.table.localeCompare(b.table)); } /** * Delete every declared row this consumer claimed. * * Called AFTER every provider has been told the consumer is going (its D4): a * provider's `on_consumer_removed` is a full converge, and converging from a set * the rows had already been removed from would withdraw more than the departing * consumer's share. * * `web_routes` and `dns_internal_records` also carry an FK cascade, so their * rows would go anyway once the `modules` row is deleted. Doing it here as well * is deliberate and not merely harmless: it makes the ORDER explicit rather than * a consequence of when some other statement happens to run, and it is the same * point in the sequence at which `port_forwards` and `trusted_sources` were * already being cleared by hand. */ export function deleteClaimedRows(db: DbClient, consumer: string): ClaimedRowTarget[] { const tables = schemaTables(); const cleared: ClaimedRowTarget[] = []; for (const target of planClaimedRowDeletion()) { const table = tables.get(target.table); if (!table) continue; const column = getTableConfig(table).columns.find((c) => c.name === target.claimColumn); if (!column) continue; db.delete(table) .where(eq(column as Column, consumer)) .run(); cleared.push(target); } return cleared; } /** Capability names that declare at least one table — for logs and reports. */ export function capabilitiesWithDeclaredTables(): string[] { return Object.keys(CAPABILITY_DECLARED_TABLES).sort(); }