Tenant Purge
tenancy.purge erases one organization — every organization-firewalled table, the R2 objects its storage keys reference, and optionally the Better Auth organization — from a list the compiler derives from the firewall.
tenancy.purge gives operator actions a ctx.tenancy.purge(organizationId, { dryRun })
that erases one organization's data. You don't keep the table list. The
compiler derives it from the firewall, so a table added next month is covered
on the next compile.
import { defineConfig, defineRuntime, defineDatabase, defineAuth, defineFileStorage } from "@quickback/compiler";
export default defineConfig({
name: "events-app",
template: "hono",
features: { organizations: true },
providers: {
runtime: defineRuntime("cloudflare"),
database: defineDatabase("cloudflare-d1"),
auth: defineAuth("better-auth", { plugins: { organization: true, admin: true } }),
fileStorage: defineFileStorage("cloudflare-r2", { bucketName: "events-app-media" }),
},
bindings: {
r2Buckets: [{ binding: "MEDIA", bucketName: "events-app-media" }],
},
tenancy: {
purge: { auth: true, storage: true },
},
});Add keep for tables a purge must leave alone — billing ledgers you are
required to retain, for example:
tenancy: {
purge: { auth: true, storage: true, keep: ["billing_*"] },
},| Option | Default | Effect |
|---|---|---|
auth | false | Also remove the organization, its members and its invitations through Better Auth's adapter (model names, not raw SQL — the same three deletes Better Auth's own deleteOrganization runs). Needs the organization plugin. |
storage | true | Delete every R2 object a purged row's q.storageKey column references. |
keep | [] | Table-name globs (*, ?) to leave untouched. |
Calling it
ctx.tenancy exists only in actions whose access requires a userRole:
a node with userRole, an and with such an arm, or an or whose every arm
has one. Reading ctx.tenancy anywhere else fails the compile
(TENANCY_ACCESS) and is typed never.
// quickback/features/tenants/actions/purgeTenant.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Purge one organization's data.",
path: "/tenants/purge",
method: "POST",
input: z.object({ organizationId: z.string(), dryRun: z.boolean() }),
access: { userRole: ["sysadmin"] },
async execute({ ctx, input }) {
return ctx.tenancy.purge(input.organizationId, { dryRun: input.dryRun });
},
});dryRun is required. Pass true to count and false to delete. The result:
{
"organizationId": "org_abc",
"dryRun": true,
"tables": { "media": 12, "events": 3 },
"objects": 12,
"refusedObjects": 0,
"kept": ["billing_accounts"],
"auth": { "organization": 1, "members": 4, "invitations": 0 }
}What it deletes, in order
- Objects. For each purged table with a storage key, it collects the
org's keys and deletes them through the column's R2 binding. A key that is
not under its own row's prefix is counted in
refusedObjectsand never deleted, so a tampered row cannot make the purge delete another tenant's object. Objects go first so that a failed delete leaves the rows in place for a retry. - Rows. It deletes the organization's rows from every table whose
firewall carries a conjunctive organization arm (
q.scope("organization"), or{ field, equals: 'ctx.activeOrgId' }). This includes soft-deleted rows. Tables are ordered from their foreign keys, so each child is deleted before the table it references. - Better Auth (
auth: true): members, invitations, then the organization.
The purge does not touch tables outside that set. That covers owner-scoped
tables, exception tables, tables reached only through a non-organization
scope, the audit and webhook databases, and the managed FILES_DB. Give a
table an organization column if a tenant's purge should cover it.
What the compiler refuses
| Code | Cause |
|---|---|
TENANCY_PURGE_KEEP_UNMATCHED | A keep glob matches no organization-firewalled table (usually a typo). |
TENANCY_PURGE_EXTERNAL_FK | A table the purge leaves behind (kept, or with no organization firewall) references a purged table without onDelete: 'cascade' or 'set null'. Deleting the parent would violate the key. |
TENANCY_PURGE_KEEP_CASCADE | A kept table references a purged one with onDelete: 'cascade', so the purge would empty it anyway. |
TENANCY_PURGE_CYCLE | Foreign keys among purged tables form a cycle. There is no order to delete them in. |
TENANCY_PURGE_ORG_COLUMNS | A table is firewalled by two different organization columns. |
TENANCY_PURGE_AUTH | auth: true without the Better Auth organization plugin. |
TENANCY_PURGE_PROVIDER | The database is not cloudflare-d1. On Postgres the request handle is RLS-scoped to the caller, so a purge would see none of the target tenant's rows. Postgres support is not built yet. |
The purge writes no security-audit row of its own. Log the call from your action if you need one.