graphile-presigned-url-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.
@@ -18,8 +18,8 @@
18
18
  * lookups. Since downloadUrl is a computed field (not a real column),
19
19
  * the plan() function is required for Grafast to execute the S3 signing.
20
20
  */
21
- import type { GraphileConfig } from 'graphile-config';
22
21
  import 'graphile-build';
22
+ import type { GraphileConfig } from 'graphile-config';
23
23
  import type { PresignedUrlPluginOptions } from './types';
24
24
  export declare function createDownloadUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
25
25
  export default createDownloadUrlPlugin;
@@ -22,8 +22,8 @@
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
23
  exports.createDownloadUrlPlugin = createDownloadUrlPlugin;
24
24
  require("graphile-build");
25
- const grafast_1 = require("grafast");
26
25
  const logger_1 = require("@pgpmjs/logger");
26
+ const grafast_1 = require("grafast");
27
27
  const s3_signer_1 = require("./s3-signer");
28
28
  const storage_module_cache_1 = require("./storage-module-cache");
29
29
  const log = new logger_1.Logger('graphile-presigned-url:download-url');
@@ -49,21 +49,21 @@ function resolveS3(options) {
49
49
  return options.s3;
50
50
  }
51
51
  /**
52
- * Build a per-database S3Config by overlaying storage_module overrides
53
- * onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
52
+ * Build a per-database S3Config for a *known* physical bucket. `physicalName`
53
+ * is required — the stored coordinate on the bucket row is the only source;
54
+ * no name is ever recomputed here. Same logic as plugin.ts resolveS3ForDatabase.
54
55
  */
55
- function resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey) {
56
+ function resolveS3ForDatabase(options, storageConfig, physicalName) {
56
57
  const globalS3 = resolveS3(options);
57
- const bucket = options.resolveBucketName
58
- ? options.resolveBucketName(databaseId, bucketKey)
59
- : globalS3.bucket;
60
- const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
61
- if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
58
+ const publicUrlPrefix = storageConfig.publicUrlPrefix != null
59
+ ? storageConfig.publicUrlPrefix
60
+ : globalS3.publicUrlPrefix;
61
+ if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
62
62
  return globalS3;
63
63
  }
64
64
  return {
65
65
  ...globalS3,
66
- bucket,
66
+ bucket: physicalName,
67
67
  ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
68
68
  };
69
69
  }
@@ -126,25 +126,24 @@ function createDownloadUrlPlugin(options) {
126
126
  const config = databaseId
127
127
  ? (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, await withPgClient(null, (pgClient) => (0, storage_module_cache_1.loadAllStorageModules)(pgClient, databaseId)))
128
128
  : null;
129
- const resolved = config
129
+ const resolved = config && bucketId
130
130
  ? await withPgClient(pgSettings, async (pgClient) => {
131
- // Look up the bucket key for scoped S3 resolution
132
- let bucketKey = 'public';
133
- if (bucketId) {
134
- const bucketResult = await pgClient.query({
135
- text: `SELECT key FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
136
- values: [bucketId],
137
- });
138
- if (bucketResult.rows[0]?.key) {
139
- bucketKey = bucketResult.rows[0].key;
140
- }
141
- }
142
- return { config, databaseId, bucketKey };
131
+ // Look up the stored physical coordinate for scoped S3 resolution
132
+ const bucketResult = await pgClient.query({
133
+ text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
134
+ values: [bucketId],
135
+ });
136
+ const row = bucketResult.rows[0];
137
+ return row ? { config, physicalName: (0, storage_module_cache_1.storedPhysicalName)(row) } : null;
143
138
  })
144
139
  : null;
145
140
  if (resolved) {
141
+ if (resolved.physicalName === null) {
142
+ // No physical bucket was ever provisioned — no object can exist.
143
+ return null;
144
+ }
146
145
  downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
147
- s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId, resolved.bucketKey);
146
+ s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.physicalName);
148
147
  }
149
148
  }
150
149
  }
@@ -18,8 +18,8 @@
18
18
  * lookups. Since downloadUrl is a computed field (not a real column),
19
19
  * the plan() function is required for Grafast to execute the S3 signing.
20
20
  */
21
- import type { GraphileConfig } from 'graphile-config';
22
21
  import 'graphile-build';
22
+ import type { GraphileConfig } from 'graphile-config';
23
23
  import type { PresignedUrlPluginOptions } from './types';
24
24
  export declare function createDownloadUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
25
25
  export default createDownloadUrlPlugin;
@@ -19,10 +19,10 @@
19
19
  * the plan() function is required for Grafast to execute the S3 signing.
20
20
  */
21
21
  import 'graphile-build';
22
- import { context as grafastContext, lambda, object } from 'grafast';
23
22
  import { Logger } from '@pgpmjs/logger';
23
+ import { context as grafastContext, lambda, object } from 'grafast';
24
24
  import { generatePresignedGetUrl } from './s3-signer';
25
- import { loadAllStorageModules, resolveStorageConfigFromCodec } from './storage-module-cache';
25
+ import { loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
26
26
  const log = new Logger('graphile-presigned-url:download-url');
27
27
  /**
28
28
  * Creates the downloadUrl computed field plugin.
@@ -46,21 +46,21 @@ function resolveS3(options) {
46
46
  return options.s3;
47
47
  }
48
48
  /**
49
- * Build a per-database S3Config by overlaying storage_module overrides
50
- * onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
49
+ * Build a per-database S3Config for a *known* physical bucket. `physicalName`
50
+ * is required — the stored coordinate on the bucket row is the only source;
51
+ * no name is ever recomputed here. Same logic as plugin.ts resolveS3ForDatabase.
51
52
  */
52
- function resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey) {
53
+ function resolveS3ForDatabase(options, storageConfig, physicalName) {
53
54
  const globalS3 = resolveS3(options);
54
- const bucket = options.resolveBucketName
55
- ? options.resolveBucketName(databaseId, bucketKey)
56
- : globalS3.bucket;
57
- const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
58
- if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
55
+ const publicUrlPrefix = storageConfig.publicUrlPrefix != null
56
+ ? storageConfig.publicUrlPrefix
57
+ : globalS3.publicUrlPrefix;
58
+ if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
59
59
  return globalS3;
60
60
  }
61
61
  return {
62
62
  ...globalS3,
63
- bucket,
63
+ bucket: physicalName,
64
64
  ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
65
65
  };
66
66
  }
@@ -123,25 +123,24 @@ export function createDownloadUrlPlugin(options) {
123
123
  const config = databaseId
124
124
  ? resolveStorageConfigFromCodec(capturedCodec, await withPgClient(null, (pgClient) => loadAllStorageModules(pgClient, databaseId)))
125
125
  : null;
126
- const resolved = config
126
+ const resolved = config && bucketId
127
127
  ? await withPgClient(pgSettings, async (pgClient) => {
128
- // Look up the bucket key for scoped S3 resolution
129
- let bucketKey = 'public';
130
- if (bucketId) {
131
- const bucketResult = await pgClient.query({
132
- text: `SELECT key FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
133
- values: [bucketId],
134
- });
135
- if (bucketResult.rows[0]?.key) {
136
- bucketKey = bucketResult.rows[0].key;
137
- }
138
- }
139
- return { config, databaseId, bucketKey };
128
+ // Look up the stored physical coordinate for scoped S3 resolution
129
+ const bucketResult = await pgClient.query({
130
+ text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
131
+ values: [bucketId],
132
+ });
133
+ const row = bucketResult.rows[0];
134
+ return row ? { config, physicalName: storedPhysicalName(row) } : null;
140
135
  })
141
136
  : null;
142
137
  if (resolved) {
138
+ if (resolved.physicalName === null) {
139
+ // No physical bucket was ever provisioned — no object can exist.
140
+ return null;
141
+ }
143
142
  downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
144
- s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId, resolved.bucketKey);
143
+ s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.physicalName);
145
144
  }
146
145
  }
147
146
  }
package/esm/index.d.ts CHANGED
@@ -26,9 +26,9 @@
26
26
  * };
27
27
  * ```
28
28
  */
29
- export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
30
29
  export { createDownloadUrlPlugin } from './download-url-field';
30
+ export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
31
31
  export { PresignedUrlPreset } from './preset';
32
- export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, loadAllStorageModules, resolveStorageConfigFromCodec, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
- export { generatePresignedPutUrl, generatePresignedGetUrl, deleteS3Object, headObject } from './s3-signer';
34
- export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';
32
+ export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
33
+ export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
34
+ export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
package/esm/index.js CHANGED
@@ -26,8 +26,8 @@
26
26
  * };
27
27
  * ```
28
28
  */
29
- export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
30
29
  export { createDownloadUrlPlugin } from './download-url-field';
30
+ export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
31
31
  export { PresignedUrlPreset } from './preset';
32
- export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, loadAllStorageModules, resolveStorageConfigFromCodec, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
- export { generatePresignedPutUrl, generatePresignedGetUrl, deleteS3Object, headObject } from './s3-signer';
32
+ export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
33
+ export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
package/esm/plugin.d.ts CHANGED
@@ -16,8 +16,8 @@
16
16
  * Scope resolution uses the codec's schema/table name matched against
17
17
  * cached storage module configs.
18
18
  */
19
- import type { GraphileConfig } from 'graphile-config';
20
19
  import 'graphile-build';
20
+ import type { GraphileConfig } from 'graphile-config';
21
21
  import type { PresignedUrlPluginOptions } from './types';
22
22
  export declare function createPresignedUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
23
23
  export declare const PresignedUrlPlugin: typeof createPresignedUrlPlugin;
package/esm/plugin.js CHANGED
@@ -16,18 +16,18 @@
16
16
  * Scope resolution uses the codec's schema/table name matched against
17
17
  * cached storage module configs.
18
18
  */
19
- import { access, context as grafastContext, lambda, object } from 'grafast';
20
19
  import 'graphile-build';
21
20
  import { Logger } from '@pgpmjs/logger';
22
- import { loadAllStorageModules, resolveStorageConfigFromCodec, getBucketConfig, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
23
- import { generatePresignedPutUrl, deleteS3Object } from './s3-signer';
21
+ import { access, context as grafastContext, lambda, object } from 'grafast';
22
+ import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
23
+ import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
24
24
  const log = new Logger('graphile-presigned-url:plugin');
25
25
  // --- Protocol-level constants (not configurable) ---
26
26
  const MAX_CONTENT_HASH_LENGTH = 128;
27
27
  const MAX_CONTENT_TYPE_LENGTH = 255;
28
28
  const MAX_CUSTOM_KEY_LENGTH = 1024;
29
29
  const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
30
- const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-\/]*$/;
30
+ const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-/]*$/;
31
31
  // --- Helpers ---
32
32
  function isValidSha256(hash) {
33
33
  return SHA256_HEX_REGEX.test(hash);
@@ -74,30 +74,72 @@ function resolveS3(options) {
74
74
  }
75
75
  return options.s3;
76
76
  }
77
- function resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey) {
77
+ /**
78
+ * Mint the physical S3 bucket name for a logical bucket's first provision.
79
+ *
80
+ * This is a naming *policy*, consulted exactly once per bucket — before the
81
+ * physical bucket exists. Once provisioned, the recorded `physical_name` on
82
+ * the row is authoritative and this function must not be consulted again.
83
+ */
84
+ function mintPhysicalBucketName(options, databaseId, bucketKey) {
85
+ if (options.resolveBucketName) {
86
+ return options.resolveBucketName(databaseId, bucketKey);
87
+ }
88
+ // Single-bucket deployment: the globally configured bucket is the physical bucket.
89
+ return resolveS3(options).bucket;
90
+ }
91
+ /**
92
+ * Build the S3 config for a *known* physical bucket. `physicalName` is
93
+ * required — callers must resolve the coordinate (stored row value, or a
94
+ * freshly provisioned name) before getting here. No name is ever recomputed.
95
+ */
96
+ function resolveS3ForDatabase(options, storageConfig, physicalName) {
78
97
  const globalS3 = resolveS3(options);
79
- const bucket = options.resolveBucketName
80
- ? options.resolveBucketName(databaseId, bucketKey)
81
- : globalS3.bucket;
82
- const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
83
- if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
98
+ const publicUrlPrefix = storageConfig.publicUrlPrefix != null
99
+ ? storageConfig.publicUrlPrefix
100
+ : globalS3.publicUrlPrefix;
101
+ if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
84
102
  return globalS3;
85
103
  }
86
104
  return {
87
105
  ...globalS3,
88
- bucket,
106
+ bucket: physicalName,
89
107
  ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
90
108
  };
91
109
  }
92
- async function ensureS3BucketExists(options, s3BucketName, bucket, databaseId, allowedOrigins) {
93
- if (!options.ensureBucketProvisioned)
94
- return;
95
- if (isS3BucketProvisioned(s3BucketName))
96
- return;
97
- log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
98
- await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
99
- markS3BucketProvisioned(s3BucketName);
100
- log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
110
+ /**
111
+ * First provision of a logical bucket: mint a name, create the physical S3
112
+ * bucket, and record the exact name on the source row. Returns the recorded
113
+ * physical name.
114
+ *
115
+ * Only called when the row has no `physical_name` yet. Afterwards the stored
116
+ * value is the durable coordinate: route resolution and every later read use
117
+ * it verbatim; nothing is recomputed.
118
+ *
119
+ * The record write runs in the system lane (`withPgClient(null, ...)`) — it is
120
+ * server bookkeeping, not request data, and anonymous request roles cannot
121
+ * UPDATE bucket rows under RLS. `bucket` (the cached config) is mutated in place
122
+ * so subsequent reads observe the recorded name without a DB round-trip.
123
+ */
124
+ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
125
+ const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
126
+ if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) {
127
+ log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
128
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
129
+ markS3BucketProvisioned(s3BucketName);
130
+ log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
131
+ }
132
+ // Record the physical coordinate on the source row. The `physical_name IS NULL`
133
+ // guard keeps this idempotent and race-safe across concurrent first uploads.
134
+ await withPgClient(null, (client) => client.query({
135
+ text: `UPDATE ${storageConfig.bucketsQualifiedName}
136
+ SET physical_name = $1
137
+ WHERE id = $2 AND physical_name IS NULL`,
138
+ values: [s3BucketName, bucket.id],
139
+ }));
140
+ bucket.physical_name = s3BucketName;
141
+ log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
142
+ return s3BucketName;
101
143
  }
102
144
  // --- Plugin factory ---
103
145
  export function createPresignedUrlPlugin(options) {
@@ -211,22 +253,24 @@ export function createPresignedUrlPlugin(options) {
211
253
  const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
212
254
  if (!storageConfig)
213
255
  throw new Error('STORAGE_MODULE_NOT_FOUND');
214
- return vals.withPgClient(vals.pgSettings, async (pgClient) => {
215
- return pgClient.withTransaction(async (txClient) => {
216
- const bucket = await getBucketConfig(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
217
- if (!bucket)
218
- throw new Error('BUCKET_NOT_FOUND');
219
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucket.key);
220
- await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
221
- return processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
222
- contentHash: vals.contentHash,
223
- contentType: vals.contentType,
224
- size: vals.size,
225
- filename: vals.filename,
226
- key: vals.customKey,
227
- });
228
- });
229
- });
256
+ // Bucket config read under the request role (RLS-gated visibility).
257
+ const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
258
+ if (!bucket)
259
+ throw new Error('BUCKET_NOT_FOUND');
260
+ // First provision mints + records the coordinate; afterwards the
261
+ // stored physical_name is authoritative and nothing is recomputed.
262
+ const physicalName = bucket.physical_name === null
263
+ ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
264
+ : bucket.physical_name;
265
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
266
+ // File row INSERT under the request role (RLS enforced).
267
+ return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction((txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
268
+ contentHash: vals.contentHash,
269
+ contentType: vals.contentType,
270
+ size: vals.size,
271
+ filename: vals.filename,
272
+ key: vals.customKey,
273
+ })));
230
274
  });
231
275
  },
232
276
  });
@@ -300,35 +344,39 @@ export function createPresignedUrlPlugin(options) {
300
344
  const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
301
345
  if (!storageConfig)
302
346
  throw new Error('STORAGE_MODULE_NOT_FOUND');
303
- return vals.withPgClient(vals.pgSettings, async (pgClient) => {
304
- return pgClient.withTransaction(async (txClient) => {
305
- const bucket = await getBucketConfig(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
306
- if (!bucket)
307
- throw new Error('BUCKET_NOT_FOUND');
308
- // Enforce bulk upload limits
309
- const filesArray = vals.files;
310
- if (filesArray.length > storageConfig.maxBulkFiles) {
311
- throw new Error(`BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`);
312
- }
313
- const totalSize = filesArray.reduce((sum, f) => sum + (f.size || 0), 0);
314
- if (totalSize > storageConfig.maxBulkTotalSize) {
315
- throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
316
- }
317
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucket.key);
318
- await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
319
- const results = [];
320
- for (const file of filesArray) {
321
- results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
322
- contentHash: file.contentHash,
323
- contentType: file.contentType,
324
- size: file.size,
325
- filename: file.filename,
326
- key: file.key,
327
- }));
328
- }
329
- return { files: results };
330
- });
331
- });
347
+ // Bucket config read under the request role (RLS-gated visibility).
348
+ const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
349
+ if (!bucket)
350
+ throw new Error('BUCKET_NOT_FOUND');
351
+ // Enforce bulk upload limits
352
+ const filesArray = vals.files;
353
+ if (filesArray.length > storageConfig.maxBulkFiles) {
354
+ throw new Error(`BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`);
355
+ }
356
+ const totalSize = filesArray.reduce((sum, f) => sum + (f.size || 0), 0);
357
+ if (totalSize > storageConfig.maxBulkTotalSize) {
358
+ throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
359
+ }
360
+ // First provision mints + records the coordinate; afterwards the
361
+ // stored physical_name is authoritative and nothing is recomputed.
362
+ const physicalName = bucket.physical_name === null
363
+ ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
364
+ : bucket.physical_name;
365
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
366
+ // File row INSERTs under the request role (RLS enforced).
367
+ return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction(async (txClient) => {
368
+ const results = [];
369
+ for (const file of filesArray) {
370
+ results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
371
+ contentHash: file.contentHash,
372
+ contentType: file.contentType,
373
+ size: file.size,
374
+ filename: file.filename,
375
+ key: file.key,
376
+ }));
377
+ }
378
+ return { files: results };
379
+ }));
332
380
  });
333
381
  },
334
382
  });
@@ -425,18 +473,25 @@ export function createPresignedUrlPlugin(options) {
425
473
  log.info(`File deleted from DB; S3 key ${fileRow.key} still referenced by ${refCount} file(s)`);
426
474
  return;
427
475
  }
428
- // No other references — attempt sync S3 delete
429
- // Look up the bucket key for scoped S3 resolution
476
+ // No other references — attempt sync S3 delete.
477
+ // Read the stored physical coordinate; the object lives in the
478
+ // recorded bucket, never a recomputed prefix name.
430
479
  const bucketResult = await pgClient.query({
431
- text: `SELECT key FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
480
+ text: `SELECT key, physical_name FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
432
481
  values: [fileRow.bucket_id],
433
482
  });
434
- const bucketKey = bucketResult.rows[0]?.key;
435
- if (!bucketKey) {
483
+ const bucketRow = bucketResult.rows[0];
484
+ if (!bucketRow) {
436
485
  log.warn(`Bucket not found for bucket_id=${fileRow.bucket_id}; skipping S3 delete`);
437
486
  return;
438
487
  }
439
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey);
488
+ const physicalName = storedPhysicalName(bucketRow);
489
+ if (physicalName === null) {
490
+ // No physical bucket was ever provisioned — there is no object to delete.
491
+ log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
492
+ return;
493
+ }
494
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
440
495
  await deleteS3Object(s3ForDb, fileRow.key);
441
496
  log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
442
497
  });
package/esm/preset.js CHANGED
@@ -5,8 +5,8 @@
5
5
  * in PostGraphile. Combines the main mutation plugin (requestUploadUrl)
6
6
  * with the downloadUrl computed field plugin.
7
7
  */
8
- import { createPresignedUrlPlugin } from './plugin';
9
8
  import { createDownloadUrlPlugin } from './download-url-field';
9
+ import { createPresignedUrlPlugin } from './plugin';
10
10
  /**
11
11
  * Creates a preset that includes the presigned URL plugins with the given options.
12
12
  *
package/esm/s3-signer.js CHANGED
@@ -1,4 +1,4 @@
1
- import { PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand, } from '@aws-sdk/client-s3';
1
+ import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
2
2
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3
3
  import { Logger } from '@pgpmjs/logger';
4
4
  const log = new Logger('graphile-presigned-url:s3');
@@ -1,4 +1,4 @@
1
- import type { StorageModuleConfig, BucketConfig } from './types';
1
+ import type { BucketConfig, StorageModuleConfig } from './types';
2
2
  /**
3
3
  * Resolve the app-level storage module config for a database, using the LRU cache.
4
4
  *
@@ -100,6 +100,17 @@ export declare function resolveStorageConfigFromCodec(pgCodec: {
100
100
  };
101
101
  sqlType?: string;
102
102
  }, allConfigs: StorageModuleConfig[]): StorageModuleConfig | null;
103
+ /**
104
+ * Normalize the recorded physical coordinate at the DB boundary.
105
+ *
106
+ * A bucket row either carries a recorded coordinate or it does not; SQL nulls
107
+ * and absent columns both mean "never provisioned". Collapsing them here is
108
+ * the single place that shape is interpreted — callers branch on `string`
109
+ * vs `null` and never coalesce a bucket name into existence.
110
+ */
111
+ export declare function storedPhysicalName(row: {
112
+ physical_name?: string | null;
113
+ }): string | null;
103
114
  /**
104
115
  * Resolve bucket metadata for a given database + bucket key, using the LRU cache.
105
116
  *
@@ -1,6 +1,6 @@
1
1
  import { Logger } from '@pgpmjs/logger';
2
- import { LRUCache } from 'lru-cache';
3
2
  import { QuoteUtils } from '@pgsql/quotes';
3
+ import { LRUCache } from 'lru-cache';
4
4
  const log = new Logger('graphile-presigned-url:cache');
5
5
  // --- Defaults ---
6
6
  const DEFAULT_UPLOAD_URL_EXPIRY_SECONDS = 900; // 15 minutes
@@ -320,6 +320,17 @@ const bucketCache = new LRUCache({
320
320
  ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
321
321
  updateAgeOnGet: true,
322
322
  });
323
+ /**
324
+ * Normalize the recorded physical coordinate at the DB boundary.
325
+ *
326
+ * A bucket row either carries a recorded coordinate or it does not; SQL nulls
327
+ * and absent columns both mean "never provisioned". Collapsing them here is
328
+ * the single place that shape is interpreted — callers branch on `string`
329
+ * vs `null` and never coalesce a bucket name into existence.
330
+ */
331
+ export function storedPhysicalName(row) {
332
+ return row.physical_name == null ? null : row.physical_name;
333
+ }
323
334
  /**
324
335
  * Resolve bucket metadata for a given database + bucket key, using the LRU cache.
325
336
  *
@@ -346,11 +357,11 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
346
357
  const hasOwner = ownerId && isEntityScoped;
347
358
  const result = await pgClient.query({
348
359
  text: hasOwner
349
- ? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys
360
+ ? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys, physical_name
350
361
  FROM ${storageConfig.bucketsQualifiedName}
351
362
  WHERE key = $1 AND owner_id = $2
352
363
  LIMIT 1`
353
- : `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys
364
+ : `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys, physical_name
354
365
  FROM ${storageConfig.bucketsQualifiedName}
355
366
  WHERE key = $1
356
367
  LIMIT 1`,
@@ -369,6 +380,7 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
369
380
  allowed_mime_types: row.allowed_mime_types,
370
381
  max_file_size: row.max_file_size,
371
382
  allow_custom_keys: row.allow_custom_keys ?? false,
383
+ physical_name: storedPhysicalName(row),
372
384
  };
373
385
  bucketCache.set(cacheKey, config);
374
386
  log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.scope})`);
package/esm/types.d.ts CHANGED
@@ -11,6 +11,13 @@ export interface BucketConfig {
11
11
  allowed_mime_types: string[] | null;
12
12
  max_file_size: number | null;
13
13
  allow_custom_keys: boolean;
14
+ /**
15
+ * The physical S3/MinIO bucket name recorded when the physical bucket was
16
+ * first provisioned. NULL until the first upload provisions it. Once set,
17
+ * it is the source of truth for the physical bucket — reads never
18
+ * reconstruct the name from a prefix convention.
19
+ */
20
+ physical_name: string | null;
14
21
  }
15
22
  /**
16
23
  * Storage module configuration resolved from metaschema for a given database.
package/index.d.ts CHANGED
@@ -26,9 +26,9 @@
26
26
  * };
27
27
  * ```
28
28
  */
29
- export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
30
29
  export { createDownloadUrlPlugin } from './download-url-field';
30
+ export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
31
31
  export { PresignedUrlPreset } from './preset';
32
- export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, loadAllStorageModules, resolveStorageConfigFromCodec, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
- export { generatePresignedPutUrl, generatePresignedGetUrl, deleteS3Object, headObject } from './s3-signer';
34
- export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';
32
+ export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
33
+ export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
34
+ export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
package/index.js CHANGED
@@ -28,27 +28,27 @@
28
28
  * ```
29
29
  */
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.headObject = exports.deleteS3Object = exports.generatePresignedGetUrl = exports.generatePresignedPutUrl = exports.markS3BucketProvisioned = exports.isS3BucketProvisioned = exports.clearBucketCache = exports.clearStorageModuleCache = exports.resolveStorageConfigFromCodec = exports.loadAllStorageModules = exports.resolveStorageModuleByFileId = exports.getBucketConfig = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.PresignedUrlPreset = exports.createDownloadUrlPlugin = exports.createPresignedUrlPlugin = exports.PresignedUrlPlugin = void 0;
32
- var plugin_1 = require("./plugin");
33
- Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
34
- Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
31
+ exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.createDownloadUrlPlugin = void 0;
35
32
  var download_url_field_1 = require("./download-url-field");
36
33
  Object.defineProperty(exports, "createDownloadUrlPlugin", { enumerable: true, get: function () { return download_url_field_1.createDownloadUrlPlugin; } });
34
+ var plugin_1 = require("./plugin");
35
+ Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
36
+ Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
37
37
  var preset_1 = require("./preset");
38
38
  Object.defineProperty(exports, "PresignedUrlPreset", { enumerable: true, get: function () { return preset_1.PresignedUrlPreset; } });
39
+ var s3_signer_1 = require("./s3-signer");
40
+ Object.defineProperty(exports, "deleteS3Object", { enumerable: true, get: function () { return s3_signer_1.deleteS3Object; } });
41
+ Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
42
+ Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });
43
+ Object.defineProperty(exports, "headObject", { enumerable: true, get: function () { return s3_signer_1.headObject; } });
39
44
  var storage_module_cache_1 = require("./storage-module-cache");
45
+ Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
46
+ Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
47
+ Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
40
48
  Object.defineProperty(exports, "getStorageModuleConfig", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfig; } });
41
49
  Object.defineProperty(exports, "getStorageModuleConfigForOwner", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfigForOwner; } });
42
- Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
43
- Object.defineProperty(exports, "resolveStorageModuleByFileId", { enumerable: true, get: function () { return storage_module_cache_1.resolveStorageModuleByFileId; } });
44
- Object.defineProperty(exports, "loadAllStorageModules", { enumerable: true, get: function () { return storage_module_cache_1.loadAllStorageModules; } });
45
- Object.defineProperty(exports, "resolveStorageConfigFromCodec", { enumerable: true, get: function () { return storage_module_cache_1.resolveStorageConfigFromCodec; } });
46
- Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
47
- Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
48
50
  Object.defineProperty(exports, "isS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.isS3BucketProvisioned; } });
51
+ Object.defineProperty(exports, "loadAllStorageModules", { enumerable: true, get: function () { return storage_module_cache_1.loadAllStorageModules; } });
49
52
  Object.defineProperty(exports, "markS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.markS3BucketProvisioned; } });
50
- var s3_signer_1 = require("./s3-signer");
51
- Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });
52
- Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
53
- Object.defineProperty(exports, "deleteS3Object", { enumerable: true, get: function () { return s3_signer_1.deleteS3Object; } });
54
- Object.defineProperty(exports, "headObject", { enumerable: true, get: function () { return s3_signer_1.headObject; } });
53
+ Object.defineProperty(exports, "resolveStorageConfigFromCodec", { enumerable: true, get: function () { return storage_module_cache_1.resolveStorageConfigFromCodec; } });
54
+ Object.defineProperty(exports, "resolveStorageModuleByFileId", { enumerable: true, get: function () { return storage_module_cache_1.resolveStorageModuleByFileId; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-presigned-url-plugin",
3
- "version": "1.9.2",
3
+ "version": "1.10.0",
4
4
  "description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -42,8 +42,8 @@
42
42
  "dependencies": {
43
43
  "@aws-sdk/client-s3": "^3.1052.0",
44
44
  "@aws-sdk/s3-request-presigner": "^3.1052.0",
45
- "@pgpmjs/logger": "^2.23.2",
46
- "@pgsql/quotes": "^18.1.0",
45
+ "@pgpmjs/logger": "^2.23.3",
46
+ "@pgsql/quotes": "^18.2.0",
47
47
  "lru-cache": "^11.2.7"
48
48
  },
49
49
  "peerDependencies": {
@@ -56,9 +56,9 @@
56
56
  "postgraphile": "5.0.3"
57
57
  },
58
58
  "devDependencies": {
59
- "@constructive-io/s3-utils": "^2.29.2",
59
+ "@constructive-io/s3-utils": "^2.29.3",
60
60
  "@types/node": "^22.19.11",
61
61
  "makage": "^0.3.0"
62
62
  },
63
- "gitHead": "90f447482b056a4949134565db2665f21d11aca3"
63
+ "gitHead": "c438821362ceaf70b2a20d74ecf25ad4191c1748"
64
64
  }
package/plugin.d.ts CHANGED
@@ -16,8 +16,8 @@
16
16
  * Scope resolution uses the codec's schema/table name matched against
17
17
  * cached storage module configs.
18
18
  */
19
- import type { GraphileConfig } from 'graphile-config';
20
19
  import 'graphile-build';
20
+ import type { GraphileConfig } from 'graphile-config';
21
21
  import type { PresignedUrlPluginOptions } from './types';
22
22
  export declare function createPresignedUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
23
23
  export declare const PresignedUrlPlugin: typeof createPresignedUrlPlugin;
package/plugin.js CHANGED
@@ -20,18 +20,18 @@
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.PresignedUrlPlugin = void 0;
22
22
  exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
23
- const grafast_1 = require("grafast");
24
23
  require("graphile-build");
25
24
  const logger_1 = require("@pgpmjs/logger");
26
- const storage_module_cache_1 = require("./storage-module-cache");
25
+ const grafast_1 = require("grafast");
27
26
  const s3_signer_1 = require("./s3-signer");
27
+ const storage_module_cache_1 = require("./storage-module-cache");
28
28
  const log = new logger_1.Logger('graphile-presigned-url:plugin');
29
29
  // --- Protocol-level constants (not configurable) ---
30
30
  const MAX_CONTENT_HASH_LENGTH = 128;
31
31
  const MAX_CONTENT_TYPE_LENGTH = 255;
32
32
  const MAX_CUSTOM_KEY_LENGTH = 1024;
33
33
  const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
34
- const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-\/]*$/;
34
+ const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-/]*$/;
35
35
  // --- Helpers ---
36
36
  function isValidSha256(hash) {
37
37
  return SHA256_HEX_REGEX.test(hash);
@@ -78,30 +78,72 @@ function resolveS3(options) {
78
78
  }
79
79
  return options.s3;
80
80
  }
81
- function resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey) {
81
+ /**
82
+ * Mint the physical S3 bucket name for a logical bucket's first provision.
83
+ *
84
+ * This is a naming *policy*, consulted exactly once per bucket — before the
85
+ * physical bucket exists. Once provisioned, the recorded `physical_name` on
86
+ * the row is authoritative and this function must not be consulted again.
87
+ */
88
+ function mintPhysicalBucketName(options, databaseId, bucketKey) {
89
+ if (options.resolveBucketName) {
90
+ return options.resolveBucketName(databaseId, bucketKey);
91
+ }
92
+ // Single-bucket deployment: the globally configured bucket is the physical bucket.
93
+ return resolveS3(options).bucket;
94
+ }
95
+ /**
96
+ * Build the S3 config for a *known* physical bucket. `physicalName` is
97
+ * required — callers must resolve the coordinate (stored row value, or a
98
+ * freshly provisioned name) before getting here. No name is ever recomputed.
99
+ */
100
+ function resolveS3ForDatabase(options, storageConfig, physicalName) {
82
101
  const globalS3 = resolveS3(options);
83
- const bucket = options.resolveBucketName
84
- ? options.resolveBucketName(databaseId, bucketKey)
85
- : globalS3.bucket;
86
- const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
87
- if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
102
+ const publicUrlPrefix = storageConfig.publicUrlPrefix != null
103
+ ? storageConfig.publicUrlPrefix
104
+ : globalS3.publicUrlPrefix;
105
+ if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
88
106
  return globalS3;
89
107
  }
90
108
  return {
91
109
  ...globalS3,
92
- bucket,
110
+ bucket: physicalName,
93
111
  ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
94
112
  };
95
113
  }
96
- async function ensureS3BucketExists(options, s3BucketName, bucket, databaseId, allowedOrigins) {
97
- if (!options.ensureBucketProvisioned)
98
- return;
99
- if ((0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName))
100
- return;
101
- log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
102
- await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
103
- (0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
104
- log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
114
+ /**
115
+ * First provision of a logical bucket: mint a name, create the physical S3
116
+ * bucket, and record the exact name on the source row. Returns the recorded
117
+ * physical name.
118
+ *
119
+ * Only called when the row has no `physical_name` yet. Afterwards the stored
120
+ * value is the durable coordinate: route resolution and every later read use
121
+ * it verbatim; nothing is recomputed.
122
+ *
123
+ * The record write runs in the system lane (`withPgClient(null, ...)`) — it is
124
+ * server bookkeeping, not request data, and anonymous request roles cannot
125
+ * UPDATE bucket rows under RLS. `bucket` (the cached config) is mutated in place
126
+ * so subsequent reads observe the recorded name without a DB round-trip.
127
+ */
128
+ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
129
+ const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
130
+ if (options.ensureBucketProvisioned && !(0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName)) {
131
+ log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
132
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
133
+ (0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
134
+ log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
135
+ }
136
+ // Record the physical coordinate on the source row. The `physical_name IS NULL`
137
+ // guard keeps this idempotent and race-safe across concurrent first uploads.
138
+ await withPgClient(null, (client) => client.query({
139
+ text: `UPDATE ${storageConfig.bucketsQualifiedName}
140
+ SET physical_name = $1
141
+ WHERE id = $2 AND physical_name IS NULL`,
142
+ values: [s3BucketName, bucket.id],
143
+ }));
144
+ bucket.physical_name = s3BucketName;
145
+ log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
146
+ return s3BucketName;
105
147
  }
106
148
  // --- Plugin factory ---
107
149
  function createPresignedUrlPlugin(options) {
@@ -215,22 +257,24 @@ function createPresignedUrlPlugin(options) {
215
257
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
216
258
  if (!storageConfig)
217
259
  throw new Error('STORAGE_MODULE_NOT_FOUND');
218
- return vals.withPgClient(vals.pgSettings, async (pgClient) => {
219
- return pgClient.withTransaction(async (txClient) => {
220
- const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
221
- if (!bucket)
222
- throw new Error('BUCKET_NOT_FOUND');
223
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucket.key);
224
- await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
225
- return processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
226
- contentHash: vals.contentHash,
227
- contentType: vals.contentType,
228
- size: vals.size,
229
- filename: vals.filename,
230
- key: vals.customKey,
231
- });
232
- });
233
- });
260
+ // Bucket config read under the request role (RLS-gated visibility).
261
+ const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
262
+ if (!bucket)
263
+ throw new Error('BUCKET_NOT_FOUND');
264
+ // First provision mints + records the coordinate; afterwards the
265
+ // stored physical_name is authoritative and nothing is recomputed.
266
+ const physicalName = bucket.physical_name === null
267
+ ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
268
+ : bucket.physical_name;
269
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
270
+ // File row INSERT under the request role (RLS enforced).
271
+ return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction((txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
272
+ contentHash: vals.contentHash,
273
+ contentType: vals.contentType,
274
+ size: vals.size,
275
+ filename: vals.filename,
276
+ key: vals.customKey,
277
+ })));
234
278
  });
235
279
  },
236
280
  });
@@ -304,35 +348,39 @@ function createPresignedUrlPlugin(options) {
304
348
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
305
349
  if (!storageConfig)
306
350
  throw new Error('STORAGE_MODULE_NOT_FOUND');
307
- return vals.withPgClient(vals.pgSettings, async (pgClient) => {
308
- return pgClient.withTransaction(async (txClient) => {
309
- const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
310
- if (!bucket)
311
- throw new Error('BUCKET_NOT_FOUND');
312
- // Enforce bulk upload limits
313
- const filesArray = vals.files;
314
- if (filesArray.length > storageConfig.maxBulkFiles) {
315
- throw new Error(`BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`);
316
- }
317
- const totalSize = filesArray.reduce((sum, f) => sum + (f.size || 0), 0);
318
- if (totalSize > storageConfig.maxBulkTotalSize) {
319
- throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
320
- }
321
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucket.key);
322
- await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
323
- const results = [];
324
- for (const file of filesArray) {
325
- results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
326
- contentHash: file.contentHash,
327
- contentType: file.contentType,
328
- size: file.size,
329
- filename: file.filename,
330
- key: file.key,
331
- }));
332
- }
333
- return { files: results };
334
- });
335
- });
351
+ // Bucket config read under the request role (RLS-gated visibility).
352
+ const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
353
+ if (!bucket)
354
+ throw new Error('BUCKET_NOT_FOUND');
355
+ // Enforce bulk upload limits
356
+ const filesArray = vals.files;
357
+ if (filesArray.length > storageConfig.maxBulkFiles) {
358
+ throw new Error(`BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`);
359
+ }
360
+ const totalSize = filesArray.reduce((sum, f) => sum + (f.size || 0), 0);
361
+ if (totalSize > storageConfig.maxBulkTotalSize) {
362
+ throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
363
+ }
364
+ // First provision mints + records the coordinate; afterwards the
365
+ // stored physical_name is authoritative and nothing is recomputed.
366
+ const physicalName = bucket.physical_name === null
367
+ ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
368
+ : bucket.physical_name;
369
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
370
+ // File row INSERTs under the request role (RLS enforced).
371
+ return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction(async (txClient) => {
372
+ const results = [];
373
+ for (const file of filesArray) {
374
+ results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
375
+ contentHash: file.contentHash,
376
+ contentType: file.contentType,
377
+ size: file.size,
378
+ filename: file.filename,
379
+ key: file.key,
380
+ }));
381
+ }
382
+ return { files: results };
383
+ }));
336
384
  });
337
385
  },
338
386
  });
@@ -429,18 +477,25 @@ function createPresignedUrlPlugin(options) {
429
477
  log.info(`File deleted from DB; S3 key ${fileRow.key} still referenced by ${refCount} file(s)`);
430
478
  return;
431
479
  }
432
- // No other references — attempt sync S3 delete
433
- // Look up the bucket key for scoped S3 resolution
480
+ // No other references — attempt sync S3 delete.
481
+ // Read the stored physical coordinate; the object lives in the
482
+ // recorded bucket, never a recomputed prefix name.
434
483
  const bucketResult = await pgClient.query({
435
- text: `SELECT key FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
484
+ text: `SELECT key, physical_name FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
436
485
  values: [fileRow.bucket_id],
437
486
  });
438
- const bucketKey = bucketResult.rows[0]?.key;
439
- if (!bucketKey) {
487
+ const bucketRow = bucketResult.rows[0];
488
+ if (!bucketRow) {
440
489
  log.warn(`Bucket not found for bucket_id=${fileRow.bucket_id}; skipping S3 delete`);
441
490
  return;
442
491
  }
443
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey);
492
+ const physicalName = (0, storage_module_cache_1.storedPhysicalName)(bucketRow);
493
+ if (physicalName === null) {
494
+ // No physical bucket was ever provisioned — there is no object to delete.
495
+ log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
496
+ return;
497
+ }
498
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
444
499
  await (0, s3_signer_1.deleteS3Object)(s3ForDb, fileRow.key);
445
500
  log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
446
501
  });
package/preset.js CHANGED
@@ -8,8 +8,8 @@
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.PresignedUrlPreset = PresignedUrlPreset;
11
- const plugin_1 = require("./plugin");
12
11
  const download_url_field_1 = require("./download-url-field");
12
+ const plugin_1 = require("./plugin");
13
13
  /**
14
14
  * Creates a preset that includes the presigned URL plugins with the given options.
15
15
  *
@@ -1,4 +1,4 @@
1
- import type { StorageModuleConfig, BucketConfig } from './types';
1
+ import type { BucketConfig, StorageModuleConfig } from './types';
2
2
  /**
3
3
  * Resolve the app-level storage module config for a database, using the LRU cache.
4
4
  *
@@ -100,6 +100,17 @@ export declare function resolveStorageConfigFromCodec(pgCodec: {
100
100
  };
101
101
  sqlType?: string;
102
102
  }, allConfigs: StorageModuleConfig[]): StorageModuleConfig | null;
103
+ /**
104
+ * Normalize the recorded physical coordinate at the DB boundary.
105
+ *
106
+ * A bucket row either carries a recorded coordinate or it does not; SQL nulls
107
+ * and absent columns both mean "never provisioned". Collapsing them here is
108
+ * the single place that shape is interpreted — callers branch on `string`
109
+ * vs `null` and never coalesce a bucket name into existence.
110
+ */
111
+ export declare function storedPhysicalName(row: {
112
+ physical_name?: string | null;
113
+ }): string | null;
103
114
  /**
104
115
  * Resolve bucket metadata for a given database + bucket key, using the LRU cache.
105
116
  *
@@ -5,14 +5,15 @@ exports.getStorageModuleConfigForOwner = getStorageModuleConfigForOwner;
5
5
  exports.resolveStorageModuleByFileId = resolveStorageModuleByFileId;
6
6
  exports.loadAllStorageModules = loadAllStorageModules;
7
7
  exports.resolveStorageConfigFromCodec = resolveStorageConfigFromCodec;
8
+ exports.storedPhysicalName = storedPhysicalName;
8
9
  exports.getBucketConfig = getBucketConfig;
9
10
  exports.isS3BucketProvisioned = isS3BucketProvisioned;
10
11
  exports.markS3BucketProvisioned = markS3BucketProvisioned;
11
12
  exports.clearStorageModuleCache = clearStorageModuleCache;
12
13
  exports.clearBucketCache = clearBucketCache;
13
14
  const logger_1 = require("@pgpmjs/logger");
14
- const lru_cache_1 = require("lru-cache");
15
15
  const quotes_1 = require("@pgsql/quotes");
16
+ const lru_cache_1 = require("lru-cache");
16
17
  const log = new logger_1.Logger('graphile-presigned-url:cache');
17
18
  // --- Defaults ---
18
19
  const DEFAULT_UPLOAD_URL_EXPIRY_SECONDS = 900; // 15 minutes
@@ -332,6 +333,17 @@ const bucketCache = new lru_cache_1.LRUCache({
332
333
  ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
333
334
  updateAgeOnGet: true,
334
335
  });
336
+ /**
337
+ * Normalize the recorded physical coordinate at the DB boundary.
338
+ *
339
+ * A bucket row either carries a recorded coordinate or it does not; SQL nulls
340
+ * and absent columns both mean "never provisioned". Collapsing them here is
341
+ * the single place that shape is interpreted — callers branch on `string`
342
+ * vs `null` and never coalesce a bucket name into existence.
343
+ */
344
+ function storedPhysicalName(row) {
345
+ return row.physical_name == null ? null : row.physical_name;
346
+ }
335
347
  /**
336
348
  * Resolve bucket metadata for a given database + bucket key, using the LRU cache.
337
349
  *
@@ -358,11 +370,11 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, o
358
370
  const hasOwner = ownerId && isEntityScoped;
359
371
  const result = await pgClient.query({
360
372
  text: hasOwner
361
- ? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys
373
+ ? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys, physical_name
362
374
  FROM ${storageConfig.bucketsQualifiedName}
363
375
  WHERE key = $1 AND owner_id = $2
364
376
  LIMIT 1`
365
- : `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys
377
+ : `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys, physical_name
366
378
  FROM ${storageConfig.bucketsQualifiedName}
367
379
  WHERE key = $1
368
380
  LIMIT 1`,
@@ -381,6 +393,7 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, o
381
393
  allowed_mime_types: row.allowed_mime_types,
382
394
  max_file_size: row.max_file_size,
383
395
  allow_custom_keys: row.allow_custom_keys ?? false,
396
+ physical_name: storedPhysicalName(row),
384
397
  };
385
398
  bucketCache.set(cacheKey, config);
386
399
  log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.scope})`);
package/types.d.ts CHANGED
@@ -11,6 +11,13 @@ export interface BucketConfig {
11
11
  allowed_mime_types: string[] | null;
12
12
  max_file_size: number | null;
13
13
  allow_custom_keys: boolean;
14
+ /**
15
+ * The physical S3/MinIO bucket name recorded when the physical bucket was
16
+ * first provisioned. NULL until the first upload provisions it. Once set,
17
+ * it is the source of truth for the physical bucket — reads never
18
+ * reconstruct the name from a prefix convention.
19
+ */
20
+ physical_name: string | null;
14
21
  }
15
22
  /**
16
23
  * Storage module configuration resolved from metaschema for a given database.