graphile-bucket-provisioner-plugin 1.9.3 → 1.11.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 +71 -16
  2. package/package.json +2 -2
  3. package/plugin.js +71 -16
package/esm/plugin.js CHANGED
@@ -81,6 +81,17 @@ const ALL_STORAGE_MODULES_QUERY = `
81
81
  LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
82
82
  WHERE sm.database_id = $1
83
83
  `;
84
+ /**
85
+ * Run a query against a grafast `withPgClient` client.
86
+ *
87
+ * That client's `query` takes the `{ text, values }` object form (the same
88
+ * shape the presigned-url plugin uses) — NOT node-pg's positional
89
+ * `(text, params)` args, which surface as "A query must have either text or a
90
+ * name" at runtime.
91
+ */
92
+ function runQuery(pgClient, text, values) {
93
+ return pgClient.query(values === undefined ? { text } : { text, values });
94
+ }
84
95
  /**
85
96
  * Resolve the storage module for a given scope.
86
97
  * If ownerId is provided, probes entity tables to find the matching module.
@@ -89,22 +100,44 @@ const ALL_STORAGE_MODULES_QUERY = `
89
100
  async function resolveStorageModule(pgClient, databaseId, ownerId) {
90
101
  if (!ownerId) {
91
102
  // App-level resolution
92
- const result = await pgClient.query(APP_STORAGE_MODULE_QUERY, [databaseId]);
103
+ const result = await runQuery(pgClient, APP_STORAGE_MODULE_QUERY, [databaseId]);
93
104
  return result.rows[0] ?? null;
94
105
  }
95
106
  // Entity-scoped: load all modules and probe entity tables
96
- const result = await pgClient.query(ALL_STORAGE_MODULES_QUERY, [databaseId]);
107
+ const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]);
97
108
  const modules = result.rows;
98
109
  const entityModules = modules.filter((m) => m.entity_schema && m.entity_table);
99
110
  for (const mod of entityModules) {
100
111
  const entityTable = QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
101
- const probe = await pgClient.query(`SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
112
+ const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
102
113
  if (probe.rows.length > 0) {
103
114
  return mod;
104
115
  }
105
116
  }
106
117
  return null;
107
118
  }
119
+ /**
120
+ * Normalize the recorded physical coordinate at the DB boundary.
121
+ *
122
+ * A bucket row either carries a recorded coordinate or it does not; SQL nulls
123
+ * and absent columns both mean "never provisioned". Collapsing them here is
124
+ * the single place that shape is interpreted — callers branch on `string`
125
+ * vs `null` and never coalesce a bucket name into existence.
126
+ */
127
+ function storedPhysicalName(row) {
128
+ return row.physical_name == null ? null : row.physical_name;
129
+ }
130
+ /**
131
+ * Record the physical S3 bucket name on the source bucket row.
132
+ *
133
+ * Runs in the system lane (`withPgClient(null, ...)`) — server bookkeeping,
134
+ * RLS-independent. Idempotent via the `physical_name IS NULL` guard so a
135
+ * re-provision never clobbers an already-recorded coordinate.
136
+ */
137
+ async function recordPhysicalName(withPgClient, bucketsTable, bucketId, physicalName) {
138
+ await withPgClient(null, (client) => runQuery(client, `UPDATE ${bucketsTable} SET physical_name = $1 WHERE id = $2 AND physical_name IS NULL`, [physicalName, bucketId]));
139
+ log.info(`Recorded physical_name="${physicalName}" on bucket ${bucketId}`);
140
+ }
108
141
  // --- Helpers ---
109
142
  /**
110
143
  * Resolve the connection config from the options. If the option is a lazy
@@ -135,7 +168,7 @@ function resolveBucketName(bucketKey, databaseId, options) {
135
168
  * Resolve the database_id from the JWT context.
136
169
  */
137
170
  async function resolveDatabaseId(pgClient) {
138
- const result = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
171
+ const result = await runQuery(pgClient, `SELECT jwt_private.current_database_id() AS id`);
139
172
  return result.rows[0]?.id ?? null;
140
173
  }
141
174
  /**
@@ -174,8 +207,7 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
174
207
  * Core provisioning logic shared by both the explicit mutation and the
175
208
  * auto-provisioning hook.
176
209
  */
177
- async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options) {
178
- const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
210
+ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
179
211
  const accessType = bucketType;
180
212
  // Read storage module config to check for endpoint/provider/CORS overrides
181
213
  const storageModule = await resolveStorageModule(pgClient, databaseId);
@@ -198,8 +230,7 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
198
230
  /**
199
231
  * Update CORS on an existing S3 bucket when allowed_origins changes.
200
232
  */
201
- async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options) {
202
- const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
233
+ async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
203
234
  const accessType = bucketType;
204
235
  const storageModule = await resolveStorageModule(pgClient, databaseId);
205
236
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
@@ -303,12 +334,12 @@ export function createBucketProvisionerPlugin(options) {
303
334
  // Look up the bucket row (RLS enforced via pgSettings)
304
335
  const hasOwner = ownerId && storageModule.scope !== 'app';
305
336
  const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
306
- const bucketResult = await pgClient.query(hasOwner
307
- ? `SELECT id, key, type, is_public, allowed_origins
337
+ const bucketResult = await runQuery(pgClient, hasOwner
338
+ ? `SELECT id, key, type, is_public, allowed_origins, physical_name
308
339
  FROM ${bucketsTable}
309
340
  WHERE key = $1 AND owner_id = $2
310
341
  LIMIT 1`
311
- : `SELECT id, key, type, is_public, allowed_origins
342
+ : `SELECT id, key, type, is_public, allowed_origins, physical_name
312
343
  FROM ${bucketsTable}
313
344
  WHERE key = $1
314
345
  LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
@@ -316,8 +347,16 @@ export function createBucketProvisionerPlugin(options) {
316
347
  throw new Error('BUCKET_NOT_FOUND');
317
348
  }
318
349
  const bucket = bucketResult.rows[0];
350
+ // First provision mints a name; afterwards the stored coordinate
351
+ // is authoritative and the naming hook is never consulted again.
352
+ const recorded = storedPhysicalName(bucket);
353
+ const s3BucketName = recorded === null
354
+ ? resolveBucketName(bucket.key, databaseId, options)
355
+ : recorded;
319
356
  try {
320
- const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options);
357
+ const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
358
+ // Record the exact provisioned name on the source row.
359
+ await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName);
321
360
  return {
322
361
  success: true,
323
362
  bucketName: result.bucketName,
@@ -331,7 +370,7 @@ export function createBucketProvisionerPlugin(options) {
331
370
  log.error(`Failed to provision bucket "${bucketKey}": ${err.message}`);
332
371
  return {
333
372
  success: false,
334
- bucketName: resolveBucketName(bucket.key, databaseId, options),
373
+ bucketName: s3BucketName,
335
374
  accessType: bucket.type,
336
375
  provider: resolveConnection(options).provider,
337
376
  endpoint: resolveConnection(options).endpoint ?? null,
@@ -417,7 +456,17 @@ export function createBucketProvisionerPlugin(options) {
417
456
  log.warn('Auto-provision skipped: could not resolve database_id');
418
457
  return;
419
458
  }
420
- await provisionBucketForRow(pgClient, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options);
459
+ // 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));
461
+ // 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
+ }
421
470
  });
422
471
  }
423
472
  else {
@@ -451,7 +500,7 @@ export function createBucketProvisionerPlugin(options) {
451
500
  }
452
501
  // Read the full bucket row (post-update) to get type + origins
453
502
  const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
454
- const bucketResult = await pgClient.query(`SELECT id, key, type, is_public, allowed_origins
503
+ const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
455
504
  FROM ${bucketsTable}
456
505
  WHERE key = $1
457
506
  LIMIT 1`, [patchKey]);
@@ -460,7 +509,13 @@ export function createBucketProvisionerPlugin(options) {
460
509
  return;
461
510
  }
462
511
  const bucket = bucketResult.rows[0];
463
- await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options);
512
+ // CORS applies to the recorded physical bucket; if the row was
513
+ // never provisioned there is nothing to update yet, so mint the
514
+ // conventional name the first provision would use.
515
+ const recorded = storedPhysicalName(bucket);
516
+ await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
517
+ ? resolveBucketName(bucket.key, databaseId, options)
518
+ : recorded);
464
519
  });
465
520
  }
466
521
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-bucket-provisioner-plugin",
3
- "version": "1.9.3",
3
+ "version": "1.11.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": "c20b6be96b39bf71c0e2df0f4b51e05deacd006b"
60
+ "gitHead": "247e48d25b13524d992f00c1b2dc063884d1deb1"
61
61
  }
package/plugin.js CHANGED
@@ -85,6 +85,17 @@ const ALL_STORAGE_MODULES_QUERY = `
85
85
  LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
86
86
  WHERE sm.database_id = $1
87
87
  `;
88
+ /**
89
+ * Run a query against a grafast `withPgClient` client.
90
+ *
91
+ * That client's `query` takes the `{ text, values }` object form (the same
92
+ * shape the presigned-url plugin uses) — NOT node-pg's positional
93
+ * `(text, params)` args, which surface as "A query must have either text or a
94
+ * name" at runtime.
95
+ */
96
+ function runQuery(pgClient, text, values) {
97
+ return pgClient.query(values === undefined ? { text } : { text, values });
98
+ }
88
99
  /**
89
100
  * Resolve the storage module for a given scope.
90
101
  * If ownerId is provided, probes entity tables to find the matching module.
@@ -93,22 +104,44 @@ const ALL_STORAGE_MODULES_QUERY = `
93
104
  async function resolveStorageModule(pgClient, databaseId, ownerId) {
94
105
  if (!ownerId) {
95
106
  // App-level resolution
96
- const result = await pgClient.query(APP_STORAGE_MODULE_QUERY, [databaseId]);
107
+ const result = await runQuery(pgClient, APP_STORAGE_MODULE_QUERY, [databaseId]);
97
108
  return result.rows[0] ?? null;
98
109
  }
99
110
  // Entity-scoped: load all modules and probe entity tables
100
- const result = await pgClient.query(ALL_STORAGE_MODULES_QUERY, [databaseId]);
111
+ const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]);
101
112
  const modules = result.rows;
102
113
  const entityModules = modules.filter((m) => m.entity_schema && m.entity_table);
103
114
  for (const mod of entityModules) {
104
115
  const entityTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
105
- const probe = await pgClient.query(`SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
116
+ const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
106
117
  if (probe.rows.length > 0) {
107
118
  return mod;
108
119
  }
109
120
  }
110
121
  return null;
111
122
  }
123
+ /**
124
+ * Normalize the recorded physical coordinate at the DB boundary.
125
+ *
126
+ * A bucket row either carries a recorded coordinate or it does not; SQL nulls
127
+ * and absent columns both mean "never provisioned". Collapsing them here is
128
+ * the single place that shape is interpreted — callers branch on `string`
129
+ * vs `null` and never coalesce a bucket name into existence.
130
+ */
131
+ function storedPhysicalName(row) {
132
+ return row.physical_name == null ? null : row.physical_name;
133
+ }
134
+ /**
135
+ * Record the physical S3 bucket name on the source bucket row.
136
+ *
137
+ * Runs in the system lane (`withPgClient(null, ...)`) — server bookkeeping,
138
+ * RLS-independent. Idempotent via the `physical_name IS NULL` guard so a
139
+ * re-provision never clobbers an already-recorded coordinate.
140
+ */
141
+ async function recordPhysicalName(withPgClient, bucketsTable, bucketId, physicalName) {
142
+ await withPgClient(null, (client) => runQuery(client, `UPDATE ${bucketsTable} SET physical_name = $1 WHERE id = $2 AND physical_name IS NULL`, [physicalName, bucketId]));
143
+ log.info(`Recorded physical_name="${physicalName}" on bucket ${bucketId}`);
144
+ }
112
145
  // --- Helpers ---
113
146
  /**
114
147
  * Resolve the connection config from the options. If the option is a lazy
@@ -139,7 +172,7 @@ function resolveBucketName(bucketKey, databaseId, options) {
139
172
  * Resolve the database_id from the JWT context.
140
173
  */
141
174
  async function resolveDatabaseId(pgClient) {
142
- const result = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
175
+ const result = await runQuery(pgClient, `SELECT jwt_private.current_database_id() AS id`);
143
176
  return result.rows[0]?.id ?? null;
144
177
  }
145
178
  /**
@@ -178,8 +211,7 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
178
211
  * Core provisioning logic shared by both the explicit mutation and the
179
212
  * auto-provisioning hook.
180
213
  */
181
- async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options) {
182
- const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
214
+ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
183
215
  const accessType = bucketType;
184
216
  // Read storage module config to check for endpoint/provider/CORS overrides
185
217
  const storageModule = await resolveStorageModule(pgClient, databaseId);
@@ -202,8 +234,7 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
202
234
  /**
203
235
  * Update CORS on an existing S3 bucket when allowed_origins changes.
204
236
  */
205
- async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options) {
206
- const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
237
+ async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
207
238
  const accessType = bucketType;
208
239
  const storageModule = await resolveStorageModule(pgClient, databaseId);
209
240
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
@@ -307,12 +338,12 @@ function createBucketProvisionerPlugin(options) {
307
338
  // Look up the bucket row (RLS enforced via pgSettings)
308
339
  const hasOwner = ownerId && storageModule.scope !== 'app';
309
340
  const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
310
- const bucketResult = await pgClient.query(hasOwner
311
- ? `SELECT id, key, type, is_public, allowed_origins
341
+ const bucketResult = await runQuery(pgClient, hasOwner
342
+ ? `SELECT id, key, type, is_public, allowed_origins, physical_name
312
343
  FROM ${bucketsTable}
313
344
  WHERE key = $1 AND owner_id = $2
314
345
  LIMIT 1`
315
- : `SELECT id, key, type, is_public, allowed_origins
346
+ : `SELECT id, key, type, is_public, allowed_origins, physical_name
316
347
  FROM ${bucketsTable}
317
348
  WHERE key = $1
318
349
  LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
@@ -320,8 +351,16 @@ function createBucketProvisionerPlugin(options) {
320
351
  throw new Error('BUCKET_NOT_FOUND');
321
352
  }
322
353
  const bucket = bucketResult.rows[0];
354
+ // First provision mints a name; afterwards the stored coordinate
355
+ // is authoritative and the naming hook is never consulted again.
356
+ const recorded = storedPhysicalName(bucket);
357
+ const s3BucketName = recorded === null
358
+ ? resolveBucketName(bucket.key, databaseId, options)
359
+ : recorded;
323
360
  try {
324
- const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options);
361
+ const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
362
+ // Record the exact provisioned name on the source row.
363
+ await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName);
325
364
  return {
326
365
  success: true,
327
366
  bucketName: result.bucketName,
@@ -335,7 +374,7 @@ function createBucketProvisionerPlugin(options) {
335
374
  log.error(`Failed to provision bucket "${bucketKey}": ${err.message}`);
336
375
  return {
337
376
  success: false,
338
- bucketName: resolveBucketName(bucket.key, databaseId, options),
377
+ bucketName: s3BucketName,
339
378
  accessType: bucket.type,
340
379
  provider: resolveConnection(options).provider,
341
380
  endpoint: resolveConnection(options).endpoint ?? null,
@@ -421,7 +460,17 @@ function createBucketProvisionerPlugin(options) {
421
460
  log.warn('Auto-provision skipped: could not resolve database_id');
422
461
  return;
423
462
  }
424
- await provisionBucketForRow(pgClient, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options);
463
+ // 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));
465
+ // 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
+ }
425
474
  });
426
475
  }
427
476
  else {
@@ -455,7 +504,7 @@ function createBucketProvisionerPlugin(options) {
455
504
  }
456
505
  // Read the full bucket row (post-update) to get type + origins
457
506
  const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
458
- const bucketResult = await pgClient.query(`SELECT id, key, type, is_public, allowed_origins
507
+ const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
459
508
  FROM ${bucketsTable}
460
509
  WHERE key = $1
461
510
  LIMIT 1`, [patchKey]);
@@ -464,7 +513,13 @@ function createBucketProvisionerPlugin(options) {
464
513
  return;
465
514
  }
466
515
  const bucket = bucketResult.rows[0];
467
- await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options);
516
+ // CORS applies to the recorded physical bucket; if the row was
517
+ // never provisioned there is nothing to update yet, so mint the
518
+ // conventional name the first provision would use.
519
+ const recorded = storedPhysicalName(bucket);
520
+ await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
521
+ ? resolveBucketName(bucket.key, databaseId, options)
522
+ : recorded);
468
523
  });
469
524
  }
470
525
  }