graphile-bucket-provisioner-plugin 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/esm/plugin.js +114 -64
  2. package/package.json +2 -2
  3. package/plugin.js +114 -64
package/esm/plugin.js CHANGED
@@ -38,9 +38,12 @@ import { extendSchema, gql } from 'graphile-utils';
38
38
  const log = new Logger('graphile-bucket-provisioner:plugin');
39
39
  // --- Storage module queries ---
40
40
  /**
41
- * Resolve the app-level storage module (scope = 'app').
41
+ * Resolve the storage module whose buckets table is the one being addressed.
42
+ *
43
+ * The buckets table's identity (schema + table, from the codec being mutated or
44
+ * the module row) is the fact that names the plane — never a scope literal.
42
45
  */
43
- const APP_STORAGE_MODULE_QUERY = `
46
+ const STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY = `
44
47
  SELECT
45
48
  sm.id,
46
49
  sm.scope,
@@ -55,11 +58,12 @@ const APP_STORAGE_MODULE_QUERY = `
55
58
  JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
56
59
  JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
57
60
  WHERE sm.database_id = $1
58
- AND sm.scope = 'app'
59
- LIMIT 1
61
+ AND bs.schema_name = $2
62
+ AND bt.name = $3
60
63
  `;
61
64
  /**
62
- * Resolve ALL storage modules for a database (for ownerId-based resolution).
65
+ * Resolve ALL storage modules for a database (for bucket-key and ownerId-based
66
+ * resolution in the explicit provisionBucket mutation).
63
67
  */
64
68
  const ALL_STORAGE_MODULES_QUERY = `
65
69
  SELECT
@@ -93,28 +97,82 @@ function runQuery(pgClient, text, values) {
93
97
  return pgClient.query(values === undefined ? { text } : { text, values });
94
98
  }
95
99
  /**
96
- * Resolve the storage module for a given scope.
97
- * If ownerId is provided, probes entity tables to find the matching module.
98
- * Otherwise, returns the app-level module.
100
+ * Resolve the storage module whose buckets table is the one being addressed.
101
+ *
102
+ * This is the resolution the auto-provision/CORS hooks use: the codec being
103
+ * mutated names its table, and the table names its module. A `@storageBuckets`
104
+ * table with no module row (or with several) is a provisioning bug and throws.
99
105
  */
100
- async function resolveStorageModule(pgClient, databaseId, ownerId) {
101
- if (!ownerId) {
102
- // App-level resolution
103
- const result = await runQuery(pgClient, APP_STORAGE_MODULE_QUERY, [databaseId]);
104
- return result.rows[0] ?? null;
106
+ async function resolveStorageModuleByBucketsTable(pgClient, databaseId, schemaName, tableName) {
107
+ const result = await runQuery(pgClient, STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY, [
108
+ databaseId,
109
+ schemaName,
110
+ tableName,
111
+ ]);
112
+ const rows = result.rows;
113
+ if (rows.length === 0) {
114
+ throw new Error(`STORAGE_MODULE_NOT_FOUND: no storage module in database ${databaseId} records ` +
115
+ `${schemaName}.${tableName} as its buckets table`);
116
+ }
117
+ if (rows.length > 1) {
118
+ throw new Error(`STORAGE_MODULE_AMBIGUOUS: ${rows.length} storage modules in database ${databaseId} record ` +
119
+ `${schemaName}.${tableName} as their buckets table`);
105
120
  }
106
- // Entity-scoped: load all modules and probe entity tables
121
+ return rows[0];
122
+ }
123
+ /**
124
+ * The explicit provisionBucket mutation's resolution: find the plane that
125
+ * actually holds the named bucket row.
126
+ *
127
+ * With an ownerId, the owning entity row names the plane (probed through each
128
+ * entity-keyed module's recorded entity table). Without one, every
129
+ * non-entity-keyed plane's buckets table is probed for the key under RLS —
130
+ * exactly one plane may hold it; several is ambiguous and throws rather than
131
+ * silently picking.
132
+ */
133
+ async function resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId) {
107
134
  const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]);
108
135
  const modules = result.rows;
109
- const entityModules = modules.filter((m) => m.entity_schema && m.entity_table);
110
- for (const mod of entityModules) {
111
- const entityTable = QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
112
- const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
113
- if (probe.rows.length > 0) {
114
- return mod;
136
+ if (modules.length === 0) {
137
+ throw new Error(`STORAGE_MODULE_NOT_PROVISIONED: database ${databaseId} has no storage modules`);
138
+ }
139
+ if (ownerId) {
140
+ const entityModules = modules.filter((m) => m.entity_table_id !== null && m.entity_schema && m.entity_table);
141
+ for (const mod of entityModules) {
142
+ const entityTable = QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
143
+ const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
144
+ if (probe.rows.length === 0)
145
+ continue;
146
+ const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
147
+ const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
148
+ FROM ${bucketsTable}
149
+ WHERE key = $1 AND owner_id = $2
150
+ LIMIT 1`, [bucketKey, ownerId]);
151
+ if (bucketResult.rows.length === 0)
152
+ return null;
153
+ return { storageModule: mod, bucket: bucketResult.rows[0] };
154
+ }
155
+ return null;
156
+ }
157
+ const matches = [];
158
+ for (const mod of modules.filter((m) => m.entity_table_id === null)) {
159
+ const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
160
+ const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
161
+ FROM ${bucketsTable}
162
+ WHERE key = $1
163
+ LIMIT 1`, [bucketKey]);
164
+ if (bucketResult.rows.length > 0) {
165
+ matches.push({ storageModule: mod, bucket: bucketResult.rows[0] });
115
166
  }
116
167
  }
117
- return null;
168
+ if (matches.length === 0)
169
+ return null;
170
+ if (matches.length > 1) {
171
+ const scopes = matches.map((m) => `'${m.storageModule.scope}'`).join(', ');
172
+ throw new Error(`BUCKET_KEY_AMBIGUOUS: bucket key "${bucketKey}" exists in ${matches.length} storage planes ` +
173
+ `(scopes ${scopes}); provide an ownerId or use a plane-unique key`);
174
+ }
175
+ return matches[0];
118
176
  }
119
177
  /**
120
178
  * Normalize the recorded physical coordinate at the DB boundary.
@@ -207,10 +265,8 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
207
265
  * Core provisioning logic shared by both the explicit mutation and the
208
266
  * auto-provisioning hook.
209
267
  */
210
- async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
268
+ async function provisionBucketForRow(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
211
269
  const accessType = bucketType;
212
- // Read storage module config to check for endpoint/provider/CORS overrides
213
- const storageModule = await resolveStorageModule(pgClient, databaseId);
214
270
  // Resolve CORS origins using the 3-tier hierarchy
215
271
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
216
272
  const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
@@ -230,9 +286,8 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
230
286
  /**
231
287
  * Update CORS on an existing S3 bucket when allowed_origins changes.
232
288
  */
233
- async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
289
+ async function updateBucketCors(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
234
290
  const accessType = bucketType;
235
- const storageModule = await resolveStorageModule(pgClient, databaseId);
236
291
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
237
292
  const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
238
293
  log.info(`Updating CORS on S3 bucket "${s3BucketName}" ` +
@@ -324,29 +379,14 @@ export function createBucketProvisionerPlugin(options) {
324
379
  if (!databaseId) {
325
380
  throw new Error('DATABASE_NOT_FOUND');
326
381
  }
327
- // Resolve storage module (app-level or entity-scoped via ownerId)
328
- const storageModule = await resolveStorageModule(pgClient, databaseId, ownerId);
329
- if (!storageModule) {
330
- throw new Error(ownerId
331
- ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
332
- : 'STORAGE_MODULE_NOT_PROVISIONED');
333
- }
334
- // Look up the bucket row (RLS enforced via pgSettings)
335
- const hasOwner = ownerId && storageModule.scope !== 'app';
336
- const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
337
- const bucketResult = await runQuery(pgClient, hasOwner
338
- ? `SELECT id, key, type, is_public, allowed_origins, physical_name
339
- FROM ${bucketsTable}
340
- WHERE key = $1 AND owner_id = $2
341
- LIMIT 1`
342
- : `SELECT id, key, type, is_public, allowed_origins, physical_name
343
- FROM ${bucketsTable}
344
- WHERE key = $1
345
- LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
346
- if (bucketResult.rows.length === 0) {
382
+ // Resolve the plane that actually holds the named bucket row
383
+ // (RLS enforced via pgSettings) — never a guessed scope.
384
+ const resolution = await resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId);
385
+ if (!resolution) {
347
386
  throw new Error('BUCKET_NOT_FOUND');
348
387
  }
349
- const bucket = bucketResult.rows[0];
388
+ const { storageModule, bucket } = resolution;
389
+ const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
350
390
  // First provision mints a name; afterwards the stored coordinate
351
391
  // is authoritative and the naming hook is never consulted again.
352
392
  const recorded = storedPhysicalName(bucket);
@@ -354,7 +394,7 @@ export function createBucketProvisionerPlugin(options) {
354
394
  ? resolveBucketName(bucket.key, databaseId, options)
355
395
  : recorded;
356
396
  try {
357
- const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
397
+ const result = await provisionBucketForRow(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
358
398
  // Record the exact provisioned name on the source row.
359
399
  await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName);
360
400
  return {
@@ -427,6 +467,10 @@ export function createBucketProvisionerPlugin(options) {
427
467
  return field;
428
468
  }
429
469
  log.debug(`Wrapping mutation "${fieldName}" for ${isCreate ? 'auto-provisioning' : 'CORS update'} (codec: ${pgCodec.name})`);
470
+ // The codec being mutated names the buckets table — the hook always
471
+ // operates on the plane that table belongs to, never a guessed scope.
472
+ const codecSchemaName = pgCodec.extensions?.pg?.schemaName;
473
+ const codecTableName = pgCodec.extensions?.pg?.name;
430
474
  const defaultResolver = (obj) => obj[fieldName];
431
475
  const { resolve: oldResolve = defaultResolver, ...rest } = field;
432
476
  return {
@@ -450,23 +494,29 @@ export function createBucketProvisionerPlugin(options) {
450
494
  `could not extract key/type from mutation input`);
451
495
  return result;
452
496
  }
497
+ if (!codecSchemaName || !codecTableName) {
498
+ throw new Error(`Auto-provision failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
499
+ }
453
500
  await withPgClient(pgSettings, async (pgClient) => {
454
501
  const databaseId = await resolveDatabaseId(pgClient);
455
502
  if (!databaseId) {
456
503
  log.warn('Auto-provision skipped: could not resolve database_id');
457
504
  return;
458
505
  }
506
+ // The mutated table names its module — the plane being written
507
+ // is the plane that gets provisioned.
508
+ const storageModule = await resolveStorageModuleByBucketsTable(pgClient, databaseId, codecSchemaName, codecTableName);
459
509
  // Newly-created row has no stored coordinate yet — mint on first provision.
460
- const result = await provisionBucketForRow(pgClient, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
510
+ const result = await provisionBucketForRow(storageModule, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
461
511
  // Record the provisioned name on the just-created row.
462
- const storageModule = await resolveStorageModule(pgClient, databaseId);
463
- if (storageModule) {
464
- const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
465
- const idResult = await runQuery(pgClient, `SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, [bucketInput.key]);
466
- const bucketId = idResult.rows[0]?.id;
467
- if (bucketId)
468
- await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName);
469
- }
512
+ const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
513
+ const ownerId = bucketInput.ownerId ?? bucketInput.owner_id ?? null;
514
+ const idResult = await runQuery(pgClient, ownerId
515
+ ? `SELECT id FROM ${bucketsTable} WHERE key = $1 AND owner_id = $2 LIMIT 1`
516
+ : `SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, ownerId ? [bucketInput.key, ownerId] : [bucketInput.key]);
517
+ const bucketId = idResult.rows[0]?.id;
518
+ if (bucketId)
519
+ await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName);
470
520
  });
471
521
  }
472
522
  else {
@@ -477,18 +527,18 @@ export function createBucketProvisionerPlugin(options) {
477
527
  // allowed_origins not being changed, nothing to do
478
528
  return result;
479
529
  }
530
+ if (!codecSchemaName || !codecTableName) {
531
+ throw new Error(`CORS update failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
532
+ }
480
533
  await withPgClient(pgSettings, async (pgClient) => {
481
534
  const databaseId = await resolveDatabaseId(pgClient);
482
535
  if (!databaseId) {
483
536
  log.warn('CORS update skipped: could not resolve database_id');
484
537
  return;
485
538
  }
486
- // Read the storage module config (app-level; auto-hook doesn't have ownerId context)
487
- const storageModule = await resolveStorageModule(pgClient, databaseId);
488
- if (!storageModule) {
489
- log.warn('CORS update skipped: storage module not provisioned');
490
- return;
491
- }
539
+ // The mutated table names its module — CORS applies to the
540
+ // plane whose row was updated.
541
+ const storageModule = await resolveStorageModuleByBucketsTable(pgClient, databaseId, codecSchemaName, codecTableName);
492
542
  // We need the bucket key — it may come from input or patch
493
543
  // For updates, PostGraphile uses nodeId or the row's PK, so
494
544
  // we read the bucket from the patch's key or from the nodeId
@@ -513,7 +563,7 @@ export function createBucketProvisionerPlugin(options) {
513
563
  // never provisioned there is nothing to update yet, so mint the
514
564
  // conventional name the first provision would use.
515
565
  const recorded = storedPhysicalName(bucket);
516
- await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
566
+ await updateBucketCors(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
517
567
  ? resolveBucketName(bucket.key, databaseId, options)
518
568
  : recorded);
519
569
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-bucket-provisioner-plugin",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Bucket provisioning plugin for PostGraphile v5 — auto-provisions S3 buckets on bucket table mutations",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -57,5 +57,5 @@
57
57
  "@types/node": "^22.19.11",
58
58
  "makage": "^0.3.0"
59
59
  },
60
- "gitHead": "b5b6b9f05276b7e9cbd3d1f4acd31dd315c353ea"
60
+ "gitHead": "d10fe91ede80a4c2dfe2c1c6dcf6fe7193784eba"
61
61
  }
package/plugin.js CHANGED
@@ -42,9 +42,12 @@ const graphile_utils_1 = require("graphile-utils");
42
42
  const log = new logger_1.Logger('graphile-bucket-provisioner:plugin');
43
43
  // --- Storage module queries ---
44
44
  /**
45
- * Resolve the app-level storage module (scope = 'app').
45
+ * Resolve the storage module whose buckets table is the one being addressed.
46
+ *
47
+ * The buckets table's identity (schema + table, from the codec being mutated or
48
+ * the module row) is the fact that names the plane — never a scope literal.
46
49
  */
47
- const APP_STORAGE_MODULE_QUERY = `
50
+ const STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY = `
48
51
  SELECT
49
52
  sm.id,
50
53
  sm.scope,
@@ -59,11 +62,12 @@ const APP_STORAGE_MODULE_QUERY = `
59
62
  JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
60
63
  JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
61
64
  WHERE sm.database_id = $1
62
- AND sm.scope = 'app'
63
- LIMIT 1
65
+ AND bs.schema_name = $2
66
+ AND bt.name = $3
64
67
  `;
65
68
  /**
66
- * Resolve ALL storage modules for a database (for ownerId-based resolution).
69
+ * Resolve ALL storage modules for a database (for bucket-key and ownerId-based
70
+ * resolution in the explicit provisionBucket mutation).
67
71
  */
68
72
  const ALL_STORAGE_MODULES_QUERY = `
69
73
  SELECT
@@ -97,28 +101,82 @@ function runQuery(pgClient, text, values) {
97
101
  return pgClient.query(values === undefined ? { text } : { text, values });
98
102
  }
99
103
  /**
100
- * Resolve the storage module for a given scope.
101
- * If ownerId is provided, probes entity tables to find the matching module.
102
- * Otherwise, returns the app-level module.
104
+ * Resolve the storage module whose buckets table is the one being addressed.
105
+ *
106
+ * This is the resolution the auto-provision/CORS hooks use: the codec being
107
+ * mutated names its table, and the table names its module. A `@storageBuckets`
108
+ * table with no module row (or with several) is a provisioning bug and throws.
103
109
  */
104
- async function resolveStorageModule(pgClient, databaseId, ownerId) {
105
- if (!ownerId) {
106
- // App-level resolution
107
- const result = await runQuery(pgClient, APP_STORAGE_MODULE_QUERY, [databaseId]);
108
- return result.rows[0] ?? null;
110
+ async function resolveStorageModuleByBucketsTable(pgClient, databaseId, schemaName, tableName) {
111
+ const result = await runQuery(pgClient, STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY, [
112
+ databaseId,
113
+ schemaName,
114
+ tableName,
115
+ ]);
116
+ const rows = result.rows;
117
+ if (rows.length === 0) {
118
+ throw new Error(`STORAGE_MODULE_NOT_FOUND: no storage module in database ${databaseId} records ` +
119
+ `${schemaName}.${tableName} as its buckets table`);
120
+ }
121
+ if (rows.length > 1) {
122
+ throw new Error(`STORAGE_MODULE_AMBIGUOUS: ${rows.length} storage modules in database ${databaseId} record ` +
123
+ `${schemaName}.${tableName} as their buckets table`);
109
124
  }
110
- // Entity-scoped: load all modules and probe entity tables
125
+ return rows[0];
126
+ }
127
+ /**
128
+ * The explicit provisionBucket mutation's resolution: find the plane that
129
+ * actually holds the named bucket row.
130
+ *
131
+ * With an ownerId, the owning entity row names the plane (probed through each
132
+ * entity-keyed module's recorded entity table). Without one, every
133
+ * non-entity-keyed plane's buckets table is probed for the key under RLS —
134
+ * exactly one plane may hold it; several is ambiguous and throws rather than
135
+ * silently picking.
136
+ */
137
+ async function resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId) {
111
138
  const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]);
112
139
  const modules = result.rows;
113
- const entityModules = modules.filter((m) => m.entity_schema && m.entity_table);
114
- for (const mod of entityModules) {
115
- const entityTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
116
- const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
117
- if (probe.rows.length > 0) {
118
- return mod;
140
+ if (modules.length === 0) {
141
+ throw new Error(`STORAGE_MODULE_NOT_PROVISIONED: database ${databaseId} has no storage modules`);
142
+ }
143
+ if (ownerId) {
144
+ const entityModules = modules.filter((m) => m.entity_table_id !== null && m.entity_schema && m.entity_table);
145
+ for (const mod of entityModules) {
146
+ const entityTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
147
+ const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
148
+ if (probe.rows.length === 0)
149
+ continue;
150
+ const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
151
+ const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
152
+ FROM ${bucketsTable}
153
+ WHERE key = $1 AND owner_id = $2
154
+ LIMIT 1`, [bucketKey, ownerId]);
155
+ if (bucketResult.rows.length === 0)
156
+ return null;
157
+ return { storageModule: mod, bucket: bucketResult.rows[0] };
158
+ }
159
+ return null;
160
+ }
161
+ const matches = [];
162
+ for (const mod of modules.filter((m) => m.entity_table_id === null)) {
163
+ const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
164
+ const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
165
+ FROM ${bucketsTable}
166
+ WHERE key = $1
167
+ LIMIT 1`, [bucketKey]);
168
+ if (bucketResult.rows.length > 0) {
169
+ matches.push({ storageModule: mod, bucket: bucketResult.rows[0] });
119
170
  }
120
171
  }
121
- return null;
172
+ if (matches.length === 0)
173
+ return null;
174
+ if (matches.length > 1) {
175
+ const scopes = matches.map((m) => `'${m.storageModule.scope}'`).join(', ');
176
+ throw new Error(`BUCKET_KEY_AMBIGUOUS: bucket key "${bucketKey}" exists in ${matches.length} storage planes ` +
177
+ `(scopes ${scopes}); provide an ownerId or use a plane-unique key`);
178
+ }
179
+ return matches[0];
122
180
  }
123
181
  /**
124
182
  * Normalize the recorded physical coordinate at the DB boundary.
@@ -211,10 +269,8 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
211
269
  * Core provisioning logic shared by both the explicit mutation and the
212
270
  * auto-provisioning hook.
213
271
  */
214
- async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
272
+ async function provisionBucketForRow(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
215
273
  const accessType = bucketType;
216
- // Read storage module config to check for endpoint/provider/CORS overrides
217
- const storageModule = await resolveStorageModule(pgClient, databaseId);
218
274
  // Resolve CORS origins using the 3-tier hierarchy
219
275
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
220
276
  const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
@@ -234,9 +290,8 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
234
290
  /**
235
291
  * Update CORS on an existing S3 bucket when allowed_origins changes.
236
292
  */
237
- async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
293
+ async function updateBucketCors(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
238
294
  const accessType = bucketType;
239
- const storageModule = await resolveStorageModule(pgClient, databaseId);
240
295
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
241
296
  const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
242
297
  log.info(`Updating CORS on S3 bucket "${s3BucketName}" ` +
@@ -328,29 +383,14 @@ function createBucketProvisionerPlugin(options) {
328
383
  if (!databaseId) {
329
384
  throw new Error('DATABASE_NOT_FOUND');
330
385
  }
331
- // Resolve storage module (app-level or entity-scoped via ownerId)
332
- const storageModule = await resolveStorageModule(pgClient, databaseId, ownerId);
333
- if (!storageModule) {
334
- throw new Error(ownerId
335
- ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
336
- : 'STORAGE_MODULE_NOT_PROVISIONED');
337
- }
338
- // Look up the bucket row (RLS enforced via pgSettings)
339
- const hasOwner = ownerId && storageModule.scope !== 'app';
340
- const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
341
- const bucketResult = await runQuery(pgClient, hasOwner
342
- ? `SELECT id, key, type, is_public, allowed_origins, physical_name
343
- FROM ${bucketsTable}
344
- WHERE key = $1 AND owner_id = $2
345
- LIMIT 1`
346
- : `SELECT id, key, type, is_public, allowed_origins, physical_name
347
- FROM ${bucketsTable}
348
- WHERE key = $1
349
- LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
350
- if (bucketResult.rows.length === 0) {
386
+ // Resolve the plane that actually holds the named bucket row
387
+ // (RLS enforced via pgSettings) — never a guessed scope.
388
+ const resolution = await resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId);
389
+ if (!resolution) {
351
390
  throw new Error('BUCKET_NOT_FOUND');
352
391
  }
353
- const bucket = bucketResult.rows[0];
392
+ const { storageModule, bucket } = resolution;
393
+ const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
354
394
  // First provision mints a name; afterwards the stored coordinate
355
395
  // is authoritative and the naming hook is never consulted again.
356
396
  const recorded = storedPhysicalName(bucket);
@@ -358,7 +398,7 @@ function createBucketProvisionerPlugin(options) {
358
398
  ? resolveBucketName(bucket.key, databaseId, options)
359
399
  : recorded;
360
400
  try {
361
- const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
401
+ const result = await provisionBucketForRow(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
362
402
  // Record the exact provisioned name on the source row.
363
403
  await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName);
364
404
  return {
@@ -431,6 +471,10 @@ function createBucketProvisionerPlugin(options) {
431
471
  return field;
432
472
  }
433
473
  log.debug(`Wrapping mutation "${fieldName}" for ${isCreate ? 'auto-provisioning' : 'CORS update'} (codec: ${pgCodec.name})`);
474
+ // The codec being mutated names the buckets table — the hook always
475
+ // operates on the plane that table belongs to, never a guessed scope.
476
+ const codecSchemaName = pgCodec.extensions?.pg?.schemaName;
477
+ const codecTableName = pgCodec.extensions?.pg?.name;
434
478
  const defaultResolver = (obj) => obj[fieldName];
435
479
  const { resolve: oldResolve = defaultResolver, ...rest } = field;
436
480
  return {
@@ -454,23 +498,29 @@ function createBucketProvisionerPlugin(options) {
454
498
  `could not extract key/type from mutation input`);
455
499
  return result;
456
500
  }
501
+ if (!codecSchemaName || !codecTableName) {
502
+ throw new Error(`Auto-provision failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
503
+ }
457
504
  await withPgClient(pgSettings, async (pgClient) => {
458
505
  const databaseId = await resolveDatabaseId(pgClient);
459
506
  if (!databaseId) {
460
507
  log.warn('Auto-provision skipped: could not resolve database_id');
461
508
  return;
462
509
  }
510
+ // The mutated table names its module — the plane being written
511
+ // is the plane that gets provisioned.
512
+ const storageModule = await resolveStorageModuleByBucketsTable(pgClient, databaseId, codecSchemaName, codecTableName);
463
513
  // Newly-created row has no stored coordinate yet — mint on first provision.
464
- const result = await provisionBucketForRow(pgClient, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
514
+ const result = await provisionBucketForRow(storageModule, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
465
515
  // Record the provisioned name on the just-created row.
466
- const storageModule = await resolveStorageModule(pgClient, databaseId);
467
- if (storageModule) {
468
- const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
469
- const idResult = await runQuery(pgClient, `SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, [bucketInput.key]);
470
- const bucketId = idResult.rows[0]?.id;
471
- if (bucketId)
472
- await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName);
473
- }
516
+ const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
517
+ const ownerId = bucketInput.ownerId ?? bucketInput.owner_id ?? null;
518
+ const idResult = await runQuery(pgClient, ownerId
519
+ ? `SELECT id FROM ${bucketsTable} WHERE key = $1 AND owner_id = $2 LIMIT 1`
520
+ : `SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, ownerId ? [bucketInput.key, ownerId] : [bucketInput.key]);
521
+ const bucketId = idResult.rows[0]?.id;
522
+ if (bucketId)
523
+ await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName);
474
524
  });
475
525
  }
476
526
  else {
@@ -481,18 +531,18 @@ function createBucketProvisionerPlugin(options) {
481
531
  // allowed_origins not being changed, nothing to do
482
532
  return result;
483
533
  }
534
+ if (!codecSchemaName || !codecTableName) {
535
+ throw new Error(`CORS update failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
536
+ }
484
537
  await withPgClient(pgSettings, async (pgClient) => {
485
538
  const databaseId = await resolveDatabaseId(pgClient);
486
539
  if (!databaseId) {
487
540
  log.warn('CORS update skipped: could not resolve database_id');
488
541
  return;
489
542
  }
490
- // Read the storage module config (app-level; auto-hook doesn't have ownerId context)
491
- const storageModule = await resolveStorageModule(pgClient, databaseId);
492
- if (!storageModule) {
493
- log.warn('CORS update skipped: storage module not provisioned');
494
- return;
495
- }
543
+ // The mutated table names its module — CORS applies to the
544
+ // plane whose row was updated.
545
+ const storageModule = await resolveStorageModuleByBucketsTable(pgClient, databaseId, codecSchemaName, codecTableName);
496
546
  // We need the bucket key — it may come from input or patch
497
547
  // For updates, PostGraphile uses nodeId or the row's PK, so
498
548
  // we read the bucket from the patch's key or from the nodeId
@@ -517,7 +567,7 @@ function createBucketProvisionerPlugin(options) {
517
567
  // never provisioned there is nothing to update yet, so mint the
518
568
  // conventional name the first provision would use.
519
569
  const recorded = storedPhysicalName(bucket);
520
- await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
570
+ await updateBucketCors(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
521
571
  ? resolveBucketName(bucket.key, databaseId, options)
522
572
  : recorded);
523
573
  });