Skip to content

feat: multi-scope bucket resolution (Option C: bucketKey + ownerId)#1009

Merged
pyramation merged 12 commits intomainfrom
devin/1776582549-multi-scope-bucket-resolution
Apr 19, 2026
Merged

feat: multi-scope bucket resolution (Option C: bucketKey + ownerId)#1009
pyramation merged 12 commits intomainfrom
devin/1776582549-multi-scope-bucket-resolution

Conversation

@pyramation
Copy link
Copy Markdown
Contributor

@pyramation pyramation commented Apr 19, 2026

Summary

Adds multi-scope storage module resolution to the presigned-url and bucket-provisioner Graphile plugins. Previously, all storage module queries used WHERE database_id = $1 LIMIT 1, which silently picked an arbitrary module when a database has multiple storage modules (app-level + entity-scoped from has_storage=true entity types, as wired in constructive-db PR #876).

Approach (Option C — Key + ownerId): Both requestUploadUrl and provisionBucket mutations accept an optional ownerId (entity UUID). When provided, the plugin probes entity tables to find the matching storage module. When omitted, it defaults to the app-level module (membership_type IS NULL).

presigned-url-plugin

  • storage-module-cache.ts: Split the single STORAGE_MODULE_QUERY into APP_STORAGE_MODULE_QUERY (app-level, filtered by membership_type IS NULL) and ALL_STORAGE_MODULES_QUERY (all modules with entity table joins). Added three new resolution functions:
    • getStorageModuleConfigForOwner() — probes entity tables to find the module for a given ownerId
    • resolveStorageModuleByFileId() — probes all file tables by UUID (for confirmUpload)
    • buildConfig() — shared helper to construct StorageModuleConfig from a DB row, using QuoteUtils.quoteQualifiedIdentifier() for all qualified table names
  • plugin.ts: requestUploadUrl routes to the correct resolver based on ownerId. confirmUpload uses resolveStorageModuleByFileId to find the file across all scopes. File INSERT conditionally includes owner_id column based on whether the storage module is entity-scoped.
  • types.ts: Added ownerId? to RequestUploadUrlInput, scope identity fields (membershipType, entityTableId, entityQualifiedName) to StorageModuleConfig, changed BucketConfig.owner_id from string to string | null.

bucket-provisioner-plugin

  • plugin.ts: Added resolveStorageModule() async function with app-level vs entity-probing pattern. Updated provisionBucket mutation, provisionBucketForRow, updateBucketCors, and the auto-provisioning hook to use scope-aware resolution. Bucket lookups use (owner_id, key) composite when ownerId is present.

Identifier quoting

Both plugins now use QuoteUtils.quoteQualifiedIdentifier() from @pgsql/quotes for all dynamic SQL table references, replacing raw string interpolation ("${schema}"."${table}"). This follows the established codebase convention used in PublicKeySignature.ts, query-builder.ts, upload.ts, and dump.ts.

Test fixture & dependency updates

  • graphql/server-test/__fixtures__/seed/simple-seed-storage/setup.sql: Updated the hand-written storage_module CREATE TABLE to match the 0.21.1 schema — added membership_type int DEFAULT NULL column, renamed table defaults to app_buckets/app_files/app_upload_requests, and added the storage_module_unique_scope index on (database_id, COALESCE(membership_type, -1)).
  • @pgpm/metaschema-modules bumped from ^0.18.0 to ^0.21.1 (resolves to 0.21.1 in lockfile).

Not changed

  • download-url-field.ts — uses smart-tag-driven type resolution; S3 config (bucket name, expiry) is the same across all scopes within a database, so no changes needed.
  • graphile-settings/presigned-url-resolver.ts — config factory only (S3Client, bucket name resolver), no storage module queries.

Review & Testing Checklist for Human

  • Conditional file INSERT parameter alignment (presigned-url-plugin/src/plugin.ts ~lines 336-367): The two INSERT branches have different column counts (with/without owner_id). Verify the $N positional parameters match the values arrays in both paths — off-by-one here would silently corrupt data.
  • Dynamic SQL in getBucketConfig (storage-module-cache.ts ~line 383): The SELECT conditionally interpolates owner_id, into the column list via ${storageConfig.membershipType !== null ? 'owner_id,' : ''}. Verify this doesn't produce malformed SQL in edge cases (e.g., membershipType of 0 is truthy for !== null, which is correct, but worth confirming).
  • N+1 entity probing in getStorageModuleConfigForOwner, resolveStorageModuleByFileId, and resolveStorageModule (bucket-provisioner): All three probe entity/file tables sequentially. Acceptable for 2-3 entity types with storage, but consider whether this will scale. The ownerId→module mapping is cached after first resolution in the presigned-url-plugin.
  • Non-null assertions in resolveStorageModule (bucket-provisioner ~line 135): mod.entity_schema! and mod.entity_table! are guarded by the .filter() above, but TypeScript can't narrow through the filter. Verify the filter condition (m.entity_schema && m.entity_table) is sufficient.
  • Test fixture drift risk: The test fixture setup.sql hand-writes the storage_module table rather than deploying via @pgpm/metaschema-modules. If the published package schema changes again, this fixture must be manually updated to match. Verify the fixture's column list matches the 0.21.1 schema.
  • Recommended test plan: Deploy to a database with both app-level storage and at least one entity type with has_storage=true. Test: (1) requestUploadUrl without ownerId → uses app_files; (2) requestUploadUrl with a valid entity ownerId → uses entity-scoped files; (3) requestUploadUrl with an invalid ownerId → returns STORAGE_MODULE_NOT_FOUND_FOR_OWNER; (4) confirmUpload with a fileId from each scope resolves correctly; (5) provisionBucket with/without ownerId.

Notes

  • Builds on constructive-db PR chore: remove stale skills/ directory + fix JSDoc comment #876 (merged) which added membership_type and entity_table_id columns to storage_module and parameterized apply_storage_security.
  • Depends on @pgpm/metaschema-modules@0.21.1 (published from pgpm-modules PR modules #58, merged) which includes the membership_type column in the deployed table schema.
  • No runtime integration tests are included in this PR — the build passes (TypeScript compilation) and all 48 CI checks pass. A MinIO integration test against the multi-scope schema is planned as a follow-up.
  • Snapshot files updated for both graphql/test (introspection query) and graphql/server-test (schema SDL) to include the new ownerId fields on RequestUploadUrlInput and ProvisionBucketInput.

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

- presigned-url-plugin: Add optional ownerId to RequestUploadUrlInput
  - getStorageModuleConfig now filters to app-level (membership_type IS NULL)
  - New getStorageModuleConfigForOwner resolves entity-scoped modules by probing entity tables
  - New resolveStorageModuleByFileId for confirmUpload (probes all file tables by UUID)
  - getBucketConfig supports entity-scoped lookups with (owner_id, key) composite
  - File INSERT adapts to presence/absence of owner_id column per scope

- bucket-provisioner-plugin: Add optional ownerId to ProvisionBucketInput
  - Replace LIMIT 1 query with scope-aware resolveStorageModule function
  - provisionBucket mutation resolves storage module via ownerId
  - Auto-provisioning hook uses app-level resolution (no ownerId context)

- Backward compatible: omitting ownerId defaults to app-level storage
- No DB changes required (builds on PR #876 membership_type + entity_table_id columns)
@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 and CI monitoring

…at code

- Replace raw string interpolation ("${schema}"."${table}") with
  QuoteUtils.quoteQualifiedIdentifier() from @pgsql/quotes in both
  presigned-url-plugin and bucket-provisioner-plugin
- Remove LEGACY_STORAGE_MODULE_QUERY constants from both plugins
- Remove schemaSupportsMultiScope module-level flags
- Remove all SAVEPOINT-based schema detection logic
- Simplify resolveStorageModule(), getStorageModuleConfig(),
  getStorageModuleConfigForOwner(), and resolveStorageModuleByFileId()
  to use direct queries without fallback
@socket-security
Copy link
Copy Markdown

socket-security Bot commented Apr 19, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​pgpm/​metaschema-modules@​0.18.0 ⏵ 0.21.1511009396 +2100

View full report

The test fixture setup.sql hardcodes the storage_module CREATE TABLE
instead of using the published @pgpm/metaschema-modules package.
Add the membership_type column and update table name defaults to match
the 0.21.1 schema (app_buckets, app_files, app_upload_requests).
Also add the unique scope index on (database_id, COALESCE(membership_type, -1)).
@pyramation pyramation merged commit b7d7d8c into main Apr 19, 2026
51 checks passed
@pyramation pyramation deleted the devin/1776582549-multi-scope-bucket-resolution branch April 19, 2026 09:07
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