Ask the server which images it still shows before dropping an upload - #233
Conversation
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>
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (2)
📒 Files selected for processing (8)
Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe change adds a Convex query for referenced asset IDs, a client method that batches requests, and upload-queue checks that use those results instead of fetching full strategy snapshots. ChangesReferenced asset lookup
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~20 minutes Change: Bug fix Sequence Diagram(s)sequenceDiagram
participant UploadQueue as Cloud media upload queue
participant ReferenceLoader as CloudMediaReferenceLoader
participant Repository as ConvexStrategyRepository
participant Convex as images:listReferencedAssetIds
UploadQueue->>ReferenceLoader: Request references for asset IDs
ReferenceLoader->>Repository: Fetch referenced IDs
Repository->>Convex: Query batches of up to 100 IDs
Convex-->>Repository: Return referenced IDs or null
Repository-->>ReferenceLoader: Return combined IDs or null
ReferenceLoader-->>UploadQueue: Return lookup result
Merge Risk: ⚪ Minimal · up to The referenced-image lookup appears ready to merge after normal checks. Unavailable answers preserve staged uploads rather than treating their images as unreferenced. Security Architecture ReviewSecurity architecture risk: 🟡 Moderate · up to The new lookup limits the data fetched and defers deletion when the server cannot answer. Image retention still depends on the lookup’s accuracy and on completing this web deployment before the planned trash rollout. No introduced security vulnerability was established. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 2 files. (6 skipped: 6 unsupported.)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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 |
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>
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>
|
@coderabbitai review |
✅ Action performedReview finished.
|
| export const listReferencedAssetIds = query({ | ||
| args: { | ||
| strategyPublicId: v.string(), | ||
| assetPublicIds: v.array(v.string()), |
There was a problem hiding this comment.
PR #228 still calls images:listReferencedAssetIds with only strategyPublicId, including from its repository method and page-trash tests. Requiring assetPublicIds rejects those calls before the query runs, so the stacked client cannot check image references. Update PR #228’s callers and generated contract before merging the stack.
Artifacts
Higher-PR caller and candidate validator source
- Captured the executed git-show commands identifying the one-argument caller and the new required validator field, establishing the contract mismatch.
Query test against higher PR before the change
- Ran the higher PR’s query test against its own revision; the one-argument call passed.
Same query test against candidate PR after the change
- Ran the unchanged higher-PR test against the candidate revision; validation rejected the missing `assetPublicIds` field.
There was a problem hiding this comment.
Right that the stack must move together, and it already has. #228 (branch page-trash) merged this change in acdcb33: its repository method passes assetPublicIds in batches of 100, its tests pass them, and lib/collab/generated was regenerated from the updated function_spec.json. The one-argument calls you saw are at f3eec36, which predates that merge. No shipped client ever called the one-argument form: the query is new in this PR, and the live web build doesn't call it.
Comments Outside DiffThese findings could not be posted inline.
|
… 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>
| // This client checks image references apart, so it can take a | ||
| // snapshot without the pages in the server's trash. | ||
| acceptsTrashedPagesLeftOut: const ConvexOptional.present(true), | ||
| ) |
There was a problem hiding this comment.
Older servers reject snapshots
If this client connects to a deployment that has not yet accepted acceptsTrashedPagesLeftOut, fetchFullSnapshot sends an argument the server rejects and does not retry without it. Cloud strategy export, video export, and the all-pages settings action then cannot complete. Deploy the server contract first or handle the older contract.
Artifacts
Local version-skew validation command
- The authored command reconstructs the previous query from Git, runs both local contract checks and the client check, then removes the temporary copy; it provides a reproducible safe run.
Previous and current server contract test
- The authored test sends the same new-client arguments to the previous and current query validators; it isolates the version-skew difference.
Local contract test configuration
- The authored configuration selects only the version-skew test in the local Convex test environment; no deployment is involved.
Previous server rejects the new argument
- A local run against the previous query contract succeeded without the field and rejected the new-client request with an unexpected-field validator error; the legacy contract is incompatible.
Current server accepts the new argument
- A local run sent the same request to the current query and received a one-page snapshot; the updated contract accepts it.
- The authored test exercises `fetchFullSnapshot` against a transport returning the observed validation failure; it checks whether the client retries.
Dart repository propagates rejection after one attempt
- The executed Dart test records the sent arguments, propagated exception, and one query attempt; there is no fallback retry.
Complete local version-skew execution
- The captured command, working directory, exit code, and output cover both server-contract runs and the Dart retry check; all checks reproduced the compatibility failure without deployment.
There was a problem hiding this comment.
Production can't hit this: Deploy Web deploys Convex before it publishes the web build, and a cancelled run can leave a new server with the old web, never the reverse. This PR's server accepts the argument before any client sends it. The only backend that lags is the dev deployment (majestic-eel-413), which CI doesn't deploy. It would also be missing images:listReferencedAssetIds, so it has to be redeployed from icarus-cloud before a dev build uses it; both PR bodies say so. I haven't added a retry without the argument. The reason isn't that a flagless read would lose data: against a server that predates the trash it is harmless. It's that telling a validation error apart from other failures means matching error text, and the only case it covers is the dev deployment.
There was a problem hiding this comment.
That rollout constraint addresses the finding. Deploy Web publishes the Convex contract before the client bundle, so production cannot receive this client while still serving a server that rejects acceptsTrashedPagesLeftOut; a cancelled run can only leave the new server with the old client, which is compatible.
The lagging majestic-eel-413 deployment is an explicitly known dev-only case that also lacks images:listReferencedAssetIds and must be redeployed before using this build. Given that scope, adding a retry based on matching validation-error text would be brittle and is not necessary. I’m withdrawing this finding.
…re (#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>
Base
icarus-cloud. First half of the page trash; #228 is the second half and stacks on this one.Merge order
icarus-cloudif GitHub hasn't, check that it shows only its own changes, then merge it. That turns the trash on.What changes for users
Nothing today. Before the upload queue drops a pending image upload as no longer wanted, it now asks the server whether the strategy's content still shows that image. Before, it read the strategy's full snapshot and looked for the image there.
Why this ships first
#228 moves deleted pages to a 30-day trash. The trash hides them from the full snapshot, but their images are still wanted, because the page can be restored. A web build that still reads the snapshot can drop the bytes of an image that is still uploading when a teammate trashes its page. After a restore, that image shows as missing. Greptile and Astra both flagged this on #228. Convex deploys before the web build, so the live web build must stop reading the snapshot before the trash is on the server.
Server change (additive only)
images:listReferencedAssetIds({ strategyPublicId, assetPublicIds })(viewer role). It returns which of the asked images the strategy's content shows, looking each one up inassetReferencesthrough the strategy/asset/deleted index. Tombstoned content is left out.MAX_REFERENCED_ASSET_IDS_PER_QUERY), so it reads at most 100 rows however large the strategy is. It returns null until the reference backfill has finished; production finished it with Serve the web beta from the production Convex deployment #206.strategy:getFullSnapshottakes a new optional argument,acceptsTrashedPagesLeftOut, ignored here. It tells Keep deleted pages in a 30-day trash, with Recently deleted and restore #228's server that this client doesn't decide uploads from the snapshot. Keep deleted pages in a 30-day trash, with Recently deleted and restore #228 refuses, withCLIENT_UPGRADE_REQUIRED, a snapshot to any client that doesn't send it while the strategy holds trashed pages. A tab from before this PR's deploy can then fail an export until it reloads, but it can't drop image bytes: its upload check keeps them when the read fails.Client change
ConvexStrategyRepository.fetchFullSnapshotalways sendsacceptsTrashedPagesLeftOut: true(export, apply-to-all-pages, video export, share-link reads all go through it).ConvexStrategyRepository.fetchReferencedAssetIdsasks in batches of 100. If any batch can't tell, the whole answer is null.Correct on its own, before and after #228
data.id, a lineup link'simages[*].id), the reference rows and the snapshot name the same images. Astra found two edge differences:idin a lineup payload, so an upload whose id equalled some lineup's entity id was kept by accident. The references count only screenshots.Left for Dara
Review
Static review by Codex (GPT-6 Astra), STATIC ONLY.
Tests (at 79e444e)
npm run test:convex: 175 passed, including the newconvex/referencedAssetIds.test.ts(5, the fifth a full snapshot read with the new argument):flutter test --no-pub: 1356 passed, 5 skipped. New tests:test/collab/referenced_asset_ids_test.dart: asks in batches of 100, deduplicated; a null batch makes the whole answer null; full snapshots are read withacceptsTrashedPagesLeftOut.test/cloud_media_upload_queue_provider_test.dart: an upload whose image the server still names is kept, with its bytes, even when the full snapshot doesn't show it. A staged upload keeps its job and bytes and is not sent while the server can't tell.Both queue tests fail when the queue goes back to reading the full snapshot (checked by swapping the loader back).
npx tsc --noEmit,npm run audit:convex-contract(46 public functions, 38 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.lib/collab/generatedwas regenerated withtool/icarus_convex_codegen.🤖 Generated with Claude Code
Summary by CodeRabbit
Do not merge until snapshot requests work with older deployments, or the updated server contract is deployed before this client.
Findings
Summary
The upload queue now asks the server which images remain referenced instead of relying on a full snapshot. Snapshot requests also send a new argument that older server deployments reject, preventing snapshot-dependent actions during a client/server rollout.
Reviews (2) · Last reviewed commit: "Say, when reading a full snapshot, that ..."