Skip to content

Keep deleted pages in a 30-day trash, with Recently deleted and restore - #228

Open
SunkenInTime wants to merge 15 commits into
page-trash-client-firstfrom
page-trash
Open

SunkenInTime wants to merge 15 commits into
page-trash-client-firstfrom
page-trash

Conversation

@SunkenInTime

@SunkenInTime SunkenInTime commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #233 (base page-trash-client-first). Follows #215.

Merge order (both PRs say this)

  1. Merge Ask the server which images it still shows before dropping an upload #233 into icarus-cloud.
  2. Wait until Ask the server which images it still shows before dropping an upload #233's Deploy Web run has completed successfully: the Convex deploy and the Cloudflare Pages publish of the web build. Deploy Web cancels a running deploy when a newer push starts, so merging this PR early could cancel Ask the server which images it still shows before dropping an upload #233's web deploy after its Convex step.
  3. If GitHub has not retargeted this PR to icarus-cloud after 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.
  4. Merge this PR. Deploy Web turns the trash on: Convex first, then the web build with restore and Recently deleted.

The dev deployment (majestic-eel-413) is not deployed by CI. Redeploy it from icarus-cloud after each merge before pointing a dev build at it; this build sends arguments an older dev backend rejects.

What changes for users

  • Deleting a page on a cloud strategy moves it to a trash on the server for 30 days instead of erasing it.
  • Recently deleted. A new button in the pages bar's footer (cloud strategies, for people who can delete pages) opens the strategy's deleted pages. Each row shows the page's name, who deleted it and when ("Deleted by Sam, 2 days ago", or "Deleted 2 days ago" when that isn't known), how many days are left, and Restore. Restore puts the page back in its old place with everything on it. When nothing was deleted, the list says so.
  • A teammate who still had unsaved work on a page that gets deleted sees Tell the user when a teammate deletes the page holding their unsaved work #215's notice, now titled "This page was deleted", with Restore page and Discard changes. Restore brings the page back and their unsaved work saves normally.
  • Delete confirmation. On a cloud strategy it now ends "You can restore it from Recently deleted for 30 days." instead of "This action cannot be undone.", with or without teammates on the page. A local strategy has no trash and keeps the old line. With teammates on the page, the dialog names them (bold, in their cursor colour): "Alex is on this page right now. Delete it anyway?"
  • If a restore fails (offline, say), the row or notice says so and nothing changes. Past the 30 days, it says the page can no longer be restored.

Screenshots (widget test rendering the real dialogs with the app's full ShadThemeData from lib/main.dart; not the running app)

Recently deleted: you, Sam, and an unknown deleter Nothing deleted
list empty
Restoring Restore failed (offline)
restoring failed
Past the 30 days Where it lives: the pages bar's footer
gone entry
Delete, nobody else on the page Delete, a teammate on the page
delete delete teammate
Delete, two teammates The notice, renamed
delete two notice

A local strategy's delete dialog is unchanged ("This action cannot be undone."), covered by a test.

Server change

  • pages.deletedAt and pages.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_deletedAt and by_deletedAt.
  • Deleting (the page.delete op and the older pages:delete) sets both fields and renumbers the pages that remain. The page's content, settings and image references stay.
  • Trashed pages are hidden from every read: shell, full snapshot, page snapshot, page/element/lineup lists, the library's side label, duplicate, and the folder agent summary.
  • Changes to a trashed page or its content fail with a new code, 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) and deletedByYou.
  • maintenance:purgeTrashedPages runs as a daily cron. It purges pages past 30 days a few rows per run, rescheduling itself; images go to the existing reclaim queue.
  • Old tabs. strategy:getFullSnapshot refuses, with CLIENT_UPGRADE_REQUIRED, a client that doesn't send acceptsTrashedPagesLeftOut (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.
  • applyBatch takes an optional checkTrashedPageDeletes. 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.
  • Deleting a strategy handles only its live pages. Its trashed pages are left to the trash purge, so the number of purges scheduled stays within Convex's limit.

Why each PR is safe on its own

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

  • Restore, from the notice or from Recently deleted, re-sends the work the server refused while the page was in the trash, under new op ids (the server answers a known op id with its first answer). It first waits for that page's sends already on their way. Once the page's queued sends have gone, it re-sends what a lost answer's replay brought back refused.
  • From the notice, restore reports success only once the page is back on screen. A read that started before the restore can no longer undo it.
  • A page counts as deleted by this device only while this device's applied delete is newer than the last time the page was seen live, so a teammate's later delete of a restored page still shows the notice.
  • Presence remembers the page each teammate's cursor was last on, including after the pointer leaves the map.
  • The Recently deleted list refreshes its times each minute. A row whose 30 days run out while the list is open loses Restore.

Tests

  • npm run test:convex: 195 passed. convex/pageTrash.test.ts has 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.json was 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, and lib/collab/generated regenerated with tool/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 through convex run --identity as two users, Dara as owner and Sam as editor), at f3d998e:

    1. Sam deletes a page through ops:applyBatch.
    2. Dara's pages:listTrashed shows "Sam", deletedByYou: false, and exactly 30 days of retention.
    3. A flagless getFullSnapshot is refused with CLIENT_UPGRADE_REQUIRED; the flagged one answers without the page.
    4. An edit to the trashed page fails with PAGE_DELETED.
    5. pages:restore puts the page back in its place with its content.
    6. The refused edit, sent again under a new op id, applies.
    7. The older pages:delete records "Dara" with deletedByYou: true.
    8. A stranger gets FORBIDDEN.

    npm run snapshot:convex-contract:check against 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.

  • Before the split: 6 rounds plus migration passes, ending in "good to ship".
  • This round:
    • Feature review: no blocking findings. Four non-blocking findings were all fixed: the lost-answer retry on the list path, expiry in an open list, the listTrashed read range, and type roles.
    • Separate rollout, split and migration pass: two blocking findings, both fixed. The old-tab byte-loss path is now closed by the snapshot refusal, and this body is rewritten for the split. It also supplied the rollback guidance above.
    • Round 2, on both at f3d998e:
      • Features "good to ship"; the old-tab path is confirmed closed.
      • Its remaining rollout blocker was publishing these bodies, done here.
      • Two non-blocking findings were fixed: deleter reads are deduplicated, and a test now moves past a page's time while the list is open.
      • Also noted: the gauntlet tool (tool/convex_client_gauntlet) still reads full snapshots without the flag. It never creates trash, so it is unaffected until it does.
  • Greptile flagged the rollout gap on the first version; it is closed as described above.

For Dara

  1. Old tabs. A tab from before Ask the server which images it still shows before dropping an upload #233 that is still open once this deploys can't export, apply to all pages, or video-export a strategy that has trashed pages until it reloads. It is refused rather than given a snapshot that could make it drop image bytes. The old build's messages don't say to reload: export says "Try again". Apply-to-all-pages fails through the generic error path after the current page has already changed. The refusal ends once the strategy has no trashed pages. Say if you'd rather accept the byte-loss risk instead.
  2. When you delete a page yourself, there's no toast. The confirmation says where to restore it from. If you want a "Page deleted · Undo" toast, that's a follow-up.
  3. A deleted strategy's trashed pages stay until the purge removes them, up to 30 days, unreachable meanwhile.
  4. Days left count down whole days. A page deleted 2 days and 3 hours ago shows "27 days left".

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

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..."

SunkenInTime and others added 7 commits September 28, 2026 23:27
…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>
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 37 minutes.

Check out review usage here.

View limit details

Limit 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.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 95c91910-9b20-4ca9-a826-4f4e765f7f54

📥 Commits

Reviewing files that changed from the base of the PR and between 79e444e and 7e6e1a0.

⛔ Files ignored due to path filters (3)
  • lib/collab/generated/convex_error_codes.dart is excluded by !**/generated/**
  • lib/collab/generated/convex_models.dart is excluded by !**/generated/**
  • lib/collab/generated/icarus_convex_api.dart is excluded by !**/generated/**
📒 Files selected for processing (42)
  • convex/crons.ts
  • convex/elements.ts
  • convex/error_codes.json
  • convex/function_spec.json
  • convex/imageAssetLifecycle.test.ts
  • convex/images.ts
  • convex/lib/entities.ts
  • convex/lib/errors.ts
  • convex/lib/publicValidators.ts
  • convex/lib/strategyAgentSummary.ts
  • convex/lineups.ts
  • convex/maintenance.ts
  • convex/ops.ts
  • convex/pageTrash.test.ts
  • convex/pages.ts
  • convex/schema.ts
  • convex/strategies.ts
  • convex/strategy.ts
  • lib/collab/cloud_sync_error_message.dart
  • lib/collab/collab_models.dart
  • lib/collab/convex_strategy_repository.dart
  • lib/collab/presence/presence_models.dart
  • lib/collab/presence/presence_room.dart
  • lib/providers/collab/active_page_live_sync_provider.dart
  • lib/providers/collab/cloud_media_upload_queue_provider.dart
  • lib/providers/collab/remote_strategy_snapshot_provider.dart
  • lib/providers/collab/strategy_op_queue_provider.dart
  • lib/providers/strategy_page_session_provider.dart
  • lib/widgets/dialogs/confirm_alert_dialog.dart
  • lib/widgets/dialogs/delete_page_dialog.dart
  • lib/widgets/dialogs/deleted_page_dialog.dart
  • lib/widgets/dialogs/recently_deleted_dialog.dart
  • lib/widgets/pages_bar.dart
  • test/collab/cloud_sync_error_message_test.dart
  • test/collab/presence_room_test.dart
  • test/global_strategy_outbox_test.dart
  • test/remote_editor_snapshot_provider_test.dart
  • test/strategy_page_session_provider_test.dart
  • test/widgets/cloud_sync_button_test.dart
  • test/widgets/delete_page_dialog_test.dart
  • test/widgets/pages_bar_recently_deleted_test.dart
  • test/widgets/recently_deleted_dialog_test.dart

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@SunkenInTime

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment thread convex/strategy.ts
Comment thread convex/lib/strategyAgentSummary.ts
@greptile-apps

This comment has been minimized.

SunkenInTime and others added 2 commits September 29, 2026 08:10
…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
@SunkenInTime
SunkenInTime changed the base branch from icarus-cloud to page-trash-client-first September 29, 2026 13:25
SunkenInTime and others added 4 commits September 29, 2026 09:40
…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>
SunkenInTime added a commit that referenced this pull request Sep 29, 2026
… 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>
Comment thread lib/providers/strategy_page_session_provider.dart Outdated
… 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>
@SunkenInTime SunkenInTime changed the title Keep deleted pages in a 30-day trash, and restore one from the deleted-page notice Keep deleted pages in a 30-day trash, with Recently deleted and restore Sep 29, 2026
@SunkenInTime

Copy link
Copy Markdown
Owner Author

@greptileai review

@SunkenInTime

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant