graphile-bucket-provisioner-plugin 1.14.0 → 1.14.1

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/README.md CHANGED
@@ -12,19 +12,16 @@
12
12
  <a href="https://www.npmjs.com/package/graphile-bucket-provisioner-plugin"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-bucket-provisioner-plugin%2Fpackage.json"/></a>
13
13
  </p>
14
14
 
15
- PostGraphile v5 plugin that automatically provisions S3-compatible buckets when bucket rows are created in the database. Wraps bucket creation mutations to call [`@constructive-io/bucket-provisioner`](../packages/bucket-provisioner) after the database row is inserted.
15
+ PostGraphile v5 plugin that explicitly provisions S3-compatible buckets through a GraphQL mutation using [`@constructive-io/bucket-provisioner`](../packages/bucket-provisioner).
16
16
 
17
17
  ## Features
18
18
 
19
- - **Auto-provisioning hook** — Wraps `create*` mutations on tables tagged with `@storageBuckets` to automatically provision S3 buckets after row creation
20
- - **CORS update hook** — Wraps `update*` mutations to detect `allowed_origins` changes and re-apply CORS rules to the S3 bucket
19
+ - **Explicit `provisionBucket` mutation** — GraphQL mutation for manual/retry provisioning of any bucket
21
20
  - **3-tier CORS resolution** — Bucket-level `allowed_origins` → storage module-level `allowed_origins` → plugin config `allowedOrigins`
22
21
  - **Wildcard CORS** — Set `allowed_origins = ['*']` on a bucket for fully open CDN/public deployments
23
- - **Explicit `provisionBucket` mutation** — GraphQL mutation for manual/retry provisioning of any bucket
24
22
  - **Per-database overrides** — Reads `endpoint`, `provider`, `public_url_prefix`, and `allowed_origins` from the `storage_module` table for multi-tenant setups
25
23
  - **Lazy S3 config** — Connection config can be a function (evaluated once, cached) to avoid eager env-var reads at import time
26
- - **Graceful error handling** — Provisioning and CORS update failures are logged but never fail the mutation (admin can retry via `provisionBucket`)
27
- - **Custom bucket naming** — Supports prefix-based naming or a fully custom `resolveBucketName` function
24
+ - **Deployment-controlled naming** — Requires a `resolveBucketName` policy for tenant-aware physical bucket names
28
25
 
29
26
  ## Installation
30
27
 
@@ -120,10 +117,8 @@ Creates the plugin instance. Returns a `GraphileConfig.Plugin`.
120
117
  |--------|------|-------------|
121
118
  | `connection` | `StorageConnectionConfig \| () => StorageConnectionConfig` | S3 connection config (static or lazy getter) |
122
119
  | `allowedOrigins` | `string[]` | CORS allowed origins for bucket configuration |
123
- | `bucketNamePrefix` | `string?` | Prefix for S3 bucket names (e.g., `"myapp"` → `"myapp-public"`) |
124
- | `resolveBucketName` | `(bucketKey, databaseId) => string` | Custom bucket name resolver (takes precedence over prefix) |
120
+ | `resolveBucketName` | `(databaseId, bucketKey) => string` | Deployment policy for deriving a tenant-aware physical bucket name |
125
121
  | `versioning` | `boolean?` | Enable S3 versioning on provisioned buckets (default: `false`) |
126
- | `autoProvision` | `boolean?` | Enable auto-provisioning hook on create mutations (default: `true`) |
127
122
 
128
123
  ### `BucketProvisionerPreset(options)`
129
124
 
package/esm/index.d.ts CHANGED
@@ -1,13 +1,7 @@
1
1
  /**
2
2
  * Bucket Provisioner Plugin for PostGraphile v5
3
3
  *
4
- * Provides automatic S3 bucket provisioning for PostGraphile v5.
5
- * When bucket rows are created via GraphQL mutations, this plugin
6
- * automatically provisions the corresponding S3 bucket with the
7
- * correct privacy policies, CORS rules, and lifecycle settings.
8
- *
9
- * Also provides an explicit `provisionBucket` mutation for manual
10
- * provisioning or re-provisioning of S3 buckets.
4
+ * Provides an explicit `provisionBucket` mutation for PostGraphile v5.
11
5
  *
12
6
  * @example
13
7
  * ```typescript
@@ -31,7 +25,6 @@
31
25
  * BucketProvisionerPreset({
32
26
  * connection: getConnection, // pass function ref, NOT getConnection()
33
27
  * allowedOrigins: ['https://app.example.com'],
34
- * bucketNamePrefix: 'myapp',
35
28
  * }),
36
29
  * ],
37
30
  * };
package/esm/index.js CHANGED
@@ -1,13 +1,7 @@
1
1
  /**
2
2
  * Bucket Provisioner Plugin for PostGraphile v5
3
3
  *
4
- * Provides automatic S3 bucket provisioning for PostGraphile v5.
5
- * When bucket rows are created via GraphQL mutations, this plugin
6
- * automatically provisions the corresponding S3 bucket with the
7
- * correct privacy policies, CORS rules, and lifecycle settings.
8
- *
9
- * Also provides an explicit `provisionBucket` mutation for manual
10
- * provisioning or re-provisioning of S3 buckets.
4
+ * Provides an explicit `provisionBucket` mutation for PostGraphile v5.
11
5
  *
12
6
  * @example
13
7
  * ```typescript
@@ -31,7 +25,6 @@
31
25
  * BucketProvisionerPreset({
32
26
  * connection: getConnection, // pass function ref, NOT getConnection()
33
27
  * allowedOrigins: ['https://app.example.com'],
34
- * bucketNamePrefix: 'myapp',
35
28
  * }),
36
29
  * ],
37
30
  * };
package/esm/plugin.d.ts CHANGED
@@ -7,21 +7,7 @@
7
7
  * logical bucket row in the database. Reads the bucket config via RLS,
8
8
  * then calls BucketProvisioner to create and configure the S3 bucket.
9
9
  *
10
- * 2. Auto-provisioning hook — wraps `create*` mutations on tables tagged
11
- * with `@storageBuckets` to automatically provision the S3 bucket after
12
- * the database row is created.
13
- *
14
- * 3. CORS update hook — wraps `update*` mutations on `@storageBuckets` tables
15
- * to detect changes to `allowed_origins` and re-apply CORS rules to the
16
- * S3 bucket.
17
- *
18
- * CORS resolution hierarchy (most specific wins):
19
- * 1. Bucket-level `allowed_origins` column (per-bucket override)
20
- * 2. Storage-module-level `allowed_origins` column (per-database default)
21
- * 3. Plugin config `allowedOrigins` (global fallback)
22
- * Supports `['*']` for open/CDN mode (wildcard CORS).
23
- *
24
- * Both pathways use `@constructive-io/bucket-provisioner` for the actual
10
+ * This plugin uses `@constructive-io/bucket-provisioner` for the actual
25
11
  * S3 operations (bucket creation, Block Public Access, CORS, policies,
26
12
  * versioning, lifecycle rules).
27
13
  *
@@ -35,16 +21,12 @@ import type { BucketProvisionerPluginOptions } from './types';
35
21
  /**
36
22
  * Creates the bucket provisioner plugin.
37
23
  *
38
- * This plugin provides two provisioning pathways:
24
+ * This plugin provides one provisioning pathway:
39
25
  *
40
26
  * 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
41
27
  * bucket key to provision (or re-provision) the S3 bucket. Protected
42
28
  * by RLS on the buckets table.
43
29
  *
44
- * 2. **Auto-provisioning hook** — When `autoProvision` is true (default),
45
- * wraps `create*` mutation resolvers on tables tagged with `@storageBuckets`
46
- * to automatically provision the S3 bucket after the row is created.
47
- *
48
30
  * @param options - Plugin configuration (S3 credentials, CORS origins, naming)
49
31
  */
50
32
  export declare function createBucketProvisionerPlugin(options: BucketProvisionerPluginOptions): GraphileConfig.Plugin;
package/esm/plugin.js CHANGED
@@ -7,21 +7,7 @@
7
7
  * logical bucket row in the database. Reads the bucket config via RLS,
8
8
  * then calls BucketProvisioner to create and configure the S3 bucket.
9
9
  *
10
- * 2. Auto-provisioning hook — wraps `create*` mutations on tables tagged
11
- * with `@storageBuckets` to automatically provision the S3 bucket after
12
- * the database row is created.
13
- *
14
- * 3. CORS update hook — wraps `update*` mutations on `@storageBuckets` tables
15
- * to detect changes to `allowed_origins` and re-apply CORS rules to the
16
- * S3 bucket.
17
- *
18
- * CORS resolution hierarchy (most specific wins):
19
- * 1. Bucket-level `allowed_origins` column (per-bucket override)
20
- * 2. Storage-module-level `allowed_origins` column (per-database default)
21
- * 3. Plugin config `allowedOrigins` (global fallback)
22
- * Supports `['*']` for open/CDN mode (wildcard CORS).
23
- *
24
- * Both pathways use `@constructive-io/bucket-provisioner` for the actual
10
+ * This plugin uses `@constructive-io/bucket-provisioner` for the actual
25
11
  * S3 operations (bucket creation, Block Public Access, CORS, policies,
26
12
  * versioning, lifecycle rules).
27
13
  *
@@ -34,33 +20,10 @@ import { BucketProvisioner, } from '@constructive-io/bucket-provisioner';
34
20
  import { Logger } from '@pgpmjs/logger';
35
21
  import { QuoteUtils } from '@pgsql/quotes';
36
22
  import { context as grafastContext, lambda, object } from 'grafast';
23
+ import { recordPhysicalName as recordPhysicalBucketName } from 'graphile-storage-registry';
37
24
  import { extendSchema, gql } from 'graphile-utils';
38
25
  const log = new Logger('graphile-bucket-provisioner:plugin');
39
26
  // --- Storage module queries ---
40
- /**
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.
45
- */
46
- const STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY = `
47
- SELECT
48
- sm.id,
49
- sm.scope,
50
- sm.entity_table_id,
51
- bs.schema_name AS buckets_schema,
52
- bt.name AS buckets_table,
53
- sm.endpoint,
54
- sm.public_url_prefix,
55
- sm.provider,
56
- sm.allowed_origins
57
- FROM metaschema_modules_public.storage_module sm
58
- JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
59
- JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
60
- WHERE sm.database_id = $1
61
- AND bs.schema_name = $2
62
- AND bt.name = $3
63
- `;
64
27
  /**
65
28
  * Resolve ALL storage modules for a database (for bucket-key and ownerId-based
66
29
  * resolution in the explicit provisionBucket mutation).
@@ -96,30 +59,6 @@ const ALL_STORAGE_MODULES_QUERY = `
96
59
  function runQuery(pgClient, text, values) {
97
60
  return pgClient.query(values === undefined ? { text } : { text, values });
98
61
  }
99
- /**
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.
105
- */
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`);
120
- }
121
- return rows[0];
122
- }
123
62
  /**
124
63
  * The explicit provisionBucket mutation's resolution: find the plane that
125
64
  * actually holds the named bucket row.
@@ -185,17 +124,6 @@ async function resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId) {
185
124
  function storedPhysicalName(row) {
186
125
  return row.physical_name == null ? null : row.physical_name;
187
126
  }
188
- /**
189
- * Record the physical S3 bucket name on the source bucket row.
190
- *
191
- * Runs in the system lane (`withPgClient(null, ...)`) — server bookkeeping,
192
- * RLS-independent. Idempotent via the `physical_name IS NULL` guard so a
193
- * re-provision never clobbers an already-recorded coordinate.
194
- */
195
- async function recordPhysicalName(withPgClient, bucketsTable, bucketId, physicalName) {
196
- await withPgClient(null, (client) => runQuery(client, `UPDATE ${bucketsTable} SET physical_name = $1 WHERE id = $2 AND physical_name IS NULL`, [physicalName, bucketId]));
197
- log.info(`Recorded physical_name="${physicalName}" on bucket ${bucketId}`);
198
- }
199
127
  // --- Helpers ---
200
128
  /**
201
129
  * Resolve the connection config from the options. If the option is a lazy
@@ -213,14 +141,14 @@ function resolveConnection(options) {
213
141
  /**
214
142
  * Resolve the S3 bucket name from a logical bucket key.
215
143
  */
216
- function resolveBucketName(bucketKey, databaseId, options) {
217
- if (options.resolveBucketName) {
218
- return options.resolveBucketName(bucketKey, databaseId);
144
+ function resolveBucketName(databaseId, bucketKey, options) {
145
+ if (!options.resolveBucketName) {
146
+ throw new Error('STORAGE_BUCKET_NAME_POLICY_MISSING: no resolveBucketName was configured, so there is ' +
147
+ `no name to provision for bucket "${bucketKey}" of database ${databaseId}. ` +
148
+ 'Physical bucket naming is a deployment policy; the configured s3.bucket is a ' +
149
+ 'connection default and is never a tenant bucket.');
219
150
  }
220
- if (options.bucketNamePrefix) {
221
- return `${options.bucketNamePrefix}-${bucketKey}`;
222
- }
223
- return bucketKey;
151
+ return options.resolveBucketName(databaseId, bucketKey);
224
152
  }
225
153
  /**
226
154
  * Resolve the database_id from the JWT context.
@@ -262,8 +190,7 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
262
190
  });
263
191
  }
264
192
  /**
265
- * Core provisioning logic shared by both the explicit mutation and the
266
- * auto-provisioning hook.
193
+ * Core provisioning logic for the explicit mutation.
267
194
  */
268
195
  async function provisionBucketForRow(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
269
196
  const accessType = bucketType;
@@ -283,40 +210,19 @@ async function provisionBucketForRow(storageModule, databaseId, bucketKey, bucke
283
210
  `(provider=${result.provider}, blockPublicAccess=${result.blockPublicAccess})`);
284
211
  return result;
285
212
  }
286
- /**
287
- * Update CORS on an existing S3 bucket when allowed_origins changes.
288
- */
289
- async function updateBucketCors(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
290
- const accessType = bucketType;
291
- const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
292
- const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
293
- log.info(`Updating CORS on S3 bucket "${s3BucketName}" ` +
294
- `(origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`);
295
- await provisioner.updateCors({
296
- bucketName: s3BucketName,
297
- accessType,
298
- allowedOrigins: effectiveOrigins,
299
- });
300
- log.info(`Successfully updated CORS on S3 bucket "${s3BucketName}"`);
301
- }
302
213
  // --- Plugin factory ---
303
214
  /**
304
215
  * Creates the bucket provisioner plugin.
305
216
  *
306
- * This plugin provides two provisioning pathways:
217
+ * This plugin provides one provisioning pathway:
307
218
  *
308
219
  * 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
309
220
  * bucket key to provision (or re-provision) the S3 bucket. Protected
310
221
  * by RLS on the buckets table.
311
222
  *
312
- * 2. **Auto-provisioning hook** — When `autoProvision` is true (default),
313
- * wraps `create*` mutation resolvers on tables tagged with `@storageBuckets`
314
- * to automatically provision the S3 bucket after the row is created.
315
- *
316
223
  * @param options - Plugin configuration (S3 credentials, CORS origins, naming)
317
224
  */
318
225
  export function createBucketProvisionerPlugin(options) {
319
- const autoProvision = options.autoProvision ?? true;
320
226
  // The extendSchema plugin adds the explicit provisionBucket mutation
321
227
  const mutationPlugin = extendSchema(() => ({
322
228
  typeDefs: gql `
@@ -391,12 +297,13 @@ export function createBucketProvisionerPlugin(options) {
391
297
  // is authoritative and the naming hook is never consulted again.
392
298
  const recorded = storedPhysicalName(bucket);
393
299
  const s3BucketName = recorded === null
394
- ? resolveBucketName(bucket.key, databaseId, options)
300
+ ? resolveBucketName(databaseId, bucket.key, options)
395
301
  : recorded;
396
302
  try {
397
303
  const result = await provisionBucketForRow(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
398
304
  // Record the exact provisioned name on the source row.
399
- await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName);
305
+ await withPgClient(null, (client) => recordPhysicalBucketName((query) => runQuery(client, query.text, query.values), bucketsTable, bucket.id, result.bucketName));
306
+ log.info(`Recorded physical_name="${result.bucketName}" on bucket ${bucket.id}`);
400
307
  return {
401
308
  success: true,
402
309
  bucketName: result.bucketName,
@@ -423,165 +330,7 @@ export function createBucketProvisionerPlugin(options) {
423
330
  },
424
331
  },
425
332
  }));
426
- // If autoProvision is disabled, return only the mutation plugin
427
- if (!autoProvision) {
428
- return mutationPlugin;
429
- }
430
- // Build a composite plugin that includes both the mutation and the hook
431
- return {
432
- ...mutationPlugin,
433
- name: 'BucketProvisionerPlugin',
434
- version: '0.1.0',
435
- description: 'Auto-provisions S3 buckets when bucket rows are created, ' +
436
- 'updates CORS when allowed_origins changes on update, ' +
437
- 'and provides a provisionBucket mutation for explicit provisioning',
438
- after: ['PgAttributesPlugin', 'PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin'],
439
- schema: {
440
- ...mutationPlugin.schema,
441
- hooks: {
442
- ...(mutationPlugin.schema?.hooks ?? {}),
443
- /**
444
- * Wrap create and update mutation resolvers on tables tagged with @storageBuckets.
445
- *
446
- * - create*: After the row is created, provision the S3 bucket.
447
- * - update*: After the row is updated, re-apply CORS if allowed_origins changed.
448
- *
449
- * If provisioning/CORS update fails, the DB row still exists (the mutation
450
- * already committed), and the error is logged. Admin can retry via provisionBucket.
451
- */
452
- GraphQLObjectType_fields_field(field, build, context) {
453
- const { scope: { isRootMutation, fieldName, pgCodec }, } = context;
454
- // Only wrap root mutation fields
455
- if (!isRootMutation || !pgCodec || !pgCodec.attributes) {
456
- return field;
457
- }
458
- // Check for @storageBuckets smart tag
459
- const tags = pgCodec.extensions?.tags;
460
- if (!tags?.storageBuckets) {
461
- return field;
462
- }
463
- const isCreate = fieldName.startsWith('create');
464
- const isUpdate = fieldName.startsWith('update');
465
- // Only wrap create and update mutations (not delete)
466
- if (!isCreate && !isUpdate) {
467
- return field;
468
- }
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;
474
- const defaultResolver = (obj) => obj[fieldName];
475
- const { resolve: oldResolve = defaultResolver, ...rest } = field;
476
- return {
477
- ...rest,
478
- async resolve(source, args, graphqlContext, info) {
479
- // Call the original resolver first (creates/updates the DB row)
480
- const result = await oldResolve(source, args, graphqlContext, info);
481
- try {
482
- const inputKey = Object.keys(args.input || {}).find((k) => k !== 'clientMutationId');
483
- const bucketInput = inputKey ? args.input[inputKey] : null;
484
- const withPgClient = graphqlContext.withPgClient;
485
- const pgSettings = graphqlContext.pgSettings;
486
- if (!withPgClient) {
487
- log.warn(`${isCreate ? 'Auto-provision' : 'CORS update'} skipped: withPgClient not available in context`);
488
- return result;
489
- }
490
- if (isCreate) {
491
- // --- CREATE: full provisioning ---
492
- if (!bucketInput?.key || !bucketInput?.type) {
493
- log.warn(`Auto-provision skipped for "${fieldName}": ` +
494
- `could not extract key/type from mutation input`);
495
- return result;
496
- }
497
- if (!codecSchemaName || !codecTableName) {
498
- throw new Error(`Auto-provision failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
499
- }
500
- await withPgClient(pgSettings, async (pgClient) => {
501
- const databaseId = await resolveDatabaseId(pgClient);
502
- if (!databaseId) {
503
- log.warn('Auto-provision skipped: could not resolve database_id');
504
- return;
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);
509
- // Newly-created row has no stored coordinate yet — mint on first provision.
510
- const result = await provisionBucketForRow(storageModule, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
511
- // Record the provisioned name on the just-created row.
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);
520
- });
521
- }
522
- else {
523
- // --- UPDATE: re-apply CORS if allowed_origins is in the patch ---
524
- const hasOriginsUpdate = bucketInput &&
525
- ('allowedOrigins' in bucketInput || 'allowed_origins' in bucketInput);
526
- if (!hasOriginsUpdate) {
527
- // allowed_origins not being changed, nothing to do
528
- return result;
529
- }
530
- if (!codecSchemaName || !codecTableName) {
531
- throw new Error(`CORS update failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
532
- }
533
- await withPgClient(pgSettings, async (pgClient) => {
534
- const databaseId = await resolveDatabaseId(pgClient);
535
- if (!databaseId) {
536
- log.warn('CORS update skipped: could not resolve database_id');
537
- return;
538
- }
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);
542
- // We need the bucket key — it may come from input or patch
543
- // For updates, PostGraphile uses nodeId or the row's PK, so
544
- // we read the bucket from the patch's key or from the nodeId
545
- const patchKey = bucketInput?.key;
546
- if (!patchKey) {
547
- log.warn(`CORS update skipped for "${fieldName}": ` +
548
- `could not determine bucket key from mutation input`);
549
- return;
550
- }
551
- // Read the full bucket row (post-update) to get type + origins
552
- const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
553
- const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
554
- FROM ${bucketsTable}
555
- WHERE key = $1
556
- LIMIT 1`, [patchKey]);
557
- if (bucketResult.rows.length === 0) {
558
- log.warn(`CORS update skipped: bucket "${patchKey}" not found`);
559
- return;
560
- }
561
- const bucket = bucketResult.rows[0];
562
- // CORS applies to the recorded physical bucket; if the row was
563
- // never provisioned there is nothing to update yet, so mint the
564
- // conventional name the first provision would use.
565
- const recorded = storedPhysicalName(bucket);
566
- await updateBucketCors(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
567
- ? resolveBucketName(bucket.key, databaseId, options)
568
- : recorded);
569
- });
570
- }
571
- }
572
- catch (err) {
573
- log.error(`${isCreate ? 'Auto-provision' : 'CORS update'} failed for "${fieldName}": ${err.message}. ` +
574
- (isCreate
575
- ? `The bucket row was created but the S3 bucket was not provisioned. Use the provisionBucket mutation to retry.`
576
- : `The bucket row was updated but CORS was not applied to the S3 bucket. Use the provisionBucket mutation to retry.`));
577
- }
578
- return result;
579
- },
580
- };
581
- },
582
- },
583
- },
584
- };
333
+ return mutationPlugin;
585
334
  }
586
335
  export const BucketProvisionerPlugin = createBucketProvisionerPlugin;
587
336
  export default BucketProvisionerPlugin;
package/esm/preset.d.ts CHANGED
@@ -31,7 +31,6 @@ import type { BucketProvisionerPluginOptions } from './types';
31
31
  * BucketProvisionerPreset({
32
32
  * connection: getConnection, // pass function ref, NOT getConnection()
33
33
  * allowedOrigins: ['https://app.example.com'],
34
- * bucketNamePrefix: 'myapp',
35
34
  * }),
36
35
  * ],
37
36
  * };
package/esm/preset.js CHANGED
@@ -30,7 +30,6 @@ import { createBucketProvisionerPlugin } from './plugin';
30
30
  * BucketProvisionerPreset({
31
31
  * connection: getConnection, // pass function ref, NOT getConnection()
32
32
  * allowedOrigins: ['https://app.example.com'],
33
- * bucketNamePrefix: 'myapp',
34
33
  * }),
35
34
  * ],
36
35
  * };
package/esm/types.d.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  /**
2
2
  * Types for the bucket provisioner plugin.
3
- *
4
- * Defines plugin options, connection configuration, and provisioning result
5
- * types used by the Graphile plugin to auto-provision S3 buckets when
6
- * bucket rows are created via GraphQL mutations.
7
3
  */
8
4
  import type { BucketAccessType, ProvisionResult, StorageConnectionConfig, StorageProvider } from '@constructive-io/bucket-provisioner';
9
5
  export type { BucketAccessType, ProvisionResult, StorageConnectionConfig, StorageProvider };
@@ -18,11 +14,11 @@ export type ConnectionConfigOrGetter = StorageConnectionConfig | (() => StorageC
18
14
  /**
19
15
  * Function to derive the actual S3 bucket name from a logical bucket key.
20
16
  *
21
- * @param bucketKey - The logical bucket key from the database (e.g., "public", "private")
22
17
  * @param databaseId - The metaschema database UUID
18
+ * @param bucketKey - The logical bucket key from the database (e.g., "public", "private")
23
19
  * @returns The S3 bucket name to create/configure
24
20
  */
25
- export type BucketNameResolver = (bucketKey: string, databaseId: string) => string;
21
+ export type BucketNameResolver = (databaseId: string, bucketKey: string) => string;
26
22
  /**
27
23
  * Plugin options for the bucket provisioner plugin.
28
24
  */
@@ -38,15 +34,9 @@ export interface BucketProvisionerPluginOptions {
38
34
  * Required for browser-based presigned URL uploads.
39
35
  */
40
36
  allowedOrigins: string[];
41
- /**
42
- * Optional prefix for S3 bucket names.
43
- * When set, the S3 bucket name becomes `{prefix}-{bucketKey}`.
44
- * Example: prefix "myapp" + key "public" → S3 bucket "myapp-public"
45
- */
46
- bucketNamePrefix?: string;
47
37
  /**
48
38
  * Optional custom function to derive S3 bucket names from logical bucket keys.
49
- * Takes precedence over `bucketNamePrefix` when provided.
39
+ * Naming is a deployment policy and must be supplied by the caller.
50
40
  */
51
41
  resolveBucketName?: BucketNameResolver;
52
42
  /**
@@ -54,15 +44,6 @@ export interface BucketProvisionerPluginOptions {
54
44
  * Default: false
55
45
  */
56
46
  versioning?: boolean;
57
- /**
58
- * Whether to auto-provision S3 buckets when bucket rows are created
59
- * via GraphQL mutations. When true, the plugin wraps create mutations
60
- * on tables tagged with `@storageBuckets` to trigger provisioning
61
- * after the mutation succeeds.
62
- *
63
- * Default: true
64
- */
65
- autoProvision?: boolean;
66
47
  }
67
48
  /**
68
49
  * Input for the provisionBucket mutation.
package/esm/types.js CHANGED
@@ -1,8 +1,4 @@
1
1
  /**
2
2
  * Types for the bucket provisioner plugin.
3
- *
4
- * Defines plugin options, connection configuration, and provisioning result
5
- * types used by the Graphile plugin to auto-provision S3 buckets when
6
- * bucket rows are created via GraphQL mutations.
7
3
  */
8
4
  export {};
package/index.d.ts CHANGED
@@ -1,13 +1,7 @@
1
1
  /**
2
2
  * Bucket Provisioner Plugin for PostGraphile v5
3
3
  *
4
- * Provides automatic S3 bucket provisioning for PostGraphile v5.
5
- * When bucket rows are created via GraphQL mutations, this plugin
6
- * automatically provisions the corresponding S3 bucket with the
7
- * correct privacy policies, CORS rules, and lifecycle settings.
8
- *
9
- * Also provides an explicit `provisionBucket` mutation for manual
10
- * provisioning or re-provisioning of S3 buckets.
4
+ * Provides an explicit `provisionBucket` mutation for PostGraphile v5.
11
5
  *
12
6
  * @example
13
7
  * ```typescript
@@ -31,7 +25,6 @@
31
25
  * BucketProvisionerPreset({
32
26
  * connection: getConnection, // pass function ref, NOT getConnection()
33
27
  * allowedOrigins: ['https://app.example.com'],
34
- * bucketNamePrefix: 'myapp',
35
28
  * }),
36
29
  * ],
37
30
  * };
package/index.js CHANGED
@@ -2,13 +2,7 @@
2
2
  /**
3
3
  * Bucket Provisioner Plugin for PostGraphile v5
4
4
  *
5
- * Provides automatic S3 bucket provisioning for PostGraphile v5.
6
- * When bucket rows are created via GraphQL mutations, this plugin
7
- * automatically provisions the corresponding S3 bucket with the
8
- * correct privacy policies, CORS rules, and lifecycle settings.
9
- *
10
- * Also provides an explicit `provisionBucket` mutation for manual
11
- * provisioning or re-provisioning of S3 buckets.
5
+ * Provides an explicit `provisionBucket` mutation for PostGraphile v5.
12
6
  *
13
7
  * @example
14
8
  * ```typescript
@@ -32,7 +26,6 @@
32
26
  * BucketProvisionerPreset({
33
27
  * connection: getConnection, // pass function ref, NOT getConnection()
34
28
  * allowedOrigins: ['https://app.example.com'],
35
- * bucketNamePrefix: 'myapp',
36
29
  * }),
37
30
  * ],
38
31
  * };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "graphile-bucket-provisioner-plugin",
3
- "version": "1.14.0",
4
- "description": "Bucket provisioning plugin for PostGraphile v5 — auto-provisions S3 buckets on bucket table mutations",
3
+ "version": "1.14.1",
4
+ "description": "Bucket provisioning plugin for PostGraphile v5 — explicitly provisions S3 buckets via a GraphQL mutation",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
7
7
  "license": "MIT",
@@ -40,9 +40,10 @@
40
40
  "url": "https://github.com/constructive-io/constructive/issues"
41
41
  },
42
42
  "dependencies": {
43
- "@constructive-io/bucket-provisioner": "^0.25.0",
43
+ "@constructive-io/bucket-provisioner": "^0.25.1",
44
44
  "@pgpmjs/logger": "^2.25.0",
45
- "@pgsql/quotes": "^18.2.4"
45
+ "@pgsql/quotes": "^18.2.4",
46
+ "graphile-storage-registry": "^0.1.1"
46
47
  },
47
48
  "peerDependencies": {
48
49
  "grafast": "^1.1.1",
@@ -57,5 +58,5 @@
57
58
  "@types/node": "^22.19.11",
58
59
  "makage": "^0.3.0"
59
60
  },
60
- "gitHead": "d10fe91ede80a4c2dfe2c1c6dcf6fe7193784eba"
61
+ "gitHead": "7e6cc68f5659d12ec852835a7d8fcca6b05a8f50"
61
62
  }
package/plugin.d.ts CHANGED
@@ -7,21 +7,7 @@
7
7
  * logical bucket row in the database. Reads the bucket config via RLS,
8
8
  * then calls BucketProvisioner to create and configure the S3 bucket.
9
9
  *
10
- * 2. Auto-provisioning hook — wraps `create*` mutations on tables tagged
11
- * with `@storageBuckets` to automatically provision the S3 bucket after
12
- * the database row is created.
13
- *
14
- * 3. CORS update hook — wraps `update*` mutations on `@storageBuckets` tables
15
- * to detect changes to `allowed_origins` and re-apply CORS rules to the
16
- * S3 bucket.
17
- *
18
- * CORS resolution hierarchy (most specific wins):
19
- * 1. Bucket-level `allowed_origins` column (per-bucket override)
20
- * 2. Storage-module-level `allowed_origins` column (per-database default)
21
- * 3. Plugin config `allowedOrigins` (global fallback)
22
- * Supports `['*']` for open/CDN mode (wildcard CORS).
23
- *
24
- * Both pathways use `@constructive-io/bucket-provisioner` for the actual
10
+ * This plugin uses `@constructive-io/bucket-provisioner` for the actual
25
11
  * S3 operations (bucket creation, Block Public Access, CORS, policies,
26
12
  * versioning, lifecycle rules).
27
13
  *
@@ -35,16 +21,12 @@ import type { BucketProvisionerPluginOptions } from './types';
35
21
  /**
36
22
  * Creates the bucket provisioner plugin.
37
23
  *
38
- * This plugin provides two provisioning pathways:
24
+ * This plugin provides one provisioning pathway:
39
25
  *
40
26
  * 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
41
27
  * bucket key to provision (or re-provision) the S3 bucket. Protected
42
28
  * by RLS on the buckets table.
43
29
  *
44
- * 2. **Auto-provisioning hook** — When `autoProvision` is true (default),
45
- * wraps `create*` mutation resolvers on tables tagged with `@storageBuckets`
46
- * to automatically provision the S3 bucket after the row is created.
47
- *
48
30
  * @param options - Plugin configuration (S3 credentials, CORS origins, naming)
49
31
  */
50
32
  export declare function createBucketProvisionerPlugin(options: BucketProvisionerPluginOptions): GraphileConfig.Plugin;
package/plugin.js CHANGED
@@ -8,21 +8,7 @@
8
8
  * logical bucket row in the database. Reads the bucket config via RLS,
9
9
  * then calls BucketProvisioner to create and configure the S3 bucket.
10
10
  *
11
- * 2. Auto-provisioning hook — wraps `create*` mutations on tables tagged
12
- * with `@storageBuckets` to automatically provision the S3 bucket after
13
- * the database row is created.
14
- *
15
- * 3. CORS update hook — wraps `update*` mutations on `@storageBuckets` tables
16
- * to detect changes to `allowed_origins` and re-apply CORS rules to the
17
- * S3 bucket.
18
- *
19
- * CORS resolution hierarchy (most specific wins):
20
- * 1. Bucket-level `allowed_origins` column (per-bucket override)
21
- * 2. Storage-module-level `allowed_origins` column (per-database default)
22
- * 3. Plugin config `allowedOrigins` (global fallback)
23
- * Supports `['*']` for open/CDN mode (wildcard CORS).
24
- *
25
- * Both pathways use `@constructive-io/bucket-provisioner` for the actual
11
+ * This plugin uses `@constructive-io/bucket-provisioner` for the actual
26
12
  * S3 operations (bucket creation, Block Public Access, CORS, policies,
27
13
  * versioning, lifecycle rules).
28
14
  *
@@ -38,33 +24,10 @@ const bucket_provisioner_1 = require("@constructive-io/bucket-provisioner");
38
24
  const logger_1 = require("@pgpmjs/logger");
39
25
  const quotes_1 = require("@pgsql/quotes");
40
26
  const grafast_1 = require("grafast");
27
+ const graphile_storage_registry_1 = require("graphile-storage-registry");
41
28
  const graphile_utils_1 = require("graphile-utils");
42
29
  const log = new logger_1.Logger('graphile-bucket-provisioner:plugin');
43
30
  // --- Storage module queries ---
44
- /**
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.
49
- */
50
- const STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY = `
51
- SELECT
52
- sm.id,
53
- sm.scope,
54
- sm.entity_table_id,
55
- bs.schema_name AS buckets_schema,
56
- bt.name AS buckets_table,
57
- sm.endpoint,
58
- sm.public_url_prefix,
59
- sm.provider,
60
- sm.allowed_origins
61
- FROM metaschema_modules_public.storage_module sm
62
- JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
63
- JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
64
- WHERE sm.database_id = $1
65
- AND bs.schema_name = $2
66
- AND bt.name = $3
67
- `;
68
31
  /**
69
32
  * Resolve ALL storage modules for a database (for bucket-key and ownerId-based
70
33
  * resolution in the explicit provisionBucket mutation).
@@ -100,30 +63,6 @@ const ALL_STORAGE_MODULES_QUERY = `
100
63
  function runQuery(pgClient, text, values) {
101
64
  return pgClient.query(values === undefined ? { text } : { text, values });
102
65
  }
103
- /**
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.
109
- */
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`);
124
- }
125
- return rows[0];
126
- }
127
66
  /**
128
67
  * The explicit provisionBucket mutation's resolution: find the plane that
129
68
  * actually holds the named bucket row.
@@ -189,17 +128,6 @@ async function resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId) {
189
128
  function storedPhysicalName(row) {
190
129
  return row.physical_name == null ? null : row.physical_name;
191
130
  }
192
- /**
193
- * Record the physical S3 bucket name on the source bucket row.
194
- *
195
- * Runs in the system lane (`withPgClient(null, ...)`) — server bookkeeping,
196
- * RLS-independent. Idempotent via the `physical_name IS NULL` guard so a
197
- * re-provision never clobbers an already-recorded coordinate.
198
- */
199
- async function recordPhysicalName(withPgClient, bucketsTable, bucketId, physicalName) {
200
- await withPgClient(null, (client) => runQuery(client, `UPDATE ${bucketsTable} SET physical_name = $1 WHERE id = $2 AND physical_name IS NULL`, [physicalName, bucketId]));
201
- log.info(`Recorded physical_name="${physicalName}" on bucket ${bucketId}`);
202
- }
203
131
  // --- Helpers ---
204
132
  /**
205
133
  * Resolve the connection config from the options. If the option is a lazy
@@ -217,14 +145,14 @@ function resolveConnection(options) {
217
145
  /**
218
146
  * Resolve the S3 bucket name from a logical bucket key.
219
147
  */
220
- function resolveBucketName(bucketKey, databaseId, options) {
221
- if (options.resolveBucketName) {
222
- return options.resolveBucketName(bucketKey, databaseId);
148
+ function resolveBucketName(databaseId, bucketKey, options) {
149
+ if (!options.resolveBucketName) {
150
+ throw new Error('STORAGE_BUCKET_NAME_POLICY_MISSING: no resolveBucketName was configured, so there is ' +
151
+ `no name to provision for bucket "${bucketKey}" of database ${databaseId}. ` +
152
+ 'Physical bucket naming is a deployment policy; the configured s3.bucket is a ' +
153
+ 'connection default and is never a tenant bucket.');
223
154
  }
224
- if (options.bucketNamePrefix) {
225
- return `${options.bucketNamePrefix}-${bucketKey}`;
226
- }
227
- return bucketKey;
155
+ return options.resolveBucketName(databaseId, bucketKey);
228
156
  }
229
157
  /**
230
158
  * Resolve the database_id from the JWT context.
@@ -266,8 +194,7 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
266
194
  });
267
195
  }
268
196
  /**
269
- * Core provisioning logic shared by both the explicit mutation and the
270
- * auto-provisioning hook.
197
+ * Core provisioning logic for the explicit mutation.
271
198
  */
272
199
  async function provisionBucketForRow(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
273
200
  const accessType = bucketType;
@@ -287,40 +214,19 @@ async function provisionBucketForRow(storageModule, databaseId, bucketKey, bucke
287
214
  `(provider=${result.provider}, blockPublicAccess=${result.blockPublicAccess})`);
288
215
  return result;
289
216
  }
290
- /**
291
- * Update CORS on an existing S3 bucket when allowed_origins changes.
292
- */
293
- async function updateBucketCors(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
294
- const accessType = bucketType;
295
- const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
296
- const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
297
- log.info(`Updating CORS on S3 bucket "${s3BucketName}" ` +
298
- `(origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`);
299
- await provisioner.updateCors({
300
- bucketName: s3BucketName,
301
- accessType,
302
- allowedOrigins: effectiveOrigins,
303
- });
304
- log.info(`Successfully updated CORS on S3 bucket "${s3BucketName}"`);
305
- }
306
217
  // --- Plugin factory ---
307
218
  /**
308
219
  * Creates the bucket provisioner plugin.
309
220
  *
310
- * This plugin provides two provisioning pathways:
221
+ * This plugin provides one provisioning pathway:
311
222
  *
312
223
  * 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
313
224
  * bucket key to provision (or re-provision) the S3 bucket. Protected
314
225
  * by RLS on the buckets table.
315
226
  *
316
- * 2. **Auto-provisioning hook** — When `autoProvision` is true (default),
317
- * wraps `create*` mutation resolvers on tables tagged with `@storageBuckets`
318
- * to automatically provision the S3 bucket after the row is created.
319
- *
320
227
  * @param options - Plugin configuration (S3 credentials, CORS origins, naming)
321
228
  */
322
229
  function createBucketProvisionerPlugin(options) {
323
- const autoProvision = options.autoProvision ?? true;
324
230
  // The extendSchema plugin adds the explicit provisionBucket mutation
325
231
  const mutationPlugin = (0, graphile_utils_1.extendSchema)(() => ({
326
232
  typeDefs: (0, graphile_utils_1.gql) `
@@ -395,12 +301,13 @@ function createBucketProvisionerPlugin(options) {
395
301
  // is authoritative and the naming hook is never consulted again.
396
302
  const recorded = storedPhysicalName(bucket);
397
303
  const s3BucketName = recorded === null
398
- ? resolveBucketName(bucket.key, databaseId, options)
304
+ ? resolveBucketName(databaseId, bucket.key, options)
399
305
  : recorded;
400
306
  try {
401
307
  const result = await provisionBucketForRow(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
402
308
  // Record the exact provisioned name on the source row.
403
- await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName);
309
+ await withPgClient(null, (client) => (0, graphile_storage_registry_1.recordPhysicalName)((query) => runQuery(client, query.text, query.values), bucketsTable, bucket.id, result.bucketName));
310
+ log.info(`Recorded physical_name="${result.bucketName}" on bucket ${bucket.id}`);
404
311
  return {
405
312
  success: true,
406
313
  bucketName: result.bucketName,
@@ -427,165 +334,7 @@ function createBucketProvisionerPlugin(options) {
427
334
  },
428
335
  },
429
336
  }));
430
- // If autoProvision is disabled, return only the mutation plugin
431
- if (!autoProvision) {
432
- return mutationPlugin;
433
- }
434
- // Build a composite plugin that includes both the mutation and the hook
435
- return {
436
- ...mutationPlugin,
437
- name: 'BucketProvisionerPlugin',
438
- version: '0.1.0',
439
- description: 'Auto-provisions S3 buckets when bucket rows are created, ' +
440
- 'updates CORS when allowed_origins changes on update, ' +
441
- 'and provides a provisionBucket mutation for explicit provisioning',
442
- after: ['PgAttributesPlugin', 'PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin'],
443
- schema: {
444
- ...mutationPlugin.schema,
445
- hooks: {
446
- ...(mutationPlugin.schema?.hooks ?? {}),
447
- /**
448
- * Wrap create and update mutation resolvers on tables tagged with @storageBuckets.
449
- *
450
- * - create*: After the row is created, provision the S3 bucket.
451
- * - update*: After the row is updated, re-apply CORS if allowed_origins changed.
452
- *
453
- * If provisioning/CORS update fails, the DB row still exists (the mutation
454
- * already committed), and the error is logged. Admin can retry via provisionBucket.
455
- */
456
- GraphQLObjectType_fields_field(field, build, context) {
457
- const { scope: { isRootMutation, fieldName, pgCodec }, } = context;
458
- // Only wrap root mutation fields
459
- if (!isRootMutation || !pgCodec || !pgCodec.attributes) {
460
- return field;
461
- }
462
- // Check for @storageBuckets smart tag
463
- const tags = pgCodec.extensions?.tags;
464
- if (!tags?.storageBuckets) {
465
- return field;
466
- }
467
- const isCreate = fieldName.startsWith('create');
468
- const isUpdate = fieldName.startsWith('update');
469
- // Only wrap create and update mutations (not delete)
470
- if (!isCreate && !isUpdate) {
471
- return field;
472
- }
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;
478
- const defaultResolver = (obj) => obj[fieldName];
479
- const { resolve: oldResolve = defaultResolver, ...rest } = field;
480
- return {
481
- ...rest,
482
- async resolve(source, args, graphqlContext, info) {
483
- // Call the original resolver first (creates/updates the DB row)
484
- const result = await oldResolve(source, args, graphqlContext, info);
485
- try {
486
- const inputKey = Object.keys(args.input || {}).find((k) => k !== 'clientMutationId');
487
- const bucketInput = inputKey ? args.input[inputKey] : null;
488
- const withPgClient = graphqlContext.withPgClient;
489
- const pgSettings = graphqlContext.pgSettings;
490
- if (!withPgClient) {
491
- log.warn(`${isCreate ? 'Auto-provision' : 'CORS update'} skipped: withPgClient not available in context`);
492
- return result;
493
- }
494
- if (isCreate) {
495
- // --- CREATE: full provisioning ---
496
- if (!bucketInput?.key || !bucketInput?.type) {
497
- log.warn(`Auto-provision skipped for "${fieldName}": ` +
498
- `could not extract key/type from mutation input`);
499
- return result;
500
- }
501
- if (!codecSchemaName || !codecTableName) {
502
- throw new Error(`Auto-provision failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
503
- }
504
- await withPgClient(pgSettings, async (pgClient) => {
505
- const databaseId = await resolveDatabaseId(pgClient);
506
- if (!databaseId) {
507
- log.warn('Auto-provision skipped: could not resolve database_id');
508
- return;
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);
513
- // Newly-created row has no stored coordinate yet — mint on first provision.
514
- const result = await provisionBucketForRow(storageModule, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
515
- // Record the provisioned name on the just-created row.
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);
524
- });
525
- }
526
- else {
527
- // --- UPDATE: re-apply CORS if allowed_origins is in the patch ---
528
- const hasOriginsUpdate = bucketInput &&
529
- ('allowedOrigins' in bucketInput || 'allowed_origins' in bucketInput);
530
- if (!hasOriginsUpdate) {
531
- // allowed_origins not being changed, nothing to do
532
- return result;
533
- }
534
- if (!codecSchemaName || !codecTableName) {
535
- throw new Error(`CORS update failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
536
- }
537
- await withPgClient(pgSettings, async (pgClient) => {
538
- const databaseId = await resolveDatabaseId(pgClient);
539
- if (!databaseId) {
540
- log.warn('CORS update skipped: could not resolve database_id');
541
- return;
542
- }
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);
546
- // We need the bucket key — it may come from input or patch
547
- // For updates, PostGraphile uses nodeId or the row's PK, so
548
- // we read the bucket from the patch's key or from the nodeId
549
- const patchKey = bucketInput?.key;
550
- if (!patchKey) {
551
- log.warn(`CORS update skipped for "${fieldName}": ` +
552
- `could not determine bucket key from mutation input`);
553
- return;
554
- }
555
- // Read the full bucket row (post-update) to get type + origins
556
- const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
557
- const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
558
- FROM ${bucketsTable}
559
- WHERE key = $1
560
- LIMIT 1`, [patchKey]);
561
- if (bucketResult.rows.length === 0) {
562
- log.warn(`CORS update skipped: bucket "${patchKey}" not found`);
563
- return;
564
- }
565
- const bucket = bucketResult.rows[0];
566
- // CORS applies to the recorded physical bucket; if the row was
567
- // never provisioned there is nothing to update yet, so mint the
568
- // conventional name the first provision would use.
569
- const recorded = storedPhysicalName(bucket);
570
- await updateBucketCors(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
571
- ? resolveBucketName(bucket.key, databaseId, options)
572
- : recorded);
573
- });
574
- }
575
- }
576
- catch (err) {
577
- log.error(`${isCreate ? 'Auto-provision' : 'CORS update'} failed for "${fieldName}": ${err.message}. ` +
578
- (isCreate
579
- ? `The bucket row was created but the S3 bucket was not provisioned. Use the provisionBucket mutation to retry.`
580
- : `The bucket row was updated but CORS was not applied to the S3 bucket. Use the provisionBucket mutation to retry.`));
581
- }
582
- return result;
583
- },
584
- };
585
- },
586
- },
587
- },
588
- };
337
+ return mutationPlugin;
589
338
  }
590
339
  exports.BucketProvisionerPlugin = createBucketProvisionerPlugin;
591
340
  exports.default = exports.BucketProvisionerPlugin;
package/preset.d.ts CHANGED
@@ -31,7 +31,6 @@ import type { BucketProvisionerPluginOptions } from './types';
31
31
  * BucketProvisionerPreset({
32
32
  * connection: getConnection, // pass function ref, NOT getConnection()
33
33
  * allowedOrigins: ['https://app.example.com'],
34
- * bucketNamePrefix: 'myapp',
35
34
  * }),
36
35
  * ],
37
36
  * };
package/preset.js CHANGED
@@ -33,7 +33,6 @@ const plugin_1 = require("./plugin");
33
33
  * BucketProvisionerPreset({
34
34
  * connection: getConnection, // pass function ref, NOT getConnection()
35
35
  * allowedOrigins: ['https://app.example.com'],
36
- * bucketNamePrefix: 'myapp',
37
36
  * }),
38
37
  * ],
39
38
  * };
package/types.d.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  /**
2
2
  * Types for the bucket provisioner plugin.
3
- *
4
- * Defines plugin options, connection configuration, and provisioning result
5
- * types used by the Graphile plugin to auto-provision S3 buckets when
6
- * bucket rows are created via GraphQL mutations.
7
3
  */
8
4
  import type { BucketAccessType, ProvisionResult, StorageConnectionConfig, StorageProvider } from '@constructive-io/bucket-provisioner';
9
5
  export type { BucketAccessType, ProvisionResult, StorageConnectionConfig, StorageProvider };
@@ -18,11 +14,11 @@ export type ConnectionConfigOrGetter = StorageConnectionConfig | (() => StorageC
18
14
  /**
19
15
  * Function to derive the actual S3 bucket name from a logical bucket key.
20
16
  *
21
- * @param bucketKey - The logical bucket key from the database (e.g., "public", "private")
22
17
  * @param databaseId - The metaschema database UUID
18
+ * @param bucketKey - The logical bucket key from the database (e.g., "public", "private")
23
19
  * @returns The S3 bucket name to create/configure
24
20
  */
25
- export type BucketNameResolver = (bucketKey: string, databaseId: string) => string;
21
+ export type BucketNameResolver = (databaseId: string, bucketKey: string) => string;
26
22
  /**
27
23
  * Plugin options for the bucket provisioner plugin.
28
24
  */
@@ -38,15 +34,9 @@ export interface BucketProvisionerPluginOptions {
38
34
  * Required for browser-based presigned URL uploads.
39
35
  */
40
36
  allowedOrigins: string[];
41
- /**
42
- * Optional prefix for S3 bucket names.
43
- * When set, the S3 bucket name becomes `{prefix}-{bucketKey}`.
44
- * Example: prefix "myapp" + key "public" → S3 bucket "myapp-public"
45
- */
46
- bucketNamePrefix?: string;
47
37
  /**
48
38
  * Optional custom function to derive S3 bucket names from logical bucket keys.
49
- * Takes precedence over `bucketNamePrefix` when provided.
39
+ * Naming is a deployment policy and must be supplied by the caller.
50
40
  */
51
41
  resolveBucketName?: BucketNameResolver;
52
42
  /**
@@ -54,15 +44,6 @@ export interface BucketProvisionerPluginOptions {
54
44
  * Default: false
55
45
  */
56
46
  versioning?: boolean;
57
- /**
58
- * Whether to auto-provision S3 buckets when bucket rows are created
59
- * via GraphQL mutations. When true, the plugin wraps create mutations
60
- * on tables tagged with `@storageBuckets` to trigger provisioning
61
- * after the mutation succeeds.
62
- *
63
- * Default: true
64
- */
65
- autoProvision?: boolean;
66
47
  }
67
48
  /**
68
49
  * Input for the provisionBucket mutation.
package/types.js CHANGED
@@ -1,9 +1,5 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Types for the bucket provisioner plugin.
4
- *
5
- * Defines plugin options, connection configuration, and provisioning result
6
- * types used by the Graphile plugin to auto-provision S3 buckets when
7
- * bucket rows are created via GraphQL mutations.
8
4
  */
9
5
  Object.defineProperty(exports, "__esModule", { value: true });