Skip to content

feat(storage): managed upload lane — tenant-resolved buckets, files rows, projection documents - #1679

Merged
pyramation merged 2 commits into
mainfrom
feat/managed-upload-resolver
Aug 8, 2026
Merged

feat(storage): managed upload lane — tenant-resolved buckets, files rows, projection documents#1679
pyramation merged 2 commits into
mainfrom
feat/managed-upload-resolver

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

Item 3 of constructive-planning#1476 §6: both upload transports now resolve the tenant's bucket in the database and produce an authoritative files row, and the value stored in an image/upload column becomes a projection of that row. Its id is the load-bearing field — it is what the document-reference refcount shipped in constructive-db#2879 counts before storage GC collects an S3 object. Until this lands, nothing writes that id, so that refcount has no producer.

The multipart @upload lane was the second, unmanaged storage model:

-const key = randomKey();
-await streamer.uploadWithContentType({ key, bucket: cdn.bucketName /* env BUCKET_NAME */ });
-return { filename, mime, url: result.upload.Location };   // no files row, no bucket, no tenant
+const target = await resolveManagedUploadTarget({ field, databaseId, ... });  // registry → module → default tag
+await streamer.uploadWithContentType({ key: `.staging/${uuid}`, bucket: target.physicalName });
+const { projection } = await finalizeStagedUpload({ target, staged: { contentHash, size, ... } });
+return { ...projection, filename, mime };  // {id, key, bucket_id, size, url?} + compat fields

Bucket resolution is logical, never environmental. A field's file_ref_field row supplies its storage module and declared bucket_key/publicness; with no declared key, function_resolution.resolve_default_bucket(database_id, scope, entity_id, public_access, NULL) resolves the tenant's reserved default tag and raises in SQL on none/ambiguous. mintPhysicalBucketName no longer falls back to the globally configured S3 bucket — a deployment without resolveBucketName now fails loudly instead of writing a tenant's object into a shared env bucket.

Streaming needs staging. The final key is the content hash, which isn't known until the last byte: bytes stream through a hashing Transform to .staging/<uuid>, then finalizeStagedUpload either finds an existing row on (content_hash, bucket_id) and drops the staged object (dedup — a property of the object, so it holds across transports), or server-side-copies to the content key and inserts the row. Every failure path leaves S3 as it found it: bytes no row names are bytes GC can never reach, so an insert failure drops both the promoted and the staged object.

Frontend compatibility. Presigned mutations are additive: all existing payload fields stay, file (the projection) is added, and bucketKey is relaxed to nullable (isPublic selects which default) — relaxing a required input is backwards compatible. For multipart, image/upload keep url/filename/mime alongside the new fields, so existing photo.url readers keep working; url is marked deprecated in favour of id + the files row's late-bound downloadUrl, which also works for private buckets.

Two deliberate calls worth reviewing:

  • attachment is a text domain, so it cannot hold a projection. It still stores a plain public URL, and a resolved private bucket raises (ATTACHMENT_BUCKET_NOT_PUBLIC) rather than persisting an expiring presigned URL in a column. Consequence: an attachment column is not a countable document reference — its files row keeps the object alive, but the column contributes nothing to the refcount. Migrating those columns to upload is the fix, and is not in this PR.
  • An unregistered column falls back to the app-scope module's public default rather than hard-failing, because columns written by the old resolver held embedded URLs and their readers assume a publicly addressable object. A registered column always states its own intent. Entity-scoped modules are refused on the multipart path (STORAGE_SCOPE_UNSUPPORTED) — that transport carries no owner row to resolve an owner-scoped bucket against.

New: file-ref-registry.ts (registry lookup, LRU-cached, misses never cached), default-bucket.ts, physical-bucket.ts (name minting + first-provision recording, moved out of plugin.ts), managed-upload.ts (the lifecycle both transports share).

Tests: graphile-presigned-url-plugin/__tests__/managed-upload.test.ts and a rewritten graphile-settings/__tests__/upload-resolver.test.ts fake the database at the query boundary — unanswered statements throw, so a path that silently proceeds fails the test. They cover default-tag resolution, declared bucket keys, first-provision recording, dedup, projection shape, compat url, staged-object cleanup, the private-attachment refusal, and each raise path.

Verified: package tests for all four touched packages (MinIO integration suites included), pnpm build, pnpm lint (0 errors).

Link to Devin session: https://app.devin.ai/sessions/739f6e40a0dc44c4a49b8d84390a2268
Requested by: @pyramation

…solved buckets

Both upload transports now resolve the tenant's logical bucket in the
database and create an authoritative files row, and return the projection
document whose id is what storage GC counts before collecting an object.

- upload field identity (schema/table/column) is reported to the resolver
- file_ref_field registry lookup supplies storage module + bucket intent
- omitted bucketKey resolves the reserved default tag via
  function_resolution.resolve_default_bucket instead of an env bucket
- multipart uploads stage, hash, promote to the content key, dedup, and
  insert a files row; image/upload columns keep url/filename/mime
- removes the global BUCKET_NAME upload lane

Refs constructive-planning#1476 (item 3 of §6)
@pyramation pyramation self-assigned this Aug 8, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

A bucket with allow_custom_keys=true (e.g. a static site's) is addressed by
publisher-chosen paths; the multipart lane only mints content-hash keys, which
would pollute it with unreachable objects. Path-keyed uploads belong to the
presigned lane, which accepts an explicit key.
@pyramation
pyramation merged commit 73b8b46 into main Aug 8, 2026
20 checks passed
@pyramation
pyramation deleted the feat/managed-upload-resolver branch August 8, 2026 07:36
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