graphile-presigned-url-plugin 1.9.3 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,7 @@ exports.createDownloadUrlPlugin = createDownloadUrlPlugin;
24
24
  require("graphile-build");
25
25
  const logger_1 = require("@pgpmjs/logger");
26
26
  const grafast_1 = require("grafast");
27
+ const request_pg_client_1 = require("./request-pg-client");
27
28
  const s3_signer_1 = require("./s3-signer");
28
29
  const storage_module_cache_1 = require("./storage-module-cache");
29
30
  const log = new logger_1.Logger('graphile-presigned-url:download-url');
@@ -49,21 +50,21 @@ function resolveS3(options) {
49
50
  return options.s3;
50
51
  }
51
52
  /**
52
- * Build a per-database S3Config by overlaying storage_module overrides
53
- * onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
53
+ * Build a per-database S3Config for a *known* physical bucket. `physicalName`
54
+ * is required — the stored coordinate on the bucket row is the only source;
55
+ * no name is ever recomputed here. Same logic as plugin.ts resolveS3ForDatabase.
54
56
  */
55
- function resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey) {
57
+ function resolveS3ForDatabase(options, storageConfig, physicalName) {
56
58
  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) {
59
+ const publicUrlPrefix = storageConfig.publicUrlPrefix != null
60
+ ? storageConfig.publicUrlPrefix
61
+ : globalS3.publicUrlPrefix;
62
+ if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
62
63
  return globalS3;
63
64
  }
64
65
  return {
65
66
  ...globalS3,
66
- bucket,
67
+ bucket: physicalName,
67
68
  ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
68
69
  };
69
70
  }
@@ -115,7 +116,7 @@ function createDownloadUrlPlugin(options) {
115
116
  let downloadUrlExpirySeconds = 3600;
116
117
  try {
117
118
  if (withPgClient && pgSettings) {
118
- const databaseId = await withPgClient(pgSettings, async (pgClient) => {
119
+ const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
119
120
  const dbResult = await pgClient.query({
120
121
  text: `SELECT jwt_private.current_database_id() AS id`,
121
122
  });
@@ -126,25 +127,24 @@ function createDownloadUrlPlugin(options) {
126
127
  const config = databaseId
127
128
  ? (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, await withPgClient(null, (pgClient) => (0, storage_module_cache_1.loadAllStorageModules)(pgClient, databaseId)))
128
129
  : null;
129
- const resolved = config
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 };
130
+ const resolved = config && bucketId
131
+ ? await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
132
+ // Look up the stored physical coordinate for scoped S3 resolution
133
+ const bucketResult = await pgClient.query({
134
+ text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
135
+ values: [bucketId],
136
+ });
137
+ const row = bucketResult.rows[0];
138
+ return row ? { config, physicalName: (0, storage_module_cache_1.storedPhysicalName)(row) } : null;
143
139
  })
144
140
  : null;
145
141
  if (resolved) {
142
+ if (resolved.physicalName === null) {
143
+ // No physical bucket was ever provisioned — no object can exist.
144
+ return null;
145
+ }
146
146
  downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
147
- s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId, resolved.bucketKey);
147
+ s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.physicalName);
148
148
  }
149
149
  }
150
150
  }
@@ -21,8 +21,9 @@
21
21
  import 'graphile-build';
22
22
  import { Logger } from '@pgpmjs/logger';
23
23
  import { context as grafastContext, lambda, object } from 'grafast';
24
+ import { withRequestPgClient } from './request-pg-client';
24
25
  import { generatePresignedGetUrl } from './s3-signer';
25
- import { loadAllStorageModules, resolveStorageConfigFromCodec } from './storage-module-cache';
26
+ import { loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
26
27
  const log = new Logger('graphile-presigned-url:download-url');
27
28
  /**
28
29
  * Creates the downloadUrl computed field plugin.
@@ -46,21 +47,21 @@ function resolveS3(options) {
46
47
  return options.s3;
47
48
  }
48
49
  /**
49
- * Build a per-database S3Config by overlaying storage_module overrides
50
- * onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
50
+ * Build a per-database S3Config for a *known* physical bucket. `physicalName`
51
+ * is required — the stored coordinate on the bucket row is the only source;
52
+ * no name is ever recomputed here. Same logic as plugin.ts resolveS3ForDatabase.
51
53
  */
52
- function resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey) {
54
+ function resolveS3ForDatabase(options, storageConfig, physicalName) {
53
55
  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) {
56
+ const publicUrlPrefix = storageConfig.publicUrlPrefix != null
57
+ ? storageConfig.publicUrlPrefix
58
+ : globalS3.publicUrlPrefix;
59
+ if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
59
60
  return globalS3;
60
61
  }
61
62
  return {
62
63
  ...globalS3,
63
- bucket,
64
+ bucket: physicalName,
64
65
  ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
65
66
  };
66
67
  }
@@ -112,7 +113,7 @@ export function createDownloadUrlPlugin(options) {
112
113
  let downloadUrlExpirySeconds = 3600;
113
114
  try {
114
115
  if (withPgClient && pgSettings) {
115
- const databaseId = await withPgClient(pgSettings, async (pgClient) => {
116
+ const databaseId = await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
116
117
  const dbResult = await pgClient.query({
117
118
  text: `SELECT jwt_private.current_database_id() AS id`,
118
119
  });
@@ -123,25 +124,24 @@ export function createDownloadUrlPlugin(options) {
123
124
  const config = databaseId
124
125
  ? resolveStorageConfigFromCodec(capturedCodec, await withPgClient(null, (pgClient) => loadAllStorageModules(pgClient, databaseId)))
125
126
  : null;
126
- const resolved = config
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 };
127
+ const resolved = config && bucketId
128
+ ? await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
129
+ // Look up the stored physical coordinate for scoped S3 resolution
130
+ const bucketResult = await pgClient.query({
131
+ text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
132
+ values: [bucketId],
133
+ });
134
+ const row = bucketResult.rows[0];
135
+ return row ? { config, physicalName: storedPhysicalName(row) } : null;
140
136
  })
141
137
  : null;
142
138
  if (resolved) {
139
+ if (resolved.physicalName === null) {
140
+ // No physical bucket was ever provisioned — no object can exist.
141
+ return null;
142
+ }
143
143
  downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
144
- s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId, resolved.bucketKey);
144
+ s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.physicalName);
145
145
  }
146
146
  }
147
147
  }
package/esm/plugin.js CHANGED
@@ -19,8 +19,9 @@
19
19
  import 'graphile-build';
20
20
  import { Logger } from '@pgpmjs/logger';
21
21
  import { access, context as grafastContext, lambda, object } from 'grafast';
22
+ import { withRequestPgClient } from './request-pg-client';
22
23
  import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
23
- import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec } from './storage-module-cache';
24
+ import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
24
25
  const log = new Logger('graphile-presigned-url:plugin');
25
26
  // --- Protocol-level constants (not configurable) ---
26
27
  const MAX_CONTENT_HASH_LENGTH = 128;
@@ -74,30 +75,79 @@ function resolveS3(options) {
74
75
  }
75
76
  return options.s3;
76
77
  }
77
- function resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey) {
78
+ /**
79
+ * Mint the physical S3 bucket name for a logical bucket's first provision.
80
+ *
81
+ * This is a naming *policy*, consulted exactly once per bucket — before the
82
+ * physical bucket exists. Once provisioned, the recorded `physical_name` on
83
+ * the row is authoritative and this function must not be consulted again.
84
+ */
85
+ function mintPhysicalBucketName(options, databaseId, bucketKey) {
86
+ if (options.resolveBucketName) {
87
+ return options.resolveBucketName(databaseId, bucketKey);
88
+ }
89
+ // Single-bucket deployment: the globally configured bucket is the physical bucket.
90
+ return resolveS3(options).bucket;
91
+ }
92
+ /**
93
+ * Build the S3 config for a *known* physical bucket. `physicalName` is
94
+ * required — callers must resolve the coordinate (stored row value, or a
95
+ * freshly provisioned name) before getting here. No name is ever recomputed.
96
+ */
97
+ function resolveS3ForDatabase(options, storageConfig, physicalName) {
78
98
  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) {
99
+ const publicUrlPrefix = storageConfig.publicUrlPrefix != null
100
+ ? storageConfig.publicUrlPrefix
101
+ : globalS3.publicUrlPrefix;
102
+ if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
84
103
  return globalS3;
85
104
  }
86
105
  return {
87
106
  ...globalS3,
88
- bucket,
107
+ bucket: physicalName,
89
108
  ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
90
109
  };
91
110
  }
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`);
111
+ /**
112
+ * First provision of a logical bucket: mint a name, create the physical S3
113
+ * bucket, and record the exact name on the source row. Returns the recorded
114
+ * physical name.
115
+ *
116
+ * Only called when the row has no `physical_name` yet. Afterwards the stored
117
+ * value is the durable coordinate: route resolution and every later read use
118
+ * it verbatim; nothing is recomputed.
119
+ *
120
+ * The record write runs in the system lane (privileged role, so it bypasses the
121
+ * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
122
+ * bookkeeping, not request data. It still carries the tenant `database_id`
123
+ * claim, because the buckets table's catalog-sync trigger calls
124
+ * `jwt_private.current_database_id()` and would otherwise raise
125
+ * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
126
+ * write's transaction without switching off the privileged role.
127
+ * `bucket` (the cached config) is mutated in place so subsequent reads observe
128
+ * the recorded name without a DB round-trip.
129
+ */
130
+ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
131
+ const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
132
+ if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) {
133
+ log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
134
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
135
+ markS3BucketProvisioned(s3BucketName);
136
+ log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
137
+ }
138
+ // Record the physical coordinate on the source row. The `physical_name IS NULL`
139
+ // guard keeps this idempotent and race-safe across concurrent first uploads.
140
+ // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
141
+ // write runs under the resolved database claim (privileged role preserved).
142
+ await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
143
+ text: `UPDATE ${storageConfig.bucketsQualifiedName}
144
+ SET physical_name = $1
145
+ WHERE id = $2 AND physical_name IS NULL`,
146
+ values: [s3BucketName, bucket.id],
147
+ }));
148
+ bucket.physical_name = s3BucketName;
149
+ log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
150
+ return s3BucketName;
101
151
  }
102
152
  // --- Plugin factory ---
103
153
  export function createPresignedUrlPlugin(options) {
@@ -202,7 +252,10 @@ export function createPresignedUrlPlugin(options) {
202
252
  pgSettings: $pgSettings,
203
253
  });
204
254
  return lambda($combined, async (vals) => {
205
- const databaseId = await vals.withPgClient(vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
255
+ // Request-lane reads/writes run under the request role's pgSettings
256
+ // inside an explicit transaction so the jwt claims stay applied
257
+ // across every statement (see withRequestPgClient).
258
+ const databaseId = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
206
259
  if (!databaseId)
207
260
  throw new Error('DATABASE_NOT_FOUND');
208
261
  // Module registration is server config, not user data:
@@ -211,22 +264,24 @@ export function createPresignedUrlPlugin(options) {
211
264
  const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
212
265
  if (!storageConfig)
213
266
  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
- });
267
+ // Bucket config read under the request role (RLS-gated visibility).
268
+ const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
269
+ if (!bucket)
270
+ throw new Error('BUCKET_NOT_FOUND');
271
+ // First provision mints + records the coordinate; afterwards the
272
+ // stored physical_name is authoritative and nothing is recomputed.
273
+ const physicalName = bucket.physical_name === null
274
+ ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
275
+ : bucket.physical_name;
276
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
277
+ // File row INSERT under the request role (RLS enforced).
278
+ return withRequestPgClient(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
279
+ contentHash: vals.contentHash,
280
+ contentType: vals.contentType,
281
+ size: vals.size,
282
+ filename: vals.filename,
283
+ key: vals.customKey,
284
+ }));
230
285
  });
231
286
  },
232
287
  });
@@ -291,7 +346,10 @@ export function createPresignedUrlPlugin(options) {
291
346
  pgSettings: $pgSettings,
292
347
  });
293
348
  return lambda($combined, async (vals) => {
294
- const databaseId = await vals.withPgClient(vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
349
+ // Request-lane reads/writes run under the request role's pgSettings
350
+ // inside an explicit transaction so the jwt claims stay applied
351
+ // across every statement (see withRequestPgClient).
352
+ const databaseId = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
295
353
  if (!databaseId)
296
354
  throw new Error('DATABASE_NOT_FOUND');
297
355
  // Module registration is server config, not user data:
@@ -300,34 +358,38 @@ export function createPresignedUrlPlugin(options) {
300
358
  const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
301
359
  if (!storageConfig)
302
360
  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
- });
361
+ // Bucket config read under the request role (RLS-gated visibility).
362
+ const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
363
+ if (!bucket)
364
+ throw new Error('BUCKET_NOT_FOUND');
365
+ // Enforce bulk upload limits
366
+ const filesArray = vals.files;
367
+ if (filesArray.length > storageConfig.maxBulkFiles) {
368
+ throw new Error(`BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`);
369
+ }
370
+ const totalSize = filesArray.reduce((sum, f) => sum + (f.size || 0), 0);
371
+ if (totalSize > storageConfig.maxBulkTotalSize) {
372
+ throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
373
+ }
374
+ // First provision mints + records the coordinate; afterwards the
375
+ // stored physical_name is authoritative and nothing is recomputed.
376
+ const physicalName = bucket.physical_name === null
377
+ ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
378
+ : bucket.physical_name;
379
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
380
+ // File row INSERTs under the request role (RLS enforced).
381
+ return withRequestPgClient(vals.withPgClient, vals.pgSettings, async (txClient) => {
382
+ const results = [];
383
+ for (const file of filesArray) {
384
+ results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
385
+ contentHash: file.contentHash,
386
+ contentType: file.contentType,
387
+ size: file.size,
388
+ filename: file.filename,
389
+ key: file.key,
390
+ }));
391
+ }
392
+ return { files: results };
331
393
  });
332
394
  });
333
395
  },
@@ -373,7 +435,7 @@ export function createPresignedUrlPlugin(options) {
373
435
  const pgSettings = graphqlContext.pgSettings;
374
436
  if (withPgClient) {
375
437
  try {
376
- const databaseId = await withPgClient(pgSettings, (pgClient) => resolveDatabaseId(pgClient));
438
+ const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
377
439
  // Module registration is server config, not user data:
378
440
  // resolve it without the request role's pgSettings.
379
441
  const allConfigs = databaseId
@@ -381,7 +443,7 @@ export function createPresignedUrlPlugin(options) {
381
443
  : [];
382
444
  const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs);
383
445
  if (storageConfig) {
384
- await withPgClient(pgSettings, async (pgClient) => {
446
+ await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
385
447
  // Read the file row (RLS enforced)
386
448
  const result = await pgClient.query({
387
449
  text: `SELECT key, bucket_id FROM ${storageConfig.filesQualifiedName} WHERE id = $1 LIMIT 1`,
@@ -406,7 +468,7 @@ export function createPresignedUrlPlugin(options) {
406
468
  const pgSettings = graphqlContext.pgSettings;
407
469
  if (withPgClient) {
408
470
  try {
409
- const databaseId = await withPgClient(pgSettings, (pgClient) => resolveDatabaseId(pgClient));
471
+ const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
410
472
  // Module registration is server config, not user data:
411
473
  // resolve it without the request role's pgSettings.
412
474
  const allConfigs = databaseId
@@ -414,7 +476,7 @@ export function createPresignedUrlPlugin(options) {
414
476
  : [];
415
477
  const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs);
416
478
  if (storageConfig)
417
- await withPgClient(pgSettings, async (pgClient) => {
479
+ await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
418
480
  // Check refcount: any other file with the same key in this bucket?
419
481
  const refResult = await pgClient.query({
420
482
  text: `SELECT COUNT(*)::int AS ref_count FROM ${storageConfig.filesQualifiedName} WHERE key = $1 AND bucket_id = $2`,
@@ -425,18 +487,25 @@ export function createPresignedUrlPlugin(options) {
425
487
  log.info(`File deleted from DB; S3 key ${fileRow.key} still referenced by ${refCount} file(s)`);
426
488
  return;
427
489
  }
428
- // No other references — attempt sync S3 delete
429
- // Look up the bucket key for scoped S3 resolution
490
+ // No other references — attempt sync S3 delete.
491
+ // Read the stored physical coordinate; the object lives in the
492
+ // recorded bucket, never a recomputed prefix name.
430
493
  const bucketResult = await pgClient.query({
431
- text: `SELECT key FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
494
+ text: `SELECT key, physical_name FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
432
495
  values: [fileRow.bucket_id],
433
496
  });
434
- const bucketKey = bucketResult.rows[0]?.key;
435
- if (!bucketKey) {
497
+ const bucketRow = bucketResult.rows[0];
498
+ if (!bucketRow) {
436
499
  log.warn(`Bucket not found for bucket_id=${fileRow.bucket_id}; skipping S3 delete`);
437
500
  return;
438
501
  }
439
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey);
502
+ const physicalName = storedPhysicalName(bucketRow);
503
+ if (physicalName === null) {
504
+ // No physical bucket was ever provisioned — there is no object to delete.
505
+ log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
506
+ return;
507
+ }
508
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
440
509
  await deleteS3Object(s3ForDb, fileRow.key);
441
510
  log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
442
511
  });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Run a callback under the request's pgSettings inside ONE explicit transaction.
3
+ *
4
+ * The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
5
+ * transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
6
+ * more than one statement WITHOUT an explicit surrounding transaction, each
7
+ * statement runs in its own implicit (autocommit) transaction, so a LOCAL
8
+ * setting applied for one statement is already gone by the next. The request
9
+ * role's jwt claims (notably `jwt.claims.database_id`) then vanish between
10
+ * statements, and `jwt_private.current_database_id()` raises
11
+ * DATABASE_CLAIM_REQUIRED even though the request carried the claim.
12
+ *
13
+ * This helper acquires the client without settings (`withPgClient(null, ...)`),
14
+ * opens a single explicit transaction, and applies the request settings inside
15
+ * it — so every statement in `cb` observes the same role and jwt claims. It
16
+ * mirrors the `pg-query-context` pattern used elsewhere in the codebase for
17
+ * manual, multi-statement RLS work.
18
+ */
19
+ export interface RequestPgClient {
20
+ query(opts: {
21
+ text: string;
22
+ values?: unknown[];
23
+ }): Promise<{
24
+ rows: Array<Record<string, unknown>>;
25
+ }>;
26
+ withTransaction<T>(cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
27
+ }
28
+ export type WithPgClient = <T>(pgSettings: Record<string, string> | null, cb: (client: RequestPgClient) => Promise<T>) => Promise<T>;
29
+ export declare function withRequestPgClient<T>(withPgClient: WithPgClient, pgSettings: Record<string, string> | null, cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Run a callback under the request's pgSettings inside ONE explicit transaction.
3
+ *
4
+ * The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
5
+ * transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
6
+ * more than one statement WITHOUT an explicit surrounding transaction, each
7
+ * statement runs in its own implicit (autocommit) transaction, so a LOCAL
8
+ * setting applied for one statement is already gone by the next. The request
9
+ * role's jwt claims (notably `jwt.claims.database_id`) then vanish between
10
+ * statements, and `jwt_private.current_database_id()` raises
11
+ * DATABASE_CLAIM_REQUIRED even though the request carried the claim.
12
+ *
13
+ * This helper acquires the client without settings (`withPgClient(null, ...)`),
14
+ * opens a single explicit transaction, and applies the request settings inside
15
+ * it — so every statement in `cb` observes the same role and jwt claims. It
16
+ * mirrors the `pg-query-context` pattern used elsewhere in the codebase for
17
+ * manual, multi-statement RLS work.
18
+ */
19
+ async function applyRequestSettings(tx, pgSettings) {
20
+ if (!pgSettings)
21
+ return;
22
+ const entries = Object.entries(pgSettings)
23
+ .filter(([, value]) => value != null)
24
+ .map(([key, value]) => [key, String(value)]);
25
+ if (entries.length === 0)
26
+ return;
27
+ await tx.query({
28
+ text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
29
+ values: [JSON.stringify(entries)],
30
+ });
31
+ }
32
+ export function withRequestPgClient(withPgClient, pgSettings, cb) {
33
+ return withPgClient(null, (client) => client.withTransaction(async (tx) => {
34
+ await applyRequestSettings(tx, pgSettings);
35
+ return cb(tx);
36
+ }));
37
+ }
@@ -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
  *
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-presigned-url-plugin",
3
- "version": "1.9.3",
3
+ "version": "1.11.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,7 +42,7 @@
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.3",
45
+ "@pgpmjs/logger": "^2.24.0",
46
46
  "@pgsql/quotes": "^18.2.0",
47
47
  "lru-cache": "^11.2.7"
48
48
  },
@@ -56,9 +56,9 @@
56
56
  "postgraphile": "5.0.3"
57
57
  },
58
58
  "devDependencies": {
59
- "@constructive-io/s3-utils": "^2.29.3",
59
+ "@constructive-io/s3-utils": "^2.30.0",
60
60
  "@types/node": "^22.19.11",
61
61
  "makage": "^0.3.0"
62
62
  },
63
- "gitHead": "c20b6be96b39bf71c0e2df0f4b51e05deacd006b"
63
+ "gitHead": "d508453a32a2f1d70ff6916871046615226e6f33"
64
64
  }
package/plugin.js CHANGED
@@ -23,6 +23,7 @@ exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
23
23
  require("graphile-build");
24
24
  const logger_1 = require("@pgpmjs/logger");
25
25
  const grafast_1 = require("grafast");
26
+ const request_pg_client_1 = require("./request-pg-client");
26
27
  const s3_signer_1 = require("./s3-signer");
27
28
  const storage_module_cache_1 = require("./storage-module-cache");
28
29
  const log = new logger_1.Logger('graphile-presigned-url:plugin');
@@ -78,30 +79,79 @@ function resolveS3(options) {
78
79
  }
79
80
  return options.s3;
80
81
  }
81
- function resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey) {
82
+ /**
83
+ * Mint the physical S3 bucket name for a logical bucket's first provision.
84
+ *
85
+ * This is a naming *policy*, consulted exactly once per bucket — before the
86
+ * physical bucket exists. Once provisioned, the recorded `physical_name` on
87
+ * the row is authoritative and this function must not be consulted again.
88
+ */
89
+ function mintPhysicalBucketName(options, databaseId, bucketKey) {
90
+ if (options.resolveBucketName) {
91
+ return options.resolveBucketName(databaseId, bucketKey);
92
+ }
93
+ // Single-bucket deployment: the globally configured bucket is the physical bucket.
94
+ return resolveS3(options).bucket;
95
+ }
96
+ /**
97
+ * Build the S3 config for a *known* physical bucket. `physicalName` is
98
+ * required — callers must resolve the coordinate (stored row value, or a
99
+ * freshly provisioned name) before getting here. No name is ever recomputed.
100
+ */
101
+ function resolveS3ForDatabase(options, storageConfig, physicalName) {
82
102
  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) {
103
+ const publicUrlPrefix = storageConfig.publicUrlPrefix != null
104
+ ? storageConfig.publicUrlPrefix
105
+ : globalS3.publicUrlPrefix;
106
+ if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
88
107
  return globalS3;
89
108
  }
90
109
  return {
91
110
  ...globalS3,
92
- bucket,
111
+ bucket: physicalName,
93
112
  ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
94
113
  };
95
114
  }
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`);
115
+ /**
116
+ * First provision of a logical bucket: mint a name, create the physical S3
117
+ * bucket, and record the exact name on the source row. Returns the recorded
118
+ * physical name.
119
+ *
120
+ * Only called when the row has no `physical_name` yet. Afterwards the stored
121
+ * value is the durable coordinate: route resolution and every later read use
122
+ * it verbatim; nothing is recomputed.
123
+ *
124
+ * The record write runs in the system lane (privileged role, so it bypasses the
125
+ * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
126
+ * bookkeeping, not request data. It still carries the tenant `database_id`
127
+ * claim, because the buckets table's catalog-sync trigger calls
128
+ * `jwt_private.current_database_id()` and would otherwise raise
129
+ * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
130
+ * write's transaction without switching off the privileged role.
131
+ * `bucket` (the cached config) is mutated in place so subsequent reads observe
132
+ * the recorded name without a DB round-trip.
133
+ */
134
+ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
135
+ const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
136
+ if (options.ensureBucketProvisioned && !(0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName)) {
137
+ log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
138
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
139
+ (0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
140
+ log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
141
+ }
142
+ // Record the physical coordinate on the source row. The `physical_name IS NULL`
143
+ // guard keeps this idempotent and race-safe across concurrent first uploads.
144
+ // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
145
+ // write runs under the resolved database claim (privileged role preserved).
146
+ await (0, request_pg_client_1.withRequestPgClient)(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
147
+ text: `UPDATE ${storageConfig.bucketsQualifiedName}
148
+ SET physical_name = $1
149
+ WHERE id = $2 AND physical_name IS NULL`,
150
+ values: [s3BucketName, bucket.id],
151
+ }));
152
+ bucket.physical_name = s3BucketName;
153
+ log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
154
+ return s3BucketName;
105
155
  }
106
156
  // --- Plugin factory ---
107
157
  function createPresignedUrlPlugin(options) {
@@ -206,7 +256,10 @@ function createPresignedUrlPlugin(options) {
206
256
  pgSettings: $pgSettings,
207
257
  });
208
258
  return (0, grafast_1.lambda)($combined, async (vals) => {
209
- const databaseId = await vals.withPgClient(vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
259
+ // Request-lane reads/writes run under the request role's pgSettings
260
+ // inside an explicit transaction so the jwt claims stay applied
261
+ // across every statement (see withRequestPgClient).
262
+ const databaseId = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
210
263
  if (!databaseId)
211
264
  throw new Error('DATABASE_NOT_FOUND');
212
265
  // Module registration is server config, not user data:
@@ -215,22 +268,24 @@ function createPresignedUrlPlugin(options) {
215
268
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
216
269
  if (!storageConfig)
217
270
  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
- });
271
+ // Bucket config read under the request role (RLS-gated visibility).
272
+ const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
273
+ if (!bucket)
274
+ throw new Error('BUCKET_NOT_FOUND');
275
+ // First provision mints + records the coordinate; afterwards the
276
+ // stored physical_name is authoritative and nothing is recomputed.
277
+ const physicalName = bucket.physical_name === null
278
+ ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
279
+ : bucket.physical_name;
280
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
281
+ // File row INSERT under the request role (RLS enforced).
282
+ return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
283
+ contentHash: vals.contentHash,
284
+ contentType: vals.contentType,
285
+ size: vals.size,
286
+ filename: vals.filename,
287
+ key: vals.customKey,
288
+ }));
234
289
  });
235
290
  },
236
291
  });
@@ -295,7 +350,10 @@ function createPresignedUrlPlugin(options) {
295
350
  pgSettings: $pgSettings,
296
351
  });
297
352
  return (0, grafast_1.lambda)($combined, async (vals) => {
298
- const databaseId = await vals.withPgClient(vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
353
+ // Request-lane reads/writes run under the request role's pgSettings
354
+ // inside an explicit transaction so the jwt claims stay applied
355
+ // across every statement (see withRequestPgClient).
356
+ const databaseId = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
299
357
  if (!databaseId)
300
358
  throw new Error('DATABASE_NOT_FOUND');
301
359
  // Module registration is server config, not user data:
@@ -304,34 +362,38 @@ function createPresignedUrlPlugin(options) {
304
362
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
305
363
  if (!storageConfig)
306
364
  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
- });
365
+ // Bucket config read under the request role (RLS-gated visibility).
366
+ const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
367
+ if (!bucket)
368
+ throw new Error('BUCKET_NOT_FOUND');
369
+ // Enforce bulk upload limits
370
+ const filesArray = vals.files;
371
+ if (filesArray.length > storageConfig.maxBulkFiles) {
372
+ throw new Error(`BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`);
373
+ }
374
+ const totalSize = filesArray.reduce((sum, f) => sum + (f.size || 0), 0);
375
+ if (totalSize > storageConfig.maxBulkTotalSize) {
376
+ throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
377
+ }
378
+ // First provision mints + records the coordinate; afterwards the
379
+ // stored physical_name is authoritative and nothing is recomputed.
380
+ const physicalName = bucket.physical_name === null
381
+ ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
382
+ : bucket.physical_name;
383
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
384
+ // File row INSERTs under the request role (RLS enforced).
385
+ return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, async (txClient) => {
386
+ const results = [];
387
+ for (const file of filesArray) {
388
+ results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
389
+ contentHash: file.contentHash,
390
+ contentType: file.contentType,
391
+ size: file.size,
392
+ filename: file.filename,
393
+ key: file.key,
394
+ }));
395
+ }
396
+ return { files: results };
335
397
  });
336
398
  });
337
399
  },
@@ -377,7 +439,7 @@ function createPresignedUrlPlugin(options) {
377
439
  const pgSettings = graphqlContext.pgSettings;
378
440
  if (withPgClient) {
379
441
  try {
380
- const databaseId = await withPgClient(pgSettings, (pgClient) => resolveDatabaseId(pgClient));
442
+ const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
381
443
  // Module registration is server config, not user data:
382
444
  // resolve it without the request role's pgSettings.
383
445
  const allConfigs = databaseId
@@ -385,7 +447,7 @@ function createPresignedUrlPlugin(options) {
385
447
  : [];
386
448
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, allConfigs);
387
449
  if (storageConfig) {
388
- await withPgClient(pgSettings, async (pgClient) => {
450
+ await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
389
451
  // Read the file row (RLS enforced)
390
452
  const result = await pgClient.query({
391
453
  text: `SELECT key, bucket_id FROM ${storageConfig.filesQualifiedName} WHERE id = $1 LIMIT 1`,
@@ -410,7 +472,7 @@ function createPresignedUrlPlugin(options) {
410
472
  const pgSettings = graphqlContext.pgSettings;
411
473
  if (withPgClient) {
412
474
  try {
413
- const databaseId = await withPgClient(pgSettings, (pgClient) => resolveDatabaseId(pgClient));
475
+ const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
414
476
  // Module registration is server config, not user data:
415
477
  // resolve it without the request role's pgSettings.
416
478
  const allConfigs = databaseId
@@ -418,7 +480,7 @@ function createPresignedUrlPlugin(options) {
418
480
  : [];
419
481
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, allConfigs);
420
482
  if (storageConfig)
421
- await withPgClient(pgSettings, async (pgClient) => {
483
+ await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
422
484
  // Check refcount: any other file with the same key in this bucket?
423
485
  const refResult = await pgClient.query({
424
486
  text: `SELECT COUNT(*)::int AS ref_count FROM ${storageConfig.filesQualifiedName} WHERE key = $1 AND bucket_id = $2`,
@@ -429,18 +491,25 @@ function createPresignedUrlPlugin(options) {
429
491
  log.info(`File deleted from DB; S3 key ${fileRow.key} still referenced by ${refCount} file(s)`);
430
492
  return;
431
493
  }
432
- // No other references — attempt sync S3 delete
433
- // Look up the bucket key for scoped S3 resolution
494
+ // No other references — attempt sync S3 delete.
495
+ // Read the stored physical coordinate; the object lives in the
496
+ // recorded bucket, never a recomputed prefix name.
434
497
  const bucketResult = await pgClient.query({
435
- text: `SELECT key FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
498
+ text: `SELECT key, physical_name FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
436
499
  values: [fileRow.bucket_id],
437
500
  });
438
- const bucketKey = bucketResult.rows[0]?.key;
439
- if (!bucketKey) {
501
+ const bucketRow = bucketResult.rows[0];
502
+ if (!bucketRow) {
440
503
  log.warn(`Bucket not found for bucket_id=${fileRow.bucket_id}; skipping S3 delete`);
441
504
  return;
442
505
  }
443
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId, bucketKey);
506
+ const physicalName = (0, storage_module_cache_1.storedPhysicalName)(bucketRow);
507
+ if (physicalName === null) {
508
+ // No physical bucket was ever provisioned — there is no object to delete.
509
+ log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
510
+ return;
511
+ }
512
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
444
513
  await (0, s3_signer_1.deleteS3Object)(s3ForDb, fileRow.key);
445
514
  log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
446
515
  });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Run a callback under the request's pgSettings inside ONE explicit transaction.
3
+ *
4
+ * The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
5
+ * transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
6
+ * more than one statement WITHOUT an explicit surrounding transaction, each
7
+ * statement runs in its own implicit (autocommit) transaction, so a LOCAL
8
+ * setting applied for one statement is already gone by the next. The request
9
+ * role's jwt claims (notably `jwt.claims.database_id`) then vanish between
10
+ * statements, and `jwt_private.current_database_id()` raises
11
+ * DATABASE_CLAIM_REQUIRED even though the request carried the claim.
12
+ *
13
+ * This helper acquires the client without settings (`withPgClient(null, ...)`),
14
+ * opens a single explicit transaction, and applies the request settings inside
15
+ * it — so every statement in `cb` observes the same role and jwt claims. It
16
+ * mirrors the `pg-query-context` pattern used elsewhere in the codebase for
17
+ * manual, multi-statement RLS work.
18
+ */
19
+ export interface RequestPgClient {
20
+ query(opts: {
21
+ text: string;
22
+ values?: unknown[];
23
+ }): Promise<{
24
+ rows: Array<Record<string, unknown>>;
25
+ }>;
26
+ withTransaction<T>(cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
27
+ }
28
+ export type WithPgClient = <T>(pgSettings: Record<string, string> | null, cb: (client: RequestPgClient) => Promise<T>) => Promise<T>;
29
+ export declare function withRequestPgClient<T>(withPgClient: WithPgClient, pgSettings: Record<string, string> | null, cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ /**
3
+ * Run a callback under the request's pgSettings inside ONE explicit transaction.
4
+ *
5
+ * The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
6
+ * transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
7
+ * more than one statement WITHOUT an explicit surrounding transaction, each
8
+ * statement runs in its own implicit (autocommit) transaction, so a LOCAL
9
+ * setting applied for one statement is already gone by the next. The request
10
+ * role's jwt claims (notably `jwt.claims.database_id`) then vanish between
11
+ * statements, and `jwt_private.current_database_id()` raises
12
+ * DATABASE_CLAIM_REQUIRED even though the request carried the claim.
13
+ *
14
+ * This helper acquires the client without settings (`withPgClient(null, ...)`),
15
+ * opens a single explicit transaction, and applies the request settings inside
16
+ * it — so every statement in `cb` observes the same role and jwt claims. It
17
+ * mirrors the `pg-query-context` pattern used elsewhere in the codebase for
18
+ * manual, multi-statement RLS work.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.withRequestPgClient = withRequestPgClient;
22
+ async function applyRequestSettings(tx, pgSettings) {
23
+ if (!pgSettings)
24
+ return;
25
+ const entries = Object.entries(pgSettings)
26
+ .filter(([, value]) => value != null)
27
+ .map(([key, value]) => [key, String(value)]);
28
+ if (entries.length === 0)
29
+ return;
30
+ await tx.query({
31
+ text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
32
+ values: [JSON.stringify(entries)],
33
+ });
34
+ }
35
+ function withRequestPgClient(withPgClient, pgSettings, cb) {
36
+ return withPgClient(null, (client) => client.withTransaction(async (tx) => {
37
+ await applyRequestSettings(tx, pgSettings);
38
+ return cb(tx);
39
+ }));
40
+ }
@@ -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,6 +5,7 @@ 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;
@@ -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.