graphile-bucket-provisioner-plugin 1.9.2 → 1.10.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.
package/esm/index.d.ts CHANGED
@@ -39,4 +39,4 @@
39
39
  */
40
40
  export { BucketProvisionerPlugin, createBucketProvisionerPlugin } from './plugin';
41
41
  export { BucketProvisionerPreset } from './preset';
42
- export type { BucketProvisionerPluginOptions, ConnectionConfigOrGetter, BucketNameResolver, ProvisionBucketInput, ProvisionBucketPayload, StorageConnectionConfig, StorageProvider, BucketAccessType, ProvisionResult, } from './types';
42
+ export type { BucketAccessType, BucketNameResolver, BucketProvisionerPluginOptions, ConnectionConfigOrGetter, ProvisionBucketInput, ProvisionBucketPayload, ProvisionResult, StorageConnectionConfig, StorageProvider, } from './types';
package/esm/plugin.js CHANGED
@@ -30,11 +30,11 @@
30
30
  * generated buckets table via a smart comment:
31
31
  * COMMENT ON TABLE buckets IS E'@storageBuckets\nStorage buckets table';
32
32
  */
33
- import { context as grafastContext, lambda, object } from 'grafast';
34
- import { extendSchema, gql } from 'graphile-utils';
33
+ import { BucketProvisioner, } from '@constructive-io/bucket-provisioner';
35
34
  import { Logger } from '@pgpmjs/logger';
36
35
  import { QuoteUtils } from '@pgsql/quotes';
37
- import { BucketProvisioner, } from '@constructive-io/bucket-provisioner';
36
+ import { context as grafastContext, lambda, object } from 'grafast';
37
+ import { extendSchema, gql } from 'graphile-utils';
38
38
  const log = new Logger('graphile-bucket-provisioner:plugin');
39
39
  // --- Storage module queries ---
40
40
  /**
@@ -105,6 +105,28 @@ async function resolveStorageModule(pgClient, databaseId, ownerId) {
105
105
  }
106
106
  return null;
107
107
  }
108
+ /**
109
+ * Normalize the recorded physical coordinate at the DB boundary.
110
+ *
111
+ * A bucket row either carries a recorded coordinate or it does not; SQL nulls
112
+ * and absent columns both mean "never provisioned". Collapsing them here is
113
+ * the single place that shape is interpreted — callers branch on `string`
114
+ * vs `null` and never coalesce a bucket name into existence.
115
+ */
116
+ function storedPhysicalName(row) {
117
+ return row.physical_name == null ? null : row.physical_name;
118
+ }
119
+ /**
120
+ * Record the physical S3 bucket name on the source bucket row.
121
+ *
122
+ * Runs in the system lane (`withPgClient(null, ...)`) — server bookkeeping,
123
+ * RLS-independent. Idempotent via the `physical_name IS NULL` guard so a
124
+ * re-provision never clobbers an already-recorded coordinate.
125
+ */
126
+ async function recordPhysicalName(withPgClient, bucketsTable, bucketId, physicalName) {
127
+ await withPgClient(null, (client) => client.query(`UPDATE ${bucketsTable} SET physical_name = $1 WHERE id = $2 AND physical_name IS NULL`, [physicalName, bucketId]));
128
+ log.info(`Recorded physical_name="${physicalName}" on bucket ${bucketId}`);
129
+ }
108
130
  // --- Helpers ---
109
131
  /**
110
132
  * Resolve the connection config from the options. If the option is a lazy
@@ -174,8 +196,7 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
174
196
  * Core provisioning logic shared by both the explicit mutation and the
175
197
  * auto-provisioning hook.
176
198
  */
177
- async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options) {
178
- const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
199
+ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
179
200
  const accessType = bucketType;
180
201
  // Read storage module config to check for endpoint/provider/CORS overrides
181
202
  const storageModule = await resolveStorageModule(pgClient, databaseId);
@@ -198,8 +219,7 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
198
219
  /**
199
220
  * Update CORS on an existing S3 bucket when allowed_origins changes.
200
221
  */
201
- async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options) {
202
- const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
222
+ async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
203
223
  const accessType = bucketType;
204
224
  const storageModule = await resolveStorageModule(pgClient, databaseId);
205
225
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
@@ -304,11 +324,11 @@ export function createBucketProvisionerPlugin(options) {
304
324
  const hasOwner = ownerId && storageModule.scope !== 'app';
305
325
  const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
306
326
  const bucketResult = await pgClient.query(hasOwner
307
- ? `SELECT id, key, type, is_public, allowed_origins
327
+ ? `SELECT id, key, type, is_public, allowed_origins, physical_name
308
328
  FROM ${bucketsTable}
309
329
  WHERE key = $1 AND owner_id = $2
310
330
  LIMIT 1`
311
- : `SELECT id, key, type, is_public, allowed_origins
331
+ : `SELECT id, key, type, is_public, allowed_origins, physical_name
312
332
  FROM ${bucketsTable}
313
333
  WHERE key = $1
314
334
  LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
@@ -316,8 +336,16 @@ export function createBucketProvisionerPlugin(options) {
316
336
  throw new Error('BUCKET_NOT_FOUND');
317
337
  }
318
338
  const bucket = bucketResult.rows[0];
339
+ // First provision mints a name; afterwards the stored coordinate
340
+ // is authoritative and the naming hook is never consulted again.
341
+ const recorded = storedPhysicalName(bucket);
342
+ const s3BucketName = recorded === null
343
+ ? resolveBucketName(bucket.key, databaseId, options)
344
+ : recorded;
319
345
  try {
320
- const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options);
346
+ const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
347
+ // Record the exact provisioned name on the source row.
348
+ await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName);
321
349
  return {
322
350
  success: true,
323
351
  bucketName: result.bucketName,
@@ -331,7 +359,7 @@ export function createBucketProvisionerPlugin(options) {
331
359
  log.error(`Failed to provision bucket "${bucketKey}": ${err.message}`);
332
360
  return {
333
361
  success: false,
334
- bucketName: resolveBucketName(bucket.key, databaseId, options),
362
+ bucketName: s3BucketName,
335
363
  accessType: bucket.type,
336
364
  provider: resolveConnection(options).provider,
337
365
  endpoint: resolveConnection(options).endpoint ?? null,
@@ -417,7 +445,17 @@ export function createBucketProvisionerPlugin(options) {
417
445
  log.warn('Auto-provision skipped: could not resolve database_id');
418
446
  return;
419
447
  }
420
- await provisionBucketForRow(pgClient, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options);
448
+ // Newly-created row has no stored coordinate yet — mint on first provision.
449
+ const result = await provisionBucketForRow(pgClient, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
450
+ // Record the provisioned name on the just-created row.
451
+ const storageModule = await resolveStorageModule(pgClient, databaseId);
452
+ if (storageModule) {
453
+ const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
454
+ const idResult = await pgClient.query(`SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, [bucketInput.key]);
455
+ const bucketId = idResult.rows[0]?.id;
456
+ if (bucketId)
457
+ await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName);
458
+ }
421
459
  });
422
460
  }
423
461
  else {
@@ -451,7 +489,7 @@ export function createBucketProvisionerPlugin(options) {
451
489
  }
452
490
  // Read the full bucket row (post-update) to get type + origins
453
491
  const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
454
- const bucketResult = await pgClient.query(`SELECT id, key, type, is_public, allowed_origins
492
+ const bucketResult = await pgClient.query(`SELECT id, key, type, is_public, allowed_origins, physical_name
455
493
  FROM ${bucketsTable}
456
494
  WHERE key = $1
457
495
  LIMIT 1`, [patchKey]);
@@ -460,7 +498,13 @@ export function createBucketProvisionerPlugin(options) {
460
498
  return;
461
499
  }
462
500
  const bucket = bucketResult.rows[0];
463
- await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options);
501
+ // CORS applies to the recorded physical bucket; if the row was
502
+ // never provisioned there is nothing to update yet, so mint the
503
+ // conventional name the first provision would use.
504
+ const recorded = storedPhysicalName(bucket);
505
+ await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
506
+ ? resolveBucketName(bucket.key, databaseId, options)
507
+ : recorded);
464
508
  });
465
509
  }
466
510
  }
package/esm/types.d.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * types used by the Graphile plugin to auto-provision S3 buckets when
6
6
  * bucket rows are created via GraphQL mutations.
7
7
  */
8
- import type { StorageConnectionConfig, StorageProvider, BucketAccessType, ProvisionResult } from '@constructive-io/bucket-provisioner';
9
- export type { StorageConnectionConfig, StorageProvider, BucketAccessType, ProvisionResult };
8
+ import type { BucketAccessType, ProvisionResult, StorageConnectionConfig, StorageProvider } from '@constructive-io/bucket-provisioner';
9
+ export type { BucketAccessType, ProvisionResult, StorageConnectionConfig, StorageProvider };
10
10
  /**
11
11
  * S3 connection configuration or a lazy getter that returns it on first use.
12
12
  *
package/index.d.ts CHANGED
@@ -39,4 +39,4 @@
39
39
  */
40
40
  export { BucketProvisionerPlugin, createBucketProvisionerPlugin } from './plugin';
41
41
  export { BucketProvisionerPreset } from './preset';
42
- export type { BucketProvisionerPluginOptions, ConnectionConfigOrGetter, BucketNameResolver, ProvisionBucketInput, ProvisionBucketPayload, StorageConnectionConfig, StorageProvider, BucketAccessType, ProvisionResult, } from './types';
42
+ export type { BucketAccessType, BucketNameResolver, BucketProvisionerPluginOptions, ConnectionConfigOrGetter, ProvisionBucketInput, ProvisionBucketPayload, ProvisionResult, StorageConnectionConfig, StorageProvider, } from './types';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-bucket-provisioner-plugin",
3
- "version": "1.9.2",
3
+ "version": "1.10.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",
@@ -40,9 +40,9 @@
40
40
  "url": "https://github.com/constructive-io/constructive/issues"
41
41
  },
42
42
  "dependencies": {
43
- "@constructive-io/bucket-provisioner": "^0.23.2",
44
- "@pgpmjs/logger": "^2.23.2",
45
- "@pgsql/quotes": "^18.1.0"
43
+ "@constructive-io/bucket-provisioner": "^0.23.3",
44
+ "@pgpmjs/logger": "^2.23.3",
45
+ "@pgsql/quotes": "^18.2.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "grafast": "1.0.2",
@@ -57,5 +57,5 @@
57
57
  "@types/node": "^22.19.11",
58
58
  "makage": "^0.3.0"
59
59
  },
60
- "gitHead": "90f447482b056a4949134565db2665f21d11aca3"
60
+ "gitHead": "c438821362ceaf70b2a20d74ecf25ad4191c1748"
61
61
  }
package/plugin.js CHANGED
@@ -34,11 +34,11 @@
34
34
  Object.defineProperty(exports, "__esModule", { value: true });
35
35
  exports.BucketProvisionerPlugin = void 0;
36
36
  exports.createBucketProvisionerPlugin = createBucketProvisionerPlugin;
37
- const grafast_1 = require("grafast");
38
- const graphile_utils_1 = require("graphile-utils");
37
+ const bucket_provisioner_1 = require("@constructive-io/bucket-provisioner");
39
38
  const logger_1 = require("@pgpmjs/logger");
40
39
  const quotes_1 = require("@pgsql/quotes");
41
- const bucket_provisioner_1 = require("@constructive-io/bucket-provisioner");
40
+ const grafast_1 = require("grafast");
41
+ 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
  /**
@@ -109,6 +109,28 @@ async function resolveStorageModule(pgClient, databaseId, ownerId) {
109
109
  }
110
110
  return null;
111
111
  }
112
+ /**
113
+ * Normalize the recorded physical coordinate at the DB boundary.
114
+ *
115
+ * A bucket row either carries a recorded coordinate or it does not; SQL nulls
116
+ * and absent columns both mean "never provisioned". Collapsing them here is
117
+ * the single place that shape is interpreted — callers branch on `string`
118
+ * vs `null` and never coalesce a bucket name into existence.
119
+ */
120
+ function storedPhysicalName(row) {
121
+ return row.physical_name == null ? null : row.physical_name;
122
+ }
123
+ /**
124
+ * Record the physical S3 bucket name on the source bucket row.
125
+ *
126
+ * Runs in the system lane (`withPgClient(null, ...)`) — server bookkeeping,
127
+ * RLS-independent. Idempotent via the `physical_name IS NULL` guard so a
128
+ * re-provision never clobbers an already-recorded coordinate.
129
+ */
130
+ async function recordPhysicalName(withPgClient, bucketsTable, bucketId, physicalName) {
131
+ await withPgClient(null, (client) => client.query(`UPDATE ${bucketsTable} SET physical_name = $1 WHERE id = $2 AND physical_name IS NULL`, [physicalName, bucketId]));
132
+ log.info(`Recorded physical_name="${physicalName}" on bucket ${bucketId}`);
133
+ }
112
134
  // --- Helpers ---
113
135
  /**
114
136
  * Resolve the connection config from the options. If the option is a lazy
@@ -178,8 +200,7 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
178
200
  * Core provisioning logic shared by both the explicit mutation and the
179
201
  * auto-provisioning hook.
180
202
  */
181
- async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options) {
182
- const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
203
+ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
183
204
  const accessType = bucketType;
184
205
  // Read storage module config to check for endpoint/provider/CORS overrides
185
206
  const storageModule = await resolveStorageModule(pgClient, databaseId);
@@ -202,8 +223,7 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
202
223
  /**
203
224
  * Update CORS on an existing S3 bucket when allowed_origins changes.
204
225
  */
205
- async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options) {
206
- const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
226
+ async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
207
227
  const accessType = bucketType;
208
228
  const storageModule = await resolveStorageModule(pgClient, databaseId);
209
229
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
@@ -308,11 +328,11 @@ function createBucketProvisionerPlugin(options) {
308
328
  const hasOwner = ownerId && storageModule.scope !== 'app';
309
329
  const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
310
330
  const bucketResult = await pgClient.query(hasOwner
311
- ? `SELECT id, key, type, is_public, allowed_origins
331
+ ? `SELECT id, key, type, is_public, allowed_origins, physical_name
312
332
  FROM ${bucketsTable}
313
333
  WHERE key = $1 AND owner_id = $2
314
334
  LIMIT 1`
315
- : `SELECT id, key, type, is_public, allowed_origins
335
+ : `SELECT id, key, type, is_public, allowed_origins, physical_name
316
336
  FROM ${bucketsTable}
317
337
  WHERE key = $1
318
338
  LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
@@ -320,8 +340,16 @@ function createBucketProvisionerPlugin(options) {
320
340
  throw new Error('BUCKET_NOT_FOUND');
321
341
  }
322
342
  const bucket = bucketResult.rows[0];
343
+ // First provision mints a name; afterwards the stored coordinate
344
+ // is authoritative and the naming hook is never consulted again.
345
+ const recorded = storedPhysicalName(bucket);
346
+ const s3BucketName = recorded === null
347
+ ? resolveBucketName(bucket.key, databaseId, options)
348
+ : recorded;
323
349
  try {
324
- const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options);
350
+ const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
351
+ // Record the exact provisioned name on the source row.
352
+ await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName);
325
353
  return {
326
354
  success: true,
327
355
  bucketName: result.bucketName,
@@ -335,7 +363,7 @@ function createBucketProvisionerPlugin(options) {
335
363
  log.error(`Failed to provision bucket "${bucketKey}": ${err.message}`);
336
364
  return {
337
365
  success: false,
338
- bucketName: resolveBucketName(bucket.key, databaseId, options),
366
+ bucketName: s3BucketName,
339
367
  accessType: bucket.type,
340
368
  provider: resolveConnection(options).provider,
341
369
  endpoint: resolveConnection(options).endpoint ?? null,
@@ -421,7 +449,17 @@ function createBucketProvisionerPlugin(options) {
421
449
  log.warn('Auto-provision skipped: could not resolve database_id');
422
450
  return;
423
451
  }
424
- await provisionBucketForRow(pgClient, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options);
452
+ // Newly-created row has no stored coordinate yet — mint on first provision.
453
+ const result = await provisionBucketForRow(pgClient, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
454
+ // Record the provisioned name on the just-created row.
455
+ const storageModule = await resolveStorageModule(pgClient, databaseId);
456
+ if (storageModule) {
457
+ const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
458
+ const idResult = await pgClient.query(`SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, [bucketInput.key]);
459
+ const bucketId = idResult.rows[0]?.id;
460
+ if (bucketId)
461
+ await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName);
462
+ }
425
463
  });
426
464
  }
427
465
  else {
@@ -455,7 +493,7 @@ function createBucketProvisionerPlugin(options) {
455
493
  }
456
494
  // Read the full bucket row (post-update) to get type + origins
457
495
  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
496
+ const bucketResult = await pgClient.query(`SELECT id, key, type, is_public, allowed_origins, physical_name
459
497
  FROM ${bucketsTable}
460
498
  WHERE key = $1
461
499
  LIMIT 1`, [patchKey]);
@@ -464,7 +502,13 @@ function createBucketProvisionerPlugin(options) {
464
502
  return;
465
503
  }
466
504
  const bucket = bucketResult.rows[0];
467
- await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options);
505
+ // CORS applies to the recorded physical bucket; if the row was
506
+ // never provisioned there is nothing to update yet, so mint the
507
+ // conventional name the first provision would use.
508
+ const recorded = storedPhysicalName(bucket);
509
+ await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
510
+ ? resolveBucketName(bucket.key, databaseId, options)
511
+ : recorded);
468
512
  });
469
513
  }
470
514
  }
package/types.d.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * types used by the Graphile plugin to auto-provision S3 buckets when
6
6
  * bucket rows are created via GraphQL mutations.
7
7
  */
8
- import type { StorageConnectionConfig, StorageProvider, BucketAccessType, ProvisionResult } from '@constructive-io/bucket-provisioner';
9
- export type { StorageConnectionConfig, StorageProvider, BucketAccessType, ProvisionResult };
8
+ import type { BucketAccessType, ProvisionResult, StorageConnectionConfig, StorageProvider } from '@constructive-io/bucket-provisioner';
9
+ export type { BucketAccessType, ProvisionResult, StorageConnectionConfig, StorageProvider };
10
10
  /**
11
11
  * S3 connection configuration or a lazy getter that returns it on first use.
12
12
  *