What Ghost Deletion Erases
POST /users/me/delete-account queues a row, and UserDeletionWorker runs it on the next minute tick. The local half is UsersService.ghostDelete, and every write below happens in one transaction, so an account is never left half-erased.
| Table | What erasure does to it |
|---|---|
users |
Row kept. email becomes deleted- plus the first 16 hex characters of SHA-256 over the user id, at @ghost.local; name becomes Deleted User; image and email_verified are cleared; is_admin goes false. created_at stays, so a deleted account still counts as a signup. |
sessions |
Deleted, so every session the user had stops working at once. This is not a sign-in ban: the original address now matches no row, so signing in with it again runs AuthAdapterService.createUser and gets a brand new account, carrying none of the old one's data. Nothing in the adapter refuses a previously deleted address. |
accounts |
Deleted, along with the OAuth provider tokens they hold. |
verification_tokens |
Deleted, matched on the original address. |
magic_link_attempts |
Deleted. Its primary key is the cleartext normalized email, so it cannot be left to the hourly TTL sweep. The same table also holds ip:<address> rows for the per-IP magic-link cap; those are not tied to any user, so deletion leaves them to the TTL sweep. |
email_jobs |
Rows kept, to rewritten to the ghost address, subject set to Deleted, html and last_error cleared. Queue stats survive without holding the address or the message. |
items |
Deleted. name and description are free text the user typed, so the demo module's rows go with the account. This is the pattern a feature module copies. |
The worker runs three steps in this order, and the order is load-bearing. First ghostDelete, above. Then BillingService.deleteCustomerData, which returns immediately when billing is dormant or the user has no Stripe customer, and otherwise asks Stripe to delete the user's customer (both, if their billing_customers and billing_subscriptions rows name different ones), which is also what cancels a live subscription. Only then does it delete the user_deletion_queue row, fenced on the claim it holds. Clearing the queue row last is what makes a Stripe failure retryable: the row survives the failure, so the next tick claims it and runs both halves again.
The users row is kept on purpose. A real DELETE would take billing_subscriptions and billing_customers with it through their FKs' cascade, null out blog_posts.published_by and lose the authorship silently, and fail outright against user_deletion_queue, whose foreign key has no delete rule at all. Ghosting leaves every reference something to resolve to. What survives in that row is id, created_at and updated_at untouched, plus the five overwritten fields above. Whether that residue still counts as personal data where you operate is a judgement the template cannot make for you.
What it does not erase
Every other table in the schema, and every table you add:
blog_posts, whosepublished_byis left pointing at the ghosted author. Posts are admin-authored public content, not the reader's own data, so deleting an author's account unpublishes nothing.billing_subscriptions, which holds plan, status and Stripe ids rather than anything the user wrote. Deletion never writes it. If billing is on and the user has astripe_customer_id, deletion asks Stripe to delete that customer, and thecustomer.subscription.deletedwebhook that follows drops the row toplan=free, status=canceled. With billing dormant, or no customer yet,deleteCustomerDatareturns without calling Stripe and the row stands as it is.billing_customers, a user id and the Stripe customer their checkouts run under. Deletion never writes it: with billing on,deleteCustomerDatadeletes that customer in Stripe and the row is left pointing at it.billing_webhook_events, from which deletion removes nothing. Where it does reach Stripe, the cancellation webhook that follows adds a row, carrying the deleted user'suser_idand a free-textdetail. The hourly cleanup worker sweeps those at 30 days, so on a billing deploy an account deletion can leave a new user-keyed row behind it for up to a month. Shorten that retention if it is too long for your obligations.billing_disputed_customers, kept on purpose because the person may come back. Billing has the reasoning and the SQL to drop it alongside the account.billing_payment_hook_claims, a Stripe event id and a timestamp with nothing user-linked in it, swept at 30 days alongside the audit rows.- Every table your own modules add.
ghostDeletewrites a fixed list of tables. One it has never heard of is untouched, whatever it holds.
backend/src/users/deletion-policy.ts states this per table, and deletion-policy.spec.ts fails on a table that states nothing, so the list above cannot quietly go stale as the schema grows.
An onDelete('CASCADE') on a foreign key to users does not change any of this. Cascades fire on DELETE, and the account-deletion path never deletes the users row, so the cascade is not what cleans up after an erasure request. Declare the cascade anyway, so a hand-run delete stays consistent, but do not read it as a deletion strategy.
So "GDPR account deletion" out of the box means the identifying fields on the account record, everything that authenticates it, and the user's own items. The moment you store personal data in a table of your own, erasing it is a decision you have to make and wire in: Adding a Module has the step and the code.