chore(openspec): archive trash-permanent-delete-and-empty and sync main specs
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See `proposal.md` - Why. Relevant current state:
|
||||
|
||||
- `photos.Service.PermanentDelete` (backend/internal/photos/service.go:592) resolves
|
||||
authorization via `albums.Access(albumID, userID)` before `HardDelete`. A photo
|
||||
whose album was deleted has `album_id = NULL`; `Access("")` returns
|
||||
`album_not_found`, so the delete can never proceed. `Restore` already special-cases
|
||||
the missing album (`album_required`), but `PermanentDelete` does not.
|
||||
- The trash listing (`photos.Store.ListTrash`) is capped at `LIMIT 200` for display,
|
||||
while `TrashCount` is uncapped. A bulk purge must not silently stop at 200.
|
||||
- Object cleanup after `HardDelete` is per-photo: `PhotoObjectIDs` then
|
||||
`MarkObjectDeleting` for orphans, with a background worker removing blobs.
|
||||
- The trash screen (frontend/src/routes/(app)/trash/+page.svelte) has per-photo
|
||||
actions only, and `ConfirmDialog` is already available for confirmations.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Permanently deleting an albumless trashed photo works for its uploader.
|
||||
- One action empties the trash for everything the caller may delete, regardless of the
|
||||
200-item display cap, and reports the number removed.
|
||||
- Consistent object garbage collection for every removed photo.
|
||||
|
||||
**Non-Goals:**
|
||||
- Trash retention/auto-expiry policies.
|
||||
- Changing trash visibility rules (`ListTrash`) or the 200-item display cap.
|
||||
- Set-based SQL bulk deletion in this change.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Authorize albumless permanent deletion by uploader
|
||||
When `p.AlbumID == ""`, `PermanentDelete` SHALL fall back to `p.UploaderID == userID`
|
||||
for authorization instead of calling `albums.Access`. When an album is present, the
|
||||
existing album-role check is unchanged. This mirrors `Restore`, which already treats a
|
||||
missing album as a distinct path, and keeps the permission model identical: only a
|
||||
photo's uploader can remove a photo that no longer belongs to an album.
|
||||
|
||||
- Alternative considered: require the client to always supply a target album for
|
||||
delete. Rejected - permanent deletion has no target album, and the trash legitimately
|
||||
holds albumless photos.
|
||||
|
||||
### Empty trash as a server-side operation on the trash resource
|
||||
Add `DELETE /trash` returning `{ "deleted": <n> }`, backed by
|
||||
`photos.Service.EmptyTrash(ctx, userID)`. It iterates the caller's accessible trashed
|
||||
photos and calls the existing `PermanentDelete` for each, counting successes and
|
||||
skipping photos the caller may not delete. Reusing `PermanentDelete` keeps authorization
|
||||
and object GC identical to single-photo deletion.
|
||||
|
||||
- Alternative considered: client enumerates trash and calls `POST /photo-batch/...`
|
||||
with every id. Rejected - the display list is capped at 200 and this is racy; the
|
||||
server already knows the full accessible set.
|
||||
- Alternative considered: a single `DELETE FROM photos ... WHERE <authorization>`. Rejected
|
||||
for now - album-role authorization lives in Go (`roles.CanDelete*`), and per-photo object
|
||||
GC is required anyway.
|
||||
|
||||
### Bypass the 200-item display cap for purging
|
||||
`EmptyTrash` must see more than the display page. Add a store method that returns all
|
||||
accessible trashed photo identities (id, album_id, uploader_id) with no display cap, and
|
||||
authorize each in the service. This avoids looping on a capped list (which would also
|
||||
risk an infinite loop when some items are not deletable).
|
||||
|
||||
- Alternative considered: paginate `ListTrash` until a pass removes nothing. Rejected -
|
||||
undeletable items would reappear and a zero-progress pass is needed to stop; the
|
||||
uncapped identity listing is simpler and bounded by the user's own trash size.
|
||||
|
||||
### Frontend: explicit action plus confirmation
|
||||
Add an «Очистить корзину» control on the trash screen header, enabled when the list is
|
||||
non-empty. It opens the existing `ConfirmDialog`; on confirm it calls `DELETE /trash`,
|
||||
then reloads the list and refreshes `trash` store count. Show a busy state while running.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Large trash makes the per-photo loop slow] -> Each `PermanentDelete` is a small fixed
|
||||
number of queries; bulk deletes are O(n) round trips. Acceptable for now; a set-based
|
||||
purge can replace the loop later without changing the spec behavior. Keep the frontend
|
||||
busy state to make latency legible.
|
||||
- [Uncapped listing memory] -> The query returns only the caller's trashed photo
|
||||
identities; bounded in practice by trash size. Revisit with chunking if it grows.
|
||||
- [Partial failure mid-purge] -> `EmptyTrash` skips failures and returns the count
|
||||
deleted; the remaining items stay in the trash and can be retried.
|
||||
- [Albumless deletes and realtime] -> `PermanentDelete` only publishes when `AlbumID != ""`;
|
||||
albumless removals publish nothing, which is correct since no album is watching.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No schema change. Deploy backend and frontend together; the new endpoint is additive and
|
||||
the authorizaton fix is behavior-only. Rollback is reverting the deploy.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None blocking.
|
||||
Reference in New Issue
Block a user