Quickback Docs

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.

quickback/quickback.config.ts
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_*"] },
},
OptionDefaultEffect
authfalseAlso 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.
storagetrueDelete 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

  1. 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 refusedObjects and 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.
  2. 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.
  3. 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

CodeCause
TENANCY_PURGE_KEEP_UNMATCHEDA keep glob matches no organization-firewalled table (usually a typo).
TENANCY_PURGE_EXTERNAL_FKA 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_CASCADEA kept table references a purged one with onDelete: 'cascade', so the purge would empty it anyway.
TENANCY_PURGE_CYCLEForeign keys among purged tables form a cycle. There is no order to delete them in.
TENANCY_PURGE_ORG_COLUMNSA table is firewalled by two different organization columns.
TENANCY_PURGE_AUTHauth: true without the Better Auth organization plugin.
TENANCY_PURGE_PROVIDERThe 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.

On this page