Esposter

Recycle Bin

Deleting a resource marks it rather than destroying it. /resources/recycle-bin lists what you deleted, restores it, or destroys it for good, and a timer sweeps anything older than 30 days.

How it works

A Sheet or a Survey can hold hours of work behind a single Delete button. The type-the-name guard makes that click deliberate, but deliberate and correct are different things — an undo window costs one column.

Deleting sets deletedAt and drops the publication row. The content blob and the {id}/ directory survive untouched; that is precisely what makes restore possible. Every read already funnels through createResourcesWhere, so excluding the bin is one predicate, and getOwnerProcedure rejects soft-deleted ids so a binned resource's page 404s. Restore and purge need the opposite, so both take the guard's isDeletedOnly mode.

Publication removal is deliberate: restore returns a Draft. Silently resurrecting a public URL because someone undid a delete would be surprising in the worst direction, so the notification says "restored as a draft" rather than letting the owner discover it later.

stateDiagram-v2
  state "Draft / Published" as Live
  state "Soft-deleted — deletedAt set,<br/>publication removed, blob kept" as Bin
  Live --> Bin: deleteResource / deleteResources
  Bin --> Live: restoreResource (returns as Draft — re-publish is manual)
  Bin --> [*]: purgeResource (blob dir → table partitions → row)
  Bin --> [*]: PurgeDeletedResources timer (deletedAt < now − 30d)

The purge protocol

purgeResource in @esposter/db is shared by the procedure and the timer, and its ordering is the whole design: blob directory, then dependent Azure Table partitions, then the Postgres row last.

The row is the durable marker. A partial failure leaves it behind, so the next pass re-drives the whole sequence from the top; blob and table deletes treat already-gone as success, which is what makes replaying safe. Delete the row first and a failure halfway through orphans a blob directory nobody will ever look for again.

The timer purges per resource rather than as one batch, so one poisoned resource cannot block the sweep — its row survives and the next tick retries it, while everything else is already gone. That is also why its per-resource failures log instead of rethrowing: a rethrow would strand the resources this pass already purged behind a retry of the whole batch.

Data model

resources.deletedAt is a nullable timestamp — null is live. No migration was needed: metadataSchema already gives every table createdAt, updatedAt, and deletedAt, so soft delete was a column the table already had.

RECYCLE_BIN_RETENTION_MS (30 days) lives in @esposter/db-schema — browser-safe, so both the app UI (the "purges in {n} days" column, the delete dialogs) and the purge timer can import the one value from the same source.

Procedures

ProcedureAuthInputPurpose
<type>.deleteResource / resource.deleteResourcesownerunchangedSet deletedAt, delete the publication row
resource.readDeletedResources / countDeletedResourcesauthedpaginationThe caller's own bin list
resource.restoreResourceowner (deletedOnly){ id }Clear deletedAt, append a Restored entry
resource.purgeResourceowner (deletedOnly){ id }Hard delete blob dir, activity, then the row

Key files

FileRole
packages/db/src/services/resource/purgeResource.tsThe shared, retry-ordered purge protocol
packages/db-schema/src/services/resource/constants.tsRECYCLE_BIN_RETENTION_MS
server/trpc/routers/resource.tsBin/restore/purge + the where predicate
server/trpc/procedure/resource/getOwnerProcedure.tsSoft-delete guard + isDeletedOnly mode
packages/azure-functions/src/functions/purgeDeletedResources.tsDaily 30-day timer sweep
app/pages/resources/recycle-bin.vueThe bin page
app/composables/resource/list/useReadResourcesPage.tsThe paged reader it shares with /all

Notes

  • Delete confirmations say the resource moves to the bin for 30 days; the post-delete toast offers Restore directly, so the common undo never needs a trip to the bin. A bulk delete links to the bin instead — restoring twelve things one toast button at a time is not an undo.
  • Purge keeps the type-the-name guard. It is the only destroy that is now real. The bin reads through the same useReadResourcesPage as /all, so paging quickly or refreshing mid-read can never leave the table showing an earlier page's rows — a purge fired from a stale row is unrecoverable in a way a stale list elsewhere is not.
  • Dataset references to a soft-deleted source fail exactly as they did under hard delete (dangling dataset references); restore heals them.
  • Names are not unique, so a restore can never conflict.
  • The activity log partition survives the bin window — history outlives the delete, and restore appends Restored to it. The sweep runs at purge time, before the row goes.