Where deleted things go
A customer deletes their homepage. It vanishes from their site immediately. It is not gone.
The page is sitting in a table, intact, with a thirty-day clock on it, and if the customer wants it back on day twenty-nine they get it back exactly as it was. That table went into Giant Context before the delete button did, which is the part worth copying.
Why the bin comes first
A platform holds other people's work, and other people make mistakes. They delete the wrong page, empty a folder they meant to keep, remove a form the week before they needed its submissions. A single-user tool can maybe afford to take delete literally. A platform cannot, because the cost of being wrong is somebody else's business.
There is also an expectation to meet. Everyone who has used a computer knows what a trash bin is and assumes one exists, so a delete button that skips the bin makes a product frightening to use.
Recoverable delete sits in the same tier of platform decisions as access control and localization, the foundations you build early because retrofitting them means touching everything. Adding a bin after the fact means finding every place that destroys something, and by then some of those places have already run.
One table for everything deleted
When the customer deletes that page, the page does not get a flag set on it. It gets picked up intact and moved.
Every deletable entity in a project, a page, a media file, a form, a branding, a template, lands in one shared table when it is deleted:
The trash table
CREATE TABLE core.project_trash ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES core.projects(id), entity_type VARCHAR(50) NOT NULL, -- 'page', 'file', 'form', ... entity_id UUID NOT NULL, -- the original id source_table VARCHAR(255) NOT NULL, -- where to put it back entity_data JSONB NOT NULL, -- the whole row, as it was name VARCHAR(255) NOT NULL, -- denormalized, for fast listing thumbnail_url VARCHAR(500), trash_batch_id UUID, -- groups one cascading delete deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_by UUID REFERENCES core.users(id), expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '30 days'), CONSTRAINT unique_trash_item UNIQUE (entity_type, entity_id));The delete reads the whole row, writes it into entity_data, and then removes it from its own table. After that the trash holds the only copy.
entity_data and source_table together are the design. The trash stores rows as data rather than modelling them, so it does not need to know what a page is, or a form, or a file. Adding a new deletable thing costs nothing here, because there is nothing here shaped like the old ones.
A few display fields are copied out alongside the blob, so the bin can list a hundred deleted items without deserializing a hundred JSON documents.
Deleting a folder deletes what is in it
One delete can produce many trashed rows. A folder takes its files and its subfolders with it, and every row from that one action carries the same trash_batch_id.
The batch matters on the way back. Restoring a folder's contents in the wrong order means inserting a subfolder whose parent does not exist yet, and the foreign key refuses it. So a batch restore puts folders back before files, and sorts the folders so a parent always precedes its children.
Restore one of those items on its own and you can hit the same problem. The batch is what makes the reverse of a cascading delete a single, ordered operation.
The thirty-day clock
Every item carries an expiry thirty days out, and a job at three each morning takes anything past its date. Thirty days covers a Friday mistake noticed on Monday.
Purging is more work than removing the row. A trashed file also has an object in cloud storage, every resized variant of that object, and embeddings in the vector store the AI searches. Deleting only the trash row leaves all three behind, which means storage nobody is accounting for and a deleted document still able to answer questions. So the purge job destroys an item according to what type it is, and the row goes last.
The schedule is declared on the job itself and extracted at deploy rather than configured separately, so what runs and when it runs cannot drift apart.
Restore or purge
From the trash table a deleted page goes one of two ways.
If the customer restores it, the snapshot is turned back into an insert. The keys of entity_data become the column list, its values become the row, and deleted_at and deleted_by are dropped on the way through, which is what makes the restored row live again. Then the trash row is removed.
The restore and permanent-delete endpoints are generated from the API's schema like every other endpoint, so every entity type that can be trashed gets a working restore without my writing one per type.
If the clock runs out first, the purge job removes it, and that is the only place in the platform where customer content is destroyed. A scheduled job does the deleting, thirty days after the button, to the items nobody came back for.
Build the trash first
Delete is a state, not an act. The button marked delete should move an item to a holding place with a clock on it, and real destruction should be slow, scheduled and boring, happening well after the human has changed their mind.
Build the bin before the button. The alternative is a support ticket that begins with I didn't mean to and has no good ending, and thirty days to change your mind costs a table and a cron job.
Building something like this
I'm Jesse. I build platforms end to end, and I'm open to work. If this is the kind of engineering you need, get in touch.