Skip to content

fix(presigned-url): no downloadUrl for a file that is not uploaded - #1971

Open
theothersideofgod wants to merge 2 commits into
mainfrom
fix/download-url-live-files
Open

theothersideofgod wants to merge 2 commits into
mainfrom
fix/download-url-live-files

Conversation

@theothersideofgod

@theothersideofgod theothersideofgod commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Why

File lifecycle invariant S2 (constructive-db docs/architecture/file-upload-and-processing.md §6, 38ef654d5e0): "Only uploaded/processed rows are live: dedup, lists, collect_storage_usage, downloadUrl, and the file-ref projection guard." #1952 made dedup read liveness from status (LIVE_FILE_STATUSES / isLiveFileRow in file-lifecycle.ts), but downloadUrl still hands out a URL whatever the status. A rejected file (its bytes disagreed with the declared type) still downloads with GET 200, and a requested file whose PUT never ran gets a URL that answers 404 NoSuchKey.

Change

The plan reads status and returns null unless isLiveFileRow({ status }), before any database read or signing. null is what the field already returns when the bucket was never provisioned.

Behavior changes

  • downloadUrl for requested and rejected rows. Was: a URL (404 for requested, the rejected bytes for rejected). Now: null. Why: only uploaded/processed rows stand for bytes a reader can GET.
  • A file that was just PUT is still requested until the confirm job runs (confirm_upload_delay, 30 seconds by default). Was: a URL that usually worked once the PUT finished. Now: null until confirm moves it to uploaded.
  • A slow PUT that finishes after the confirm window is retried by the queue with growing backoff (about 3, 7, 20, 55, 148, 403 s; fail_jobs_for_worker.sql:76-77), so its downloadUrl can stay null for minutes.
  • Field description gains "Null until the upload is confirmed (status uploaded or processed), and for a rejected file." The committed SDK copies of the description (sdk/constructive-sdk/schemas/storage.graphql, sdk/constructive-{sdk,react,cli}/src/storage/orm/input-types.ts) change on the next regen; the type is unchanged.

Existing tenants (R7)

Files tables provisioned before constructive-db #996 (merged 2026-05-04) have status text with the values pending / ready / processing / failed (generator at d202622175d, storage_module.sql:387-393), not the file_status enum. After this PR their ready files get downloadUrl: null, because only uploaded/processed count as live. Dedup already treats those rows the same way since #1952 (same isLiveFileRow). No backfill or compatibility mapping here.

Question for the owner: are there tenants in production with the pre-#996 files table? If so, should ready/processing count as live, or will those tenants be rebuilt?

Needs a follow-up in constructive-db

When constructive-db picks up this plugin, these integration tests upload and PUT a file, then assert a non-null downloadUrl without confirming the upload first, so they will fail (read from the code, not run): application/app/__tests__/storage/app-storage.integration.test.ts:865, :916, and entity-storage-security.integration.test.ts:566, :579, :640, :739, :1361. They need to confirm the row first, as confirmUploadedAsLane already does in app-storage.integration.test.ts:400. The e2e storage-pipeline.spec.ts:661-665 already expects no downloadUrl on a rejected row.

Before / after

Base: origin/main f1f197863. cd graphql/server-test && STORAGE_ACCESS_KEY_ID=… STORAGE_SECRET_ACCESS_KEY=… pnpm exec jest __tests__/download-url-status.integration.test.ts

  • Before (test commit only): Tests: 2 failed, 2 passed, 4 total. returns no download URL for a requested file and ... for a rejected file fail: both receive signed URLs.
  • After: Tests: 4 passed, 4 total. Plugin suite 83 passed; the four server-test files using the changed fixture, 63 passed. tsc --noEmit and eslint src exit 0.

Cluster end to end (local kind cluster, GraphQL image = an older deployed pin plus this fix, swapped in and back): before, the rejected file's downloadUrl answered GET 200 with the rejected bytes and the requested file's answered 404; after, both null; processed and uploaded files kept their URLs (GET 200, same content).

The simple-seed-storage fixture gains app_buckets_physical_name(bucket_id), copying the generator's reader (constructive-db storage_module.sql:1721-1750); no test queried downloadUrl before, so the fixture never needed it.

Rules touched: S2, R7.

🤖 Generated with Claude Code

theothersideofgods and others added 2 commits November 6, 2026 15:52
A files row is written before its presigned PUT runs, so a `requested`
row may name an object that never arrives, and a `rejected` row names
bytes the confirm job refused. Neither should hand a reader a GET URL.
Today both get a signed URL.

The storage fixture gains the generator's `app_buckets_physical_name`
reader, which downloadUrl calls; no existing server test selected
downloadUrl, so the fixture never needed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
downloadUrl signed a GET for every files row with a key and a
provisioned bucket, whatever its status. A rejected file's URL served
the rejected bytes, and a requested row's URL pointed at an object
that may never exist.

The plan now reads `status` and returns null unless the row is live
per `isLiveFileRow` (uploaded or processed), the same rule upload
dedup already uses. The check runs before any database or S3 work,
and null matches the existing answer for a bucket with no physical
name. The field description says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tenki-reviewer

tenki-reviewer Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 1 medium

💬 Inline comments (1)

🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Pass nullable url directly to new URL (download-url-status.integration.test.ts:109) — In the new integration test, url is read from urlByKey, typed string | null, and passed straight to new URL(url) (download-url-status.integration.test.ts:111).

The presigned-URL resolver now combines the file's status with its physical_name when deciding whether to issue a URL, so rows whose bytes are not yet (or no longer) live in S3 no longer receive working links. The test fixture seeds a app_buckets_physical_name helper function and the integration test asserts the requested/rejected status behavior.

Files Change
graphile/graphile-presigned-url-plugin/src/download-url-field.ts, graphile/graphile-presigned-url-plugin/src/file-lifecycle.ts Add a $status plan step selected into the combined lambda so URL issuance is gated on the row's S3-ready status.
graphql/server-test/__fixtures__/seed/simple-seed-storage/schema.sql Seed a SECURITY DEFINER app_buckets_physical_name helper resolving bucket/physical names for the storage tables.
graphql/server-test/__tests__/download-url-status.integration.test.ts Extend the integration test to cover gated URL issuance across file statuses.

Reviewed commit: 221bc04

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR gates presigned download-URL issuance on the file row's status column in the PostGraphile storage plugin, and updates the server-test seed and integration tests accordingly.

Key findings

Comment on lines 98 to +115
@@ -102,12 +105,14 @@ export function createDownloadUrlPlugin(
isPublic: $isPublic,
filename: $filename,
bucketId: $bucketId,
status: $status,
withPgClient: $withPgClient,
pgSettings: $pgSettings,
});

return lambda($combined, async ({ key, isPublic, filename, bucketId, withPgClient, pgSettings }: any) => {
return lambda($combined, async ({ key, isPublic, filename, bucketId, status, withPgClient, pgSettings }: any) => {
if (!key) return null;
if (!isLiveFileRow({ status })) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

status column assumed on every @storageFiles table

The downloadUrl plan adds $parent.get('status') and gates on isLiveFileRow({ status }), but the field is attached to every codec carrying the @storageFiles smart tag, not only tables with a lifecycle column (download-url-field.ts:98). A tagged table whose schema lacks status fails at plan time (grafast cannot get a nonexistent attribute), turning the graceful null promised in the field description into a broken query. Additionally, isLiveFileRow matches only the exact strings uploaded/processed, so a tagged table with a differently-valued lifecycle column would silently return null for every row.

📋 Prompt for AI Agents

In graphile/graphile-presigned-url-plugin/src/download-url-field.ts around lines 68-98, before adding the downloadUrl field, verify the codec actually has a status attribute (e.g. if (!pgCodec.attributes?.status) return fields; alongside the existing tags?.storageFiles check, or log a warning naming the codec when the tag is present but status is missing). If you keep the field for tables without status, treat an absent status as live so isLiveFileRow gating applies only where the schema declares the column. This prevents a plan-time error or a permanently-null downloadUrl on @storageFiles-tagged tables that do not carry the lifecycle status column.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked both parts.

Missing status column: not reachable. @storageFiles is set in one place, constructive-db storage_module.sql:1038-1041, and the same generator always creates status (:768-777, is_required := true). The one commit that replaced status with a processed boolean (4b9078bf847) landed on main together with the commit that restored it as the file_status enum (e8d4e51d885), both in constructive-db #996, so no main version tagged a table without status. Every @storageFiles table on a local cluster (11) has status of type file_status.

Different lifecycle values: real for pre-#996 tenants. Files tables provisioned before 2026-05-04 have status text with pending/ready/processing/failed; their ready files would get null. Dedup already treats them the same way since #1952. Added an "Existing tenants (R7)" section to the PR description with a question for the owner rather than adding a compatibility mapping here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed analysis. The two-part response is helpful, but neither part addresses the original finding in the diff itself.

Missing status column: Your argument is that the tag is always co-generated with the column in constructive-db, so the case cannot arise in practice. That is a valid operational argument, but the plugin code still has no guard — if the tag is ever applied manually or by a different generator, the plan-time error will occur with no warning. The finding asked for a codec-level check (e.g. if (!pgCodec.attributes?.status) return fields;) or at minimum a warning, and the diff does not show that added.

Different lifecycle values (pre-#996 tenants): You confirm this is a real issue — ready files on older tenants will return null from downloadUrl. Deferring to a PR description question does not fix the silent data-correctness bug that the finding flagged.

The diff adds $parent.get('status') and isLiveFileRow gating unconditionally with no codec attribute guard and no compatibility mapping for legacy status values. The finding is not resolved.

@theothersideofgod

Copy link
Copy Markdown
Contributor Author

Nitpick Pass nullable url directly to new URL: not changed. The line before asserts expect(url).toEqual(expect.any(String)), so a null fails there first; the repo builds with strictNullChecks: false, so there is no type error either.

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.

2 participants