Keep deleted pages in a 30-day trash, with Recently deleted and restore - #228
SunkenInTime wants to merge 15 commits into
Conversation
…ored Deleting a page now moves it to a trash on the server (pages.deletedAt): hidden from every read and refusing changes (PAGE_DELETED), with its content kept for 30 days, then purged. pages:restore brings it back at its place. The deleted-page notice offers Restore page next to Discard; the work refused while the page was gone is sent again under new op ids. The delete confirmation says who presence last saw on the page. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…etes a no-op From Astra's review of the migration: - Live pages come from an index on (strategyId, deletedAt), and full snapshots and strategy-wide element and lineup lists read content page by page, so thirty days of trash never count against a live read. - A delete of content on a trashed page is refused only for clients that send checkTrashedPageDeletes (this one); older clients get the no-op they got when the content was purged. - A replayed rejection shows no current copy of content in the trash. - Deleting a strategy leaves its trashed pages to the trash purge, so the number of scheduled purges stays bounded by its live pages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From Astra's review of the client: - A page deleted from this device stops counting as deleted here once a read at a later strategy revision lists it again (restored), so a teammate's later delete of it shows the notice. - A read of the shell and page that a newer read or page selection overtook changes nothing, and a watched shell older than the one held is ignored: a read from before a restore cannot undo it. - Restore fails honestly if sends already made for the page never answer, and reports success only if the notice really cleared. - Discard works once the page is back: it shows the server's copy. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From Astra's second review: - A delete answered on replay (noop at the strategy's current revision) no longer outlives a restore at that same revision: a page listed at the delete's revision or later is back. - A refresh drops a shell older than the one held, as the live read already did, so it cannot hide a page the live read just restored. - A page read that a newer selection overtook no longer reports its failure over the newer state. - Element and lineup lists read only their own table. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From Astra's third review: a send whose answer was lost waits, queued, to be replayed under its own op id, and the server answers the replay with the refusal it recorded while the page was in the trash. Restore now waits for the page's sends to be answered after its flush and sends such refusals again. Sends still unanswered after the wait keep saving as usual; a late refusal among them shows in the sync status. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From Astra's fourth review: a delete replayed after its answer was lost is answered with a no-op at the strategy's current revision, which may come after a teammate restored the page and deleted it again. A no-op only says the page was already gone, not by whom, so it no longer marks the page as deleted here; the teammate's delete shows the notice. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From Astra's fifth review: an applied delete's ack is published only after the outbox write, so it can reach the session after reads showing a teammate's restore and second delete. The session now remembers the latest revision each page was seen live, and counts a delete as this device's only while it is newer than that, whichever order the ack and the reads arrive in. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reachedNext included review available in 37 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (3)
📒 Files selected for processing (42)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
|
This comment has been minimized.
This comment has been minimized.
…warning Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # convex/images.ts # convex/strategy.ts # lib/collab/convex_strategy_repository.dart # lib/providers/collab/cloud_media_upload_queue_provider.dart # lib/providers/collab/remote_strategy_snapshot_provider.dart # test/cloud_media_upload_queue_provider_test.dart # test/remote_editor_snapshot_provider_test.dart
…there Deleting a page records who deleted it (pages.deletedBy, optional, set by the page.delete op and the older pages:delete; cleared on restore). The new query pages:listTrashed (editor role, like delete and restore) lists the strategy's restorable pages, newest first, with the deleter's stored display name, whether it was you, and when the page is purged. A Recently deleted button in the pages bar's footer (cloud strategies, for those who can delete pages) opens a dialog listing them: "Deleted by Sam, 2 days ago · 28 days left", or "Deleted 2 days ago" when the deleter is not known, with Restore. Restore puts the page back where it was and sends again the changes the server refused while it was in the trash. The delete confirmation now says a cloud page can be restored from Recently deleted for 30 days; a local page still cannot be undone. The deleted-page notice is titled "This page was deleted". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # convex/images.ts # lib/collab/convex_strategy_repository.dart # lib/providers/collab/cloud_media_upload_queue_provider.dart
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… out getFullSnapshot takes an optional acceptsTrashedPagesLeftOut, ignored for now; this client sends it. Once the server keeps deleted pages in a trash (#228), it can refuse a snapshot to a client that does not send it while the strategy holds trashed pages: such a client decides from the snapshot whether an upload is still wanted, and would drop the bytes of an image on a page that can be restored. A refused read already keeps the bytes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
getFullSnapshot now refuses, with CLIENT_UPGRADE_REQUIRED, a client that does not send acceptsTrashedPagesLeftOut (#233) while the strategy holds trashed pages. Such a client decides from the snapshot whether an upload is still wanted, and would drop the bytes of an image on a page that can be restored; its upload check keeps the bytes when the read fails, and a reload brings the current build. From Astra's review of Recently deleted: - Restoring from the list waits for the page's queued sends too, and sends again what a lost answer's replay brings back refused. - A page whose time runs out while the list is open loses Restore; the list refreshes its times every minute. - listTrashed reads only the restorable range of the index, one read per deleter. - Rows use the body and label type roles and the spacing steps. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… open listTrashed started a read per page before deduplicating; it now reads each deleter once. Recently deleted reads the time through package:clock, so a widget test can move past a page's time while the list is open and see Restore go. The outbox test's keys are const. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@greptileai review |
|
@coderabbitai review |
|
Stacked on #233 (base
page-trash-client-first). Follows #215.Merge order (both PRs say this)
icarus-cloud.icarus-cloudafter Ask the server which images it still shows before dropping an upload #233 merges, retarget it by hand. It must show only this PR's changes.The dev deployment (
majestic-eel-413) is not deployed by CI. Redeploy it fromicarus-cloudafter each merge before pointing a dev build at it; this build sends arguments an older dev backend rejects.What changes for users
Screenshots (widget test rendering the real dialogs with the app's full
ShadThemeDatafromlib/main.dart; not the running app)A local strategy's delete dialog is unchanged ("This action cannot be undone."), covered by a test.
Server change
pages.deletedAtandpages.deletedBy(a user id), both optional. Absent means live, so every existing row is valid as it is, and no backfill is needed. Two new indexes:by_strategyId_and_deletedAtandby_deletedAt.page.deleteop and the olderpages:delete) sets both fields and renumbers the pages that remain. The page's content, settings and image references stay.PAGE_DELETED.pages:restore(editor, same as delete) puts the page back at its old place, or last if fewer pages remain. It clears both fields, and returns NOT_FOUND once the 30 days are up.pages:listTrashed(editor) lists the restorable trashed pages, newest first, reading only the restorable range of the index. Each entry has the deleter's stored display name (null when unknown or the placeholder name) anddeletedByYou.maintenance:purgeTrashedPagesruns as a daily cron. It purges pages past 30 days a few rows per run, rescheduling itself; images go to the existing reclaim queue.strategy:getFullSnapshotrefuses, withCLIENT_UPGRADE_REQUIRED, a client that doesn't sendacceptsTrashedPagesLeftOut(added in Ask the server which images it still shows before dropping an upload #233), but only while the strategy holds trashed pages. A web build from before Ask the server which images it still shows before dropping an upload #233 decides from that snapshot whether an upload is still wanted. Without the trash's pages it would drop the bytes of an image on a page that can still be restored. Its upload check keeps the bytes when the read fails, and a reload brings the current build. In such a tab, export, apply-to-all-pages and video export of that strategy fail until the reload. Found by Astra's rollout pass.applyBatchtakes an optionalcheckTrashedPageDeletes. Clients that send it (this one) get a delete of content on a trashed page refused, so they can send it again after a restore. Older clients get the no-op they got when purged content was gone.Why each PR is safe on its own
PAGE_DELETED. The old client keeps them in attention; its sync popover calls it a conflict, and Tell the user when a teammate deletes the page holding their unsaved work #215's notice offers OK/discard. It has no restore UI until it reloads.getFullSnapshotrefusal above.main, which has no cloud sync.Rollback
Don't redeploy the pre-trash schema. Convex refuses it once rows carry
deletedAt/deletedBy, and older server code would treat trashed pages as live. To back out the feature, ship a forward commit that keeps this server (schema, trash filtering, the accepted arguments) and reverts the web UI. If the purge misbehaves, turn off its cron and make the handler return early in a forward fix. Pages already purged, and their reclaimed image bytes, can't be recovered.Client
Tests
npm run test:convex: 195 passed.convex/pageTrash.test.tshas 20 tests: soft delete, hidden everywhere, every refused op, the old-client no-op, restore (place, content, retries landing, idempotent, permissions, never-existed), purge only after retention (with rescheduling), old clients' reorder and add, duplicate, strategy delete, and replayed rejections. It also covers the old-tab snapshot refusal, and a Recently deleted block (who deleted what, "you", unknown deleters, expiry, and permissions).npx tsc --noEmit;npm run audit:convex-contract(48 public functions, 39 error codes).convex/function_spec.jsonwas hand-edited in its sorted format, as Refuse lineup links whose origin or landing is gone #209/Refuse deleting a lineup origin or landing a live link still names #213 did, andlib/collab/generatedregenerated withtool/icarus_convex_codegen.flutter test --no-pub: 1399 passed, 5 skipped, at 7e6e1a0 (the head). New in this round:test/widgets/recently_deleted_dialog_test.dart(9): labels, list, empty, load failure and retry, restoring, failed, gone, and expiry while open.test/widgets/pages_bar_recently_deleted_test.dart(2): the entry opens the list for editors; people who can't delete don't see it.test/widgets/delete_page_dialog_test.dart: the cloud and local copy.test/strategy_page_session_provider_test.dart, four Recently deleted tests: re-sending after sends answer, sends that never answer, gone/failed, and a lost answer's replay sent again.Each new test was checked to fail without its fix.
End to end on a real local Convex backend (
npx convex deployment create …:local,npx convex dev, calls throughconvex run --identityas two users, Dara as owner and Sam as editor), at f3d998e:ops:applyBatch.pages:listTrashedshows "Sam",deletedByYou: false, and exactly 30 days of retention.getFullSnapshotis refused withCLIENT_UPGRADE_REQUIRED; the flagged one answers without the page.PAGE_DELETED.pages:restoreputs the page back in its place with its content.pages:deleterecords "Dara" withdeletedByYou: true.FORBIDDEN.npm run snapshot:convex-contract:checkagainst that backend passed.Not run: the app itself against a live deployment. The Recently deleted UI needs a signed-in editor, which a local backend can't provide through the app's Discord sign-in. The UI is covered by widget tests and the screenshots above.
Review
Static review by Codex (GPT-6 Astra), STATIC ONLY.
tool/convex_client_gauntlet) still reads full snapshots without the flag. It never creates trash, so it is unaffected until it does.For Dara
🤖 Generated with Claude Code
No outstanding blocking findings remain.
Summary
The PR adds 30-day page trash, restoration, and a Recently deleted view. No new issues were identified.
Reviews (5) · Last reviewed commit: "Read each deleter once, and test Restore..."