/** * Cascade walker — finds every table in a schema with a `tenant_id` * column and topologically deletes rows for one tenant, leaf tables * first. * * Why introspect rather than enumerate? * * Schemas evolve every release. Hard-coding a tenant-cascade list * guarantees it drifts. Introspecting against `information_schema` * means the walker stays correct as the app adds tables, and the * only invariant the app needs to maintain is "tenant-scoped * tables have a `tenant_id` column". * * Topological ordering: * * The walker queries `pg_catalog` to build a directed graph of * foreign-key dependencies between the tenant-scoped tables, then * deletes leaves first so it never fights an FK constraint. Tables * that have a self-referential FK loop are detected and surfaced * as an error — the app must add the table to `skipTables` and * handle that case explicitly. * * Soft delete vs. hard delete: * * - `suspendTenant` / `archiveTenant` only mutate `tenants.status`. * Children stay intact, ready for restore. * - `hardDeleteTenant` runs the cascade. Irreversible. * * `tenants.status = 'archived'` is the public "this is going away" * signal. Operators typically wait 30-90 days before invoking the * hard delete; the package doesn't enforce that delay (compliance * regimes vary), but it's the recommended pattern. */ import type { Querier } from './types.js'; export interface CascadePlanEntry { /** Schema-qualified table name (`public.blog_posts`). */ tableName: string; /** Topological depth — 0 = leaf (deleted first). */ depth: number; /** Tables this table has FKs to among the tenant-scoped set. */ dependsOn: string[]; } export interface CascadeResult { /** Plan that was executed, in deletion order. */ plan: CascadePlanEntry[]; /** Per-table row counts deleted. */ counts: Record; /** Total wall-clock milliseconds. */ durationMs: number; } export interface CascadeOptions { tenantId: string; /** * Schema to introspect. Defaults to `public` — the only schema * most VentureKit apps use. */ schema?: string; /** * Tables to skip even when they have a `tenant_id` column. * Typical uses: * - Cross-tenant audit / billing tables where DELETE is REVOKE'd. * - The `tenants` table itself (handled separately at the end). * - Tables with self-referential FK cycles that need manual * handling. * * `tenants` is always implicitly skipped — the walker deletes the * row at the end as a final step. */ skipTables?: string[]; } /** * Plan the cascade WITHOUT executing it. Useful for dry-runs in the * admin UI ("about to delete N rows from M tables — confirm?"). * * The plan is deterministic given (schema, skipTables): re-running * it returns the same order. Tables with a self-FK cycle inside the * tenant-scoped set fail loud with a clear error. */ export declare function planCascade(querier: Querier, options?: Pick): Promise; /** * Execute the cascade. Deletes rows for one tenant from every * `tenant_id`-bearing table in `schema`, leaves first; finally * deletes the row from `tenants` itself. * * **Destructive.** Caller must have DELETE privileges on every * involved table. Apps typically wire this to a dedicated cron * Lambda running under a privileged maintenance role. * * Transaction semantics: * - Each table's delete runs in its own transaction by default, * so a failure mid-cascade leaves the DB in a partially-cleaned * state (re-running the cascade is idempotent — rows already * deleted simply don't match the WHERE). * - Apps that want all-or-nothing semantics wrap the call in * their own `withTransaction()` and pass a transaction-bound * querier in. */ export declare function executeCascade(querier: Querier, options: CascadeOptions): Promise; export interface UserDataOptions { /** The user's id as stored in `column` (a Cognito sub, a members.id, …). */ userId: string; /** * Column that marks a row as belonging to the user. Default `user_id`. * Apps whose ownership column differs (`member_id`, `created_by`) name * it here; call once per column if several apply. */ column?: string; /** Schema to introspect. Default `public`. */ schema?: string; /** * Tables to leave alone even though they carry the column — e.g. an * append-only audit log the regime requires you to KEEP, or a billing * ledger. For erasure these are the tables you anonymise instead. */ skipTables?: string[]; } export interface UserDataExport { userId: string; column: string; /** Rows per schema-qualified table, in introspection order. */ tables: Record[]>; /** Tables that were listed but skipped. */ skipped: string[]; exportedAt: string; } /** * Plan a per-user erasure: every table in `schema` with `column`, * children before parents. Same guarantees as {@link planCascade}. */ export declare function planUserErasure(querier: Querier, options?: Omit): Promise; /** * Erase one user's rows from every `column`-bearing table, leaves first. * The user's Cognito account and any row keyed by a DIFFERENT column * (e.g. `author_id`) are the caller's responsibility — run this once per * ownership column, then `adminDeleteUser`. * * **Destructive.** Wrap in `withTransaction()` and pass `tx.query` for * all-or-nothing semantics; otherwise re-running is idempotent. */ export declare function executeUserErasure(querier: Querier, options: UserDataOptions): Promise; /** * Export one user's rows from every `column`-bearing table — the raw * material for a data-portability response. Rows come back as stored * (`SELECT *`); the app decides which columns to redact (other people's * identifiers, internal flags) before handing the file over. * * Read-only; safe to run against a replica. */ export declare function exportUserData(querier: Querier, options: UserDataOptions): Promise; //# sourceMappingURL=cascade.d.ts.map