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

* Keep deleted pages in a trash so a teammate's unsent work can be restored

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>

* Read only live pages' content, and keep old clients' trashed-page deletes 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>

* Restore holds up against late reads and a page that comes back

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>

* Late reads and replayed deletes cannot undo a restore

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>

* Send again what a lost answer's replay brings back refused

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>

* Only a page delete the server applied counts as this device's

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>

* A page seen live after this device's delete is not deleted here

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>

* Show teammates' names in bold, in their cursor colour, in the delete warning

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Ask the server which images it still shows before dropping an upload

The upload queue decided whether a pending upload was still wanted by
reading the strategy's full snapshot. It now asks the new
images:listReferencedAssetIds query: every image the server's content
shows, read from the reference rows, or null while the reference
backfill has not finished (the queue then keeps the upload and asks
again). Nothing changes for users today.

This ships before the page trash (#228). The trash hides deleted pages
from the full snapshot while their images are still wanted, so a client
still reading the snapshot could drop the bytes of an image on a page
that can be restored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test the uncertain answer on a staged upload, the job it holds

A durable upload may upload while the server cannot tell (nothing the
user made is lost that way); a staged one keeps its check, its job and
its bytes, and never uploads. The test now covers the staged case, where
the answer decides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Ask only about the images in question, a bounded batch at a time

listReferencedAssetIds read every live reference row of the strategy,
so a strategy with tens of thousands of image references (thousands of
lineups sharing screenshots) would pass Convex's read limit and never
get an answer. It now takes the asset ids the client holds uploads for,
at most 100 per call, and looks each up through the strategy/asset/
deleted index. The repository asks in batches of 100; if any batch
cannot tell, the answer is null.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* List recently deleted pages, with who deleted them, and restore from 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>

* Keep the new tests' constants const and imports lean

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say, when reading a full snapshot, that the trash's pages may be left 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>

* Refuse a trash-blind snapshot to old tabs, and tighten Recently deleted

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>

* Read each deleter once, and test Restore going away while the list is 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>

* Offer Undo after deleting a cloud page; lead Recently deleted rows with the name

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Undo: go when the strategy closes, re-check it's the same strategy, open the page only once it's listed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Undo: pin the strategy before the delete, check after every wait, say only what's true

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Undo: check the strategy after the confirmation, recheck the list before claiming it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Undo: a failed restore after leaving names the strategy the page is in

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Undo: say when a page can no longer come back; name no strategy when unknown

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say a restore couldn't be confirmed, not that it failed: its reply can be lost

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Undo's unconfirmed restore points to where to look instead of guessing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Offer Undo only for a delete that applied; re-send refused edits whenever their page is live again

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Don't re-send during Use cloud or Keep mine; catch up once a strategy finishes opening

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say which choice the re-send guard covers

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Show Recently deleted only when a page is in it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Try a failed trash read again so Recently deleted comes back online

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read the trash again when its first page's 30 days run out

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
97dd6c463eDara Adedeji committed on 10/1/2026, 12:54:55 AM· committed by GitHubparentd56ab72
48 files changedLine totals unavailable