@@ -35,6 +35,7 @@ import { context as grafastContext, lambda, object } from 'grafast';
3535import type { GraphileConfig } from 'graphile-config' ;
3636import { extendSchema , gql } from 'graphile-utils' ;
3737import { Logger } from '@pgpmjs/logger' ;
38+ import { QuoteUtils } from '@pgsql/quotes' ;
3839import {
3940 BucketProvisioner ,
4041} from '@constructive-io/bucket-provisioner' ;
@@ -47,11 +48,16 @@ import type {
4748
4849const log = new Logger ( 'graphile-bucket-provisioner:plugin' ) ;
4950
50- // --- Storage module query (same as presigned-url-plugin) ---
51+ // --- Storage module queries ---
5152
52- const STORAGE_MODULE_QUERY = `
53+ /**
54+ * Resolve the app-level storage module (membership_type IS NULL).
55+ */
56+ const APP_STORAGE_MODULE_QUERY = `
5357 SELECT
5458 sm.id,
59+ sm.membership_type,
60+ sm.entity_table_id,
5561 bs.schema_name AS buckets_schema,
5662 bt.name AS buckets_table,
5763 sm.endpoint,
@@ -62,17 +68,81 @@ const STORAGE_MODULE_QUERY = `
6268 JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
6369 JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
6470 WHERE sm.database_id = $1
71+ AND sm.membership_type IS NULL
6572 LIMIT 1
6673` ;
6774
75+ /**
76+ * Resolve ALL storage modules for a database (for ownerId-based resolution).
77+ */
78+ const ALL_STORAGE_MODULES_QUERY = `
79+ SELECT
80+ sm.id,
81+ sm.membership_type,
82+ sm.entity_table_id,
83+ bs.schema_name AS buckets_schema,
84+ bt.name AS buckets_table,
85+ sm.endpoint,
86+ sm.public_url_prefix,
87+ sm.provider,
88+ sm.allowed_origins,
89+ es.schema_name AS entity_schema,
90+ et.name AS entity_table
91+ FROM metaschema_modules_public.storage_module sm
92+ JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
93+ JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
94+ LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
95+ LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
96+ WHERE sm.database_id = $1
97+ ` ;
98+
6899interface StorageModuleRow {
69100 id : string ;
101+ membership_type : number | null ;
102+ entity_table_id : string | null ;
70103 buckets_schema : string ;
71104 buckets_table : string ;
72105 endpoint : string | null ;
73106 public_url_prefix : string | null ;
74107 provider : string | null ;
75108 allowed_origins : string [ ] | null ;
109+ entity_schema ?: string | null ;
110+ entity_table ?: string | null ;
111+ }
112+
113+ /**
114+ * Resolve the storage module for a given scope.
115+ * If ownerId is provided, probes entity tables to find the matching module.
116+ * Otherwise, returns the app-level module.
117+ */
118+ async function resolveStorageModule (
119+ pgClient : any ,
120+ databaseId : string ,
121+ ownerId ?: string ,
122+ ) : Promise < StorageModuleRow | null > {
123+ if ( ! ownerId ) {
124+ // App-level resolution
125+ const result = await pgClient . query ( APP_STORAGE_MODULE_QUERY , [ databaseId ] ) ;
126+ return ( result . rows [ 0 ] as StorageModuleRow ) ?? null ;
127+ }
128+
129+ // Entity-scoped: load all modules and probe entity tables
130+ const result = await pgClient . query ( ALL_STORAGE_MODULES_QUERY , [ databaseId ] ) ;
131+ const modules = result . rows as StorageModuleRow [ ] ;
132+ const entityModules = modules . filter ( ( m ) => m . entity_schema && m . entity_table ) ;
133+
134+ for ( const mod of entityModules ) {
135+ const entityTable = QuoteUtils . quoteQualifiedIdentifier ( mod . entity_schema ! , mod . entity_table ! ) ;
136+ const probe = await pgClient . query (
137+ `SELECT 1 FROM ${ entityTable } WHERE id = $1 LIMIT 1` ,
138+ [ ownerId ] ,
139+ ) ;
140+ if ( probe . rows . length > 0 ) {
141+ return mod ;
142+ }
143+ }
144+
145+ return null ;
76146}
77147
78148interface BucketRow {
@@ -187,8 +257,7 @@ async function provisionBucketForRow(
187257 const accessType = bucketType as 'public' | 'private' | 'temp' ;
188258
189259 // Read storage module config to check for endpoint/provider/CORS overrides
190- const smResult = await pgClient . query ( STORAGE_MODULE_QUERY , [ databaseId ] ) ;
191- const storageModule : StorageModuleRow | null = smResult . rows [ 0 ] ?? null ;
260+ const storageModule = await resolveStorageModule ( pgClient , databaseId ) ;
192261
193262 // Resolve CORS origins using the 3-tier hierarchy
194263 const effectiveOrigins = resolveAllowedOrigins (
@@ -234,8 +303,7 @@ async function updateBucketCors(
234303 const s3BucketName = resolveBucketName ( bucketKey , databaseId , options ) ;
235304 const accessType = bucketType as 'public' | 'private' | 'temp' ;
236305
237- const smResult = await pgClient . query ( STORAGE_MODULE_QUERY , [ databaseId ] ) ;
238- const storageModule : StorageModuleRow | null = smResult . rows [ 0 ] ?? null ;
306+ const storageModule = await resolveStorageModule ( pgClient , databaseId ) ;
239307
240308 const effectiveOrigins = resolveAllowedOrigins (
241309 bucketAllowedOrigins ,
@@ -287,6 +355,11 @@ export function createBucketProvisionerPlugin(
287355 input ProvisionBucketInput {
288356 """The logical bucket key (e.g., "public", "private")"""
289357 bucketKey: String!
358+ """
359+ Owner entity ID for entity-scoped bucket provisioning.
360+ Omit for app-level (database-wide) storage.
361+ """
362+ ownerId: UUID
290363 }
291364
292365 type ProvisionBucketPayload {
@@ -329,7 +402,7 @@ export function createBucketProvisionerPlugin(
329402 } ) ;
330403
331404 return lambda ( $combined , async ( { input, withPgClient, pgSettings } : any ) => {
332- const { bucketKey } = input ;
405+ const { bucketKey, ownerId } = input ;
333406
334407 if ( ! bucketKey || typeof bucketKey !== 'string' ) {
335408 throw new Error ( 'INVALID_BUCKET_KEY' ) ;
@@ -342,20 +415,30 @@ export function createBucketProvisionerPlugin(
342415 throw new Error ( 'DATABASE_NOT_FOUND' ) ;
343416 }
344417
345- // Read storage module config
346- const smResult = await pgClient . query ( STORAGE_MODULE_QUERY , [ databaseId ] ) ;
347- if ( smResult . rows . length === 0 ) {
348- throw new Error ( 'STORAGE_MODULE_NOT_PROVISIONED' ) ;
418+ // Resolve storage module (app-level or entity-scoped via ownerId)
419+ const storageModule = await resolveStorageModule ( pgClient , databaseId , ownerId ) ;
420+ if ( ! storageModule ) {
421+ throw new Error (
422+ ownerId
423+ ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
424+ : 'STORAGE_MODULE_NOT_PROVISIONED' ,
425+ ) ;
349426 }
350- const storageModule = smResult . rows [ 0 ] as StorageModuleRow ;
351427
352428 // Look up the bucket row (RLS enforced via pgSettings)
429+ const hasOwner = ownerId && storageModule . membership_type !== null ;
430+ const bucketsTable = QuoteUtils . quoteQualifiedIdentifier ( storageModule . buckets_schema , storageModule . buckets_table ) ;
353431 const bucketResult = await pgClient . query (
354- `SELECT id, key, type, is_public, allowed_origins
355- FROM "${ storageModule . buckets_schema } "."${ storageModule . buckets_table } "
356- WHERE key = $1
357- LIMIT 1` ,
358- [ bucketKey ] ,
432+ hasOwner
433+ ? `SELECT id, key, type, is_public, allowed_origins
434+ FROM ${ bucketsTable }
435+ WHERE key = $1 AND owner_id = $2
436+ LIMIT 1`
437+ : `SELECT id, key, type, is_public, allowed_origins
438+ FROM ${ bucketsTable }
439+ WHERE key = $1
440+ LIMIT 1` ,
441+ hasOwner ? [ bucketKey , ownerId ] : [ bucketKey ] ,
359442 ) ;
360443
361444 if ( bucketResult . rows . length === 0 ) {
@@ -522,13 +605,12 @@ export function createBucketProvisionerPlugin(
522605 return ;
523606 }
524607
525- // Read the updated bucket row to get full state
526- const smResult = await pgClient . query ( STORAGE_MODULE_QUERY , [ databaseId ] ) ;
527- if ( smResult . rows . length === 0 ) {
608+ // Read the storage module config (app-level; auto-hook doesn't have ownerId context)
609+ const storageModule = await resolveStorageModule ( pgClient , databaseId ) ;
610+ if ( ! storageModule ) {
528611 log . warn ( 'CORS update skipped: storage module not provisioned' ) ;
529612 return ;
530613 }
531- const storageModule = smResult . rows [ 0 ] as StorageModuleRow ;
532614
533615 // We need the bucket key — it may come from input or patch
534616 // For updates, PostGraphile uses nodeId or the row's PK, so
@@ -543,9 +625,10 @@ export function createBucketProvisionerPlugin(
543625 }
544626
545627 // Read the full bucket row (post-update) to get type + origins
628+ const bucketsTable = QuoteUtils . quoteQualifiedIdentifier ( storageModule . buckets_schema , storageModule . buckets_table ) ;
546629 const bucketResult = await pgClient . query (
547630 `SELECT id, key, type, is_public, allowed_origins
548- FROM " ${ storageModule . buckets_schema } "." ${ storageModule . buckets_table } "
631+ FROM ${ bucketsTable }
549632 WHERE key = $1
550633 LIMIT 1` ,
551634 [ patchKey ] ,
0 commit comments