graphile-bucket-provisioner-plugin 1.13.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,32 +20,13 @@ 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
27
  /**
41
- * Resolve the app-level storage module (scope = 'app').
42
- */
43
- const APP_STORAGE_MODULE_QUERY = `
44
- SELECT
45
- sm.id,
46
- sm.scope,
47
- sm.entity_table_id,
48
- bs.schema_name AS buckets_schema,
49
- bt.name AS buckets_table,
50
- sm.endpoint,
51
- sm.public_url_prefix,
52
- sm.provider,
53
- sm.allowed_origins
54
- FROM metaschema_modules_public.storage_module sm
55
- JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
56
- JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
57
- WHERE sm.database_id = $1
58
- AND sm.scope = 'app'
59
- LIMIT 1
60
- `;
61
- /**
62
- * Resolve ALL storage modules for a database (for ownerId-based resolution).
28
+ * Resolve ALL storage modules for a database (for bucket-key and ownerId-based
29
+ * resolution in the explicit provisionBucket mutation).
63
30
  */
64
31
  const ALL_STORAGE_MODULES_QUERY = `
65
32
  SELECT
@@ -93,28 +60,58 @@ function runQuery(pgClient, text, values) {
93
60
  return pgClient.query(values === undefined ? { text } : { text, values });
94
61
  }
95
62
  /**
96
- * Resolve the storage module for a given scope.
97
- * If ownerId is provided, probes entity tables to find the matching module.
98
- * Otherwise, returns the app-level module.
63
+ * The explicit provisionBucket mutation's resolution: find the plane that
64
+ * actually holds the named bucket row.
65
+ *
66
+ * With an ownerId, the owning entity row names the plane (probed through each
67
+ * entity-keyed module's recorded entity table). Without one, every
68
+ * non-entity-keyed plane's buckets table is probed for the key under RLS —
69
+ * exactly one plane may hold it; several is ambiguous and throws rather than
70
+ * silently picking.
99
71
  */
100
- async function resolveStorageModule(pgClient, databaseId, ownerId) {
101
- if (!ownerId) {
102
- // App-level resolution
103
- const result = await runQuery(pgClient, APP_STORAGE_MODULE_QUERY, [databaseId]);
104
- return result.rows[0] ?? null;
105
- }
106
- // Entity-scoped: load all modules and probe entity tables
72
+ async function resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId) {
107
73
  const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]);
108
74
  const modules = result.rows;
109
- const entityModules = modules.filter((m) => m.entity_schema && m.entity_table);
110
- for (const mod of entityModules) {
111
- const entityTable = QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
112
- const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
113
- if (probe.rows.length > 0) {
114
- return mod;
75
+ if (modules.length === 0) {
76
+ throw new Error(`STORAGE_MODULE_NOT_PROVISIONED: database ${databaseId} has no storage modules`);
77
+ }
78
+ if (ownerId) {
79
+ const entityModules = modules.filter((m) => m.entity_table_id !== null && m.entity_schema && m.entity_table);
80
+ for (const mod of entityModules) {
81
+ const entityTable = QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
82
+ const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
83
+ if (probe.rows.length === 0)
84
+ continue;
85
+ const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
86
+ const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
87
+ FROM ${bucketsTable}
88
+ WHERE key = $1 AND owner_id = $2
89
+ LIMIT 1`, [bucketKey, ownerId]);
90
+ if (bucketResult.rows.length === 0)
91
+ return null;
92
+ return { storageModule: mod, bucket: bucketResult.rows[0] };
93
+ }
94
+ return null;
95
+ }
96
+ const matches = [];
97
+ for (const mod of modules.filter((m) => m.entity_table_id === null)) {
98
+ const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
99
+ const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
100
+ FROM ${bucketsTable}
101
+ WHERE key = $1
102
+ LIMIT 1`, [bucketKey]);
103
+ if (bucketResult.rows.length > 0) {
104
+ matches.push({ storageModule: mod, bucket: bucketResult.rows[0] });
115
105
  }
116
106
  }
117
- return null;
107
+ if (matches.length === 0)
108
+ return null;
109
+ if (matches.length > 1) {
110
+ const scopes = matches.map((m) => `'${m.storageModule.scope}'`).join(', ');
111
+ throw new Error(`BUCKET_KEY_AMBIGUOUS: bucket key "${bucketKey}" exists in ${matches.length} storage planes ` +
112
+ `(scopes ${scopes}); provide an ownerId or use a plane-unique key`);
113
+ }
114
+ return matches[0];
118
115
  }
119
116
  /**
120
117
  * Normalize the recorded physical coordinate at the DB boundary.
@@ -127,17 +124,6 @@ async function resolveStorageModule(pgClient, databaseId, ownerId) {
127
124
  function storedPhysicalName(row) {
128
125
  return row.physical_name == null ? null : row.physical_name;
129
126
  }
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
- }
141
127
  // --- Helpers ---
142
128
  /**
143
129
  * Resolve the connection config from the options. If the option is a lazy
@@ -155,14 +141,14 @@ function resolveConnection(options) {
155
141
  /**
156
142
  * Resolve the S3 bucket name from a logical bucket key.
157
143
  */
158
- function resolveBucketName(bucketKey, databaseId, options) {
159
- if (options.resolveBucketName) {
160
- 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.');
161
150
  }
162
- if (options.bucketNamePrefix) {
163
- return `${options.bucketNamePrefix}-${bucketKey}`;
164
- }
165
- return bucketKey;
151
+ return options.resolveBucketName(databaseId, bucketKey);
166
152
  }
167
153
  /**
168
154
  * Resolve the database_id from the JWT context.
@@ -204,13 +190,10 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
204
190
  });
205
191
  }
206
192
  /**
207
- * Core provisioning logic shared by both the explicit mutation and the
208
- * auto-provisioning hook.
193
+ * Core provisioning logic for the explicit mutation.
209
194
  */
210
- async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
195
+ async function provisionBucketForRow(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
211
196
  const accessType = bucketType;
212
- // Read storage module config to check for endpoint/provider/CORS overrides
213
- const storageModule = await resolveStorageModule(pgClient, databaseId);
214
197
  // Resolve CORS origins using the 3-tier hierarchy
215
198
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
216
199
  const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
@@ -227,41 +210,19 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
227
210
  `(provider=${result.provider}, blockPublicAccess=${result.blockPublicAccess})`);
228
211
  return result;
229
212
  }
230
- /**
231
- * Update CORS on an existing S3 bucket when allowed_origins changes.
232
- */
233
- async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
234
- const accessType = bucketType;
235
- const storageModule = await resolveStorageModule(pgClient, databaseId);
236
- const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
237
- const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
238
- log.info(`Updating CORS on S3 bucket "${s3BucketName}" ` +
239
- `(origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`);
240
- await provisioner.updateCors({
241
- bucketName: s3BucketName,
242
- accessType,
243
- allowedOrigins: effectiveOrigins,
244
- });
245
- log.info(`Successfully updated CORS on S3 bucket "${s3BucketName}"`);
246
- }
247
213
  // --- Plugin factory ---
248
214
  /**
249
215
  * Creates the bucket provisioner plugin.
250
216
  *
251
- * This plugin provides two provisioning pathways:
217
+ * This plugin provides one provisioning pathway:
252
218
  *
253
219
  * 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
254
220
  * bucket key to provision (or re-provision) the S3 bucket. Protected
255
221
  * by RLS on the buckets table.
256
222
  *
257
- * 2. **Auto-provisioning hook** — When `autoProvision` is true (default),
258
- * wraps `create*` mutation resolvers on tables tagged with `@storageBuckets`
259
- * to automatically provision the S3 bucket after the row is created.
260
- *
261
223
  * @param options - Plugin configuration (S3 credentials, CORS origins, naming)
262
224
  */
263
225
  export function createBucketProvisionerPlugin(options) {
264
- const autoProvision = options.autoProvision ?? true;
265
226
  // The extendSchema plugin adds the explicit provisionBucket mutation
266
227
  const mutationPlugin = extendSchema(() => ({
267
228
  typeDefs: gql `
@@ -324,39 +285,25 @@ export function createBucketProvisionerPlugin(options) {
324
285
  if (!databaseId) {
325
286
  throw new Error('DATABASE_NOT_FOUND');
326
287
  }
327
- // Resolve storage module (app-level or entity-scoped via ownerId)
328
- const storageModule = await resolveStorageModule(pgClient, databaseId, ownerId);
329
- if (!storageModule) {
330
- throw new Error(ownerId
331
- ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
332
- : 'STORAGE_MODULE_NOT_PROVISIONED');
333
- }
334
- // Look up the bucket row (RLS enforced via pgSettings)
335
- const hasOwner = ownerId && storageModule.scope !== 'app';
336
- const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
337
- const bucketResult = await runQuery(pgClient, hasOwner
338
- ? `SELECT id, key, type, is_public, allowed_origins, physical_name
339
- FROM ${bucketsTable}
340
- WHERE key = $1 AND owner_id = $2
341
- LIMIT 1`
342
- : `SELECT id, key, type, is_public, allowed_origins, physical_name
343
- FROM ${bucketsTable}
344
- WHERE key = $1
345
- LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
346
- if (bucketResult.rows.length === 0) {
288
+ // Resolve the plane that actually holds the named bucket row
289
+ // (RLS enforced via pgSettings) — never a guessed scope.
290
+ const resolution = await resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId);
291
+ if (!resolution) {
347
292
  throw new Error('BUCKET_NOT_FOUND');
348
293
  }
349
- const bucket = bucketResult.rows[0];
294
+ const { storageModule, bucket } = resolution;
295
+ const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
350
296
  // First provision mints a name; afterwards the stored coordinate
351
297
  // is authoritative and the naming hook is never consulted again.
352
298
  const recorded = storedPhysicalName(bucket);
353
299
  const s3BucketName = recorded === null
354
- ? resolveBucketName(bucket.key, databaseId, options)
300
+ ? resolveBucketName(databaseId, bucket.key, options)
355
301
  : recorded;
356
302
  try {
357
- const result = await provisionBucketForRow(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
303
+ const result = await provisionBucketForRow(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
358
304
  // Record the exact provisioned name on the source row.
359
- 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}`);
360
307
  return {
361
308
  success: true,
362
309
  bucketName: result.bucketName,
@@ -383,155 +330,7 @@ export function createBucketProvisionerPlugin(options) {
383
330
  },
384
331
  },
385
332
  }));
386
- // If autoProvision is disabled, return only the mutation plugin
387
- if (!autoProvision) {
388
- return mutationPlugin;
389
- }
390
- // Build a composite plugin that includes both the mutation and the hook
391
- return {
392
- ...mutationPlugin,
393
- name: 'BucketProvisionerPlugin',
394
- version: '0.1.0',
395
- description: 'Auto-provisions S3 buckets when bucket rows are created, ' +
396
- 'updates CORS when allowed_origins changes on update, ' +
397
- 'and provides a provisionBucket mutation for explicit provisioning',
398
- after: ['PgAttributesPlugin', 'PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin'],
399
- schema: {
400
- ...mutationPlugin.schema,
401
- hooks: {
402
- ...(mutationPlugin.schema?.hooks ?? {}),
403
- /**
404
- * Wrap create and update mutation resolvers on tables tagged with @storageBuckets.
405
- *
406
- * - create*: After the row is created, provision the S3 bucket.
407
- * - update*: After the row is updated, re-apply CORS if allowed_origins changed.
408
- *
409
- * If provisioning/CORS update fails, the DB row still exists (the mutation
410
- * already committed), and the error is logged. Admin can retry via provisionBucket.
411
- */
412
- GraphQLObjectType_fields_field(field, build, context) {
413
- const { scope: { isRootMutation, fieldName, pgCodec }, } = context;
414
- // Only wrap root mutation fields
415
- if (!isRootMutation || !pgCodec || !pgCodec.attributes) {
416
- return field;
417
- }
418
- // Check for @storageBuckets smart tag
419
- const tags = pgCodec.extensions?.tags;
420
- if (!tags?.storageBuckets) {
421
- return field;
422
- }
423
- const isCreate = fieldName.startsWith('create');
424
- const isUpdate = fieldName.startsWith('update');
425
- // Only wrap create and update mutations (not delete)
426
- if (!isCreate && !isUpdate) {
427
- return field;
428
- }
429
- log.debug(`Wrapping mutation "${fieldName}" for ${isCreate ? 'auto-provisioning' : 'CORS update'} (codec: ${pgCodec.name})`);
430
- const defaultResolver = (obj) => obj[fieldName];
431
- const { resolve: oldResolve = defaultResolver, ...rest } = field;
432
- return {
433
- ...rest,
434
- async resolve(source, args, graphqlContext, info) {
435
- // Call the original resolver first (creates/updates the DB row)
436
- const result = await oldResolve(source, args, graphqlContext, info);
437
- try {
438
- const inputKey = Object.keys(args.input || {}).find((k) => k !== 'clientMutationId');
439
- const bucketInput = inputKey ? args.input[inputKey] : null;
440
- const withPgClient = graphqlContext.withPgClient;
441
- const pgSettings = graphqlContext.pgSettings;
442
- if (!withPgClient) {
443
- log.warn(`${isCreate ? 'Auto-provision' : 'CORS update'} skipped: withPgClient not available in context`);
444
- return result;
445
- }
446
- if (isCreate) {
447
- // --- CREATE: full provisioning ---
448
- if (!bucketInput?.key || !bucketInput?.type) {
449
- log.warn(`Auto-provision skipped for "${fieldName}": ` +
450
- `could not extract key/type from mutation input`);
451
- return result;
452
- }
453
- await withPgClient(pgSettings, async (pgClient) => {
454
- const databaseId = await resolveDatabaseId(pgClient);
455
- if (!databaseId) {
456
- log.warn('Auto-provision skipped: could not resolve database_id');
457
- return;
458
- }
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
- }
470
- });
471
- }
472
- else {
473
- // --- UPDATE: re-apply CORS if allowed_origins is in the patch ---
474
- const hasOriginsUpdate = bucketInput &&
475
- ('allowedOrigins' in bucketInput || 'allowed_origins' in bucketInput);
476
- if (!hasOriginsUpdate) {
477
- // allowed_origins not being changed, nothing to do
478
- return result;
479
- }
480
- await withPgClient(pgSettings, async (pgClient) => {
481
- const databaseId = await resolveDatabaseId(pgClient);
482
- if (!databaseId) {
483
- log.warn('CORS update skipped: could not resolve database_id');
484
- return;
485
- }
486
- // Read the storage module config (app-level; auto-hook doesn't have ownerId context)
487
- const storageModule = await resolveStorageModule(pgClient, databaseId);
488
- if (!storageModule) {
489
- log.warn('CORS update skipped: storage module not provisioned');
490
- return;
491
- }
492
- // We need the bucket key — it may come from input or patch
493
- // For updates, PostGraphile uses nodeId or the row's PK, so
494
- // we read the bucket from the patch's key or from the nodeId
495
- const patchKey = bucketInput?.key;
496
- if (!patchKey) {
497
- log.warn(`CORS update skipped for "${fieldName}": ` +
498
- `could not determine bucket key from mutation input`);
499
- return;
500
- }
501
- // Read the full bucket row (post-update) to get type + origins
502
- const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
503
- const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
504
- FROM ${bucketsTable}
505
- WHERE key = $1
506
- LIMIT 1`, [patchKey]);
507
- if (bucketResult.rows.length === 0) {
508
- log.warn(`CORS update skipped: bucket "${patchKey}" not found`);
509
- return;
510
- }
511
- const bucket = bucketResult.rows[0];
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);
519
- });
520
- }
521
- }
522
- catch (err) {
523
- log.error(`${isCreate ? 'Auto-provision' : 'CORS update'} failed for "${fieldName}": ${err.message}. ` +
524
- (isCreate
525
- ? `The bucket row was created but the S3 bucket was not provisioned. Use the provisionBucket mutation to retry.`
526
- : `The bucket row was updated but CORS was not applied to the S3 bucket. Use the provisionBucket mutation to retry.`));
527
- }
528
- return result;
529
- },
530
- };
531
- },
532
- },
533
- },
534
- };
333
+ return mutationPlugin;
535
334
  }
536
335
  export const BucketProvisionerPlugin = createBucketProvisionerPlugin;
537
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 {};