Accepted
- Roadmap item (
README.md): "Soft-delete for items + trash (and recover) feature + housekeeping to clean up later". - ADR-0006 deferred soft-delete: "There is no undo/soft-delete… would need revisiting".
- Hard delete is unforgiving — a misclicked category cascades to all its items and their images.
- Drizzle has no equivalent to EF Core global query filters, so "default reads exclude deleted" must be enforced by other means or it will rot.
- Add nullable
deleted_at TEXTtocategoryandtier_list_item.NULL= active; ISO-8601 datetime = soft-deleted at that moment. Type matchescreated_at/updated_at(ADR-0009). Soft-delete writesdatetime('now')via SQL so the format is identical to the existing timestamp columns. - Add nullable
deleted_with_cascade INTEGER(boolean) totier_list_item. Set to1when an item is soft-deleted as part of a category cascade,NULLotherwise.restoreCategorymatches on this flag — not ondeleted_atequality — so a same-millisecond standalone trash and cascade can never collide. - Replace
category_slug_uniqueanditem_category_slugwith partial unique indexes scoped todeleted_at IS NULL, so a slug can be reused while the original sits in trash. - Existing
tier_list_item.category_idON DELETE CASCADEFK kept — still correct for permanent delete.
-
src/lib/server/db/schema.tsexports flip:Name Kind category,tierListItemsqliteView('*_active')filteringdeleted_at IS NULL— default read pathcategoryTable,tierListItemTableunderlying sqliteTable— required for writes; opt-in for trash reads -
db.insert/update/deleteonly acceptSQLiteTable— TypeScript fails any write that targets a view, so writes can't silently bypass the filter. -
db.select().from(view)works unchanged → existing read sites auto-filter with no per-call edits. -
Reading deleted rows requires importing
*Table— a deliberate, greppable signal.
- Soft-delete a category: app code, single transaction. The category gets
deleted_at = datetime('now'); each currently-active child item gets the same timestamp anddeleted_with_cascade = 1. - Restore a category: clears
deleted_aton the category and on items wheredeleted_with_cascade = 1(anddeleted_at IS NOT NULL); also clears the flag. Items the user trashed independently keep their state. - An earlier draft used timestamp equality alone to link cascaded items to the category; a unit test caught the millisecond-collision bug, so we added the explicit flag column. Easier to reason about than disambiguating timestamps.
- Triggers rejected: would have to coordinate with the
_suppress_updated_atcontract; app-code cascade is clearer and unit-testable.
permanentlyDeleteCategory: walk all items in the category (including already-trashed),deleteImage()each, thendb.delete(categoryTable)— FK CASCADE drops item rows.permanentlyDeleteItem:deleteImage()thendb.delete(tierListItemTable). Same pattern as today.
- Stay on disk while soft-deleted. Cleanup happens only on permanent delete (current
deleteImage(hash)fromsrc/lib/server/images.ts, per ADR-0011). - Justified by ADR-0011's 8–15 KB per image and small expected data sets.
- Admin "Delete" buttons → "Move to Trash". Simple
ConfirmDialog(no typed gate — restore is cheap). - New
/admin/trashroute lists soft-deleted categories and items with Restore (simple confirm) and Delete forever (typed-confirmation per ADR-0021 — irreversible). - Slug conflicts on restore: block with a friendly error in
loader.error. No auto-rename. - "Trash" added to admin nav after "Items (all)".
- Light-touch alternative to an automated cleanup job: a reusable
<AdminWarning>banner appears on the admin dashboard and at the top of the trash page when any soft-deleted row'sdeleted_atis older thanSTALE_TRASH_DAYS(60). Counts come fromcountStaleTrash()insoft-delete.ts. - Nudge, not enforcement — the user decides when to permanently delete. Avoids a background scheduler in a single-process self-hosted app.
- "Empty trash" bulk action.
- Soft-delete for
page/site_setting/user. - Auto-rename on slug conflict during restore.
- Toast/notification system.
- Positive: Mistakes are recoverable. Default read path is provably filtered (TypeScript-enforced and asserted by unit tests against the view). Slug reuse works while the old record sits in trash. Image storage cost stays small (cleanup only on permanent delete). Reuses existing primitives —
ConfirmDialog,Button,deleteImage(),admin-loader.deleted_atstays a clean ISO timestamp (no embedded marker), so future cleanup jobs can range-query it directly. - Negative: Two names per entity (
categoryvscategoryTable) — small ergonomic cost. Drizzle Kit re-emits the view's column list on every schema change; that's why migration0004_add_cascade_flag.sqlships aDROP VIEW+CREATE VIEW. Trigger-cascade option not taken; if a future write path skips the helpers it could leave orphaned cascade state — mitigated by funnelling all soft-deletes throughsrc/lib/server/db/soft-delete.ts. Slug-conflict-on-restore is a hand-resolved error, not a smart auto-rename. - Neutral: Schema change is additive (columns + indexes + views); existing data unaffected. Sets
deleted_atup for a future cleanup job without committing to one now.