graphile-presigned-url-plugin 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,25 @@ function resolveS3(options) {
40
40
  }
41
41
  return options.s3;
42
42
  }
43
+ /**
44
+ * Build a per-database S3Config by overlaying storage_module overrides
45
+ * onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
46
+ */
47
+ function resolveS3ForDatabase(options, storageConfig, databaseId) {
48
+ const globalS3 = resolveS3(options);
49
+ const bucket = options.resolveBucketName
50
+ ? options.resolveBucketName(databaseId)
51
+ : globalS3.bucket;
52
+ const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
53
+ if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
54
+ return globalS3;
55
+ }
56
+ return {
57
+ ...globalS3,
58
+ bucket,
59
+ ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
60
+ };
61
+ }
43
62
  function createDownloadUrlPlugin(options) {
44
63
  return {
45
64
  name: 'PresignedUrlDownloadPlugin',
@@ -76,35 +95,41 @@ function createDownloadUrlPlugin(options) {
76
95
  if (status !== 'ready' && status !== 'processed') {
77
96
  return null;
78
97
  }
79
- const s3 = resolveS3(options);
80
- if (isPublic && s3.publicUrlPrefix) {
81
- // Public file: return direct URL
82
- return `${s3.publicUrlPrefix}/${key}`;
83
- }
84
- // Resolve download URL expiry from storage module config (per-database)
98
+ // Resolve per-database config (bucket, publicUrlPrefix, expiry)
99
+ let s3ForDb = resolveS3(options); // fallback to global
85
100
  let downloadUrlExpirySeconds = 3600; // fallback default
86
101
  try {
87
102
  const withPgClient = context.pgSettings
88
103
  ? context.withPgClient
89
104
  : null;
90
105
  if (withPgClient) {
91
- const config = await withPgClient(null, async (pgClient) => {
92
- const dbResult = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
106
+ const resolved = await withPgClient(null, async (pgClient) => {
107
+ const dbResult = await pgClient.query({
108
+ text: `SELECT jwt_private.current_database_id() AS id`,
109
+ });
93
110
  const databaseId = dbResult.rows[0]?.id;
94
111
  if (!databaseId)
95
112
  return null;
96
- return (0, storage_module_cache_1.getStorageModuleConfig)(pgClient, databaseId);
113
+ const config = await (0, storage_module_cache_1.getStorageModuleConfig)(pgClient, databaseId);
114
+ if (!config)
115
+ return null;
116
+ return { config, databaseId };
97
117
  });
98
- if (config) {
99
- downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
118
+ if (resolved) {
119
+ downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
120
+ s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
100
121
  }
101
122
  }
102
123
  }
103
124
  catch {
104
- // Fall back to default if config lookup fails
125
+ // Fall back to global config if lookup fails
126
+ }
127
+ if (isPublic && s3ForDb.publicUrlPrefix) {
128
+ // Public file: return direct CDN URL (per-database prefix)
129
+ return `${s3ForDb.publicUrlPrefix}/${key}`;
105
130
  }
106
- // Private file: generate presigned GET URL
107
- return (0, s3_signer_1.generatePresignedGetUrl)(resolveS3(options), key, downloadUrlExpirySeconds, filename || undefined);
131
+ // Private file: generate presigned GET URL (per-database bucket)
132
+ return (0, s3_signer_1.generatePresignedGetUrl)(s3ForDb, key, downloadUrlExpirySeconds, filename || undefined);
108
133
  },
109
134
  }),
110
135
  }, 'PresignedUrlDownloadPlugin adding downloadUrl field');
@@ -37,6 +37,25 @@ function resolveS3(options) {
37
37
  }
38
38
  return options.s3;
39
39
  }
40
+ /**
41
+ * Build a per-database S3Config by overlaying storage_module overrides
42
+ * onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
43
+ */
44
+ function resolveS3ForDatabase(options, storageConfig, databaseId) {
45
+ const globalS3 = resolveS3(options);
46
+ const bucket = options.resolveBucketName
47
+ ? options.resolveBucketName(databaseId)
48
+ : globalS3.bucket;
49
+ const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
50
+ if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
51
+ return globalS3;
52
+ }
53
+ return {
54
+ ...globalS3,
55
+ bucket,
56
+ ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
57
+ };
58
+ }
40
59
  export function createDownloadUrlPlugin(options) {
41
60
  return {
42
61
  name: 'PresignedUrlDownloadPlugin',
@@ -73,35 +92,41 @@ export function createDownloadUrlPlugin(options) {
73
92
  if (status !== 'ready' && status !== 'processed') {
74
93
  return null;
75
94
  }
76
- const s3 = resolveS3(options);
77
- if (isPublic && s3.publicUrlPrefix) {
78
- // Public file: return direct URL
79
- return `${s3.publicUrlPrefix}/${key}`;
80
- }
81
- // Resolve download URL expiry from storage module config (per-database)
95
+ // Resolve per-database config (bucket, publicUrlPrefix, expiry)
96
+ let s3ForDb = resolveS3(options); // fallback to global
82
97
  let downloadUrlExpirySeconds = 3600; // fallback default
83
98
  try {
84
99
  const withPgClient = context.pgSettings
85
100
  ? context.withPgClient
86
101
  : null;
87
102
  if (withPgClient) {
88
- const config = await withPgClient(null, async (pgClient) => {
89
- const dbResult = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
103
+ const resolved = await withPgClient(null, async (pgClient) => {
104
+ const dbResult = await pgClient.query({
105
+ text: `SELECT jwt_private.current_database_id() AS id`,
106
+ });
90
107
  const databaseId = dbResult.rows[0]?.id;
91
108
  if (!databaseId)
92
109
  return null;
93
- return getStorageModuleConfig(pgClient, databaseId);
110
+ const config = await getStorageModuleConfig(pgClient, databaseId);
111
+ if (!config)
112
+ return null;
113
+ return { config, databaseId };
94
114
  });
95
- if (config) {
96
- downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
115
+ if (resolved) {
116
+ downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
117
+ s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
97
118
  }
98
119
  }
99
120
  }
100
121
  catch {
101
- // Fall back to default if config lookup fails
122
+ // Fall back to global config if lookup fails
123
+ }
124
+ if (isPublic && s3ForDb.publicUrlPrefix) {
125
+ // Public file: return direct CDN URL (per-database prefix)
126
+ return `${s3ForDb.publicUrlPrefix}/${key}`;
102
127
  }
103
- // Private file: generate presigned GET URL
104
- return generatePresignedGetUrl(resolveS3(options), key, downloadUrlExpirySeconds, filename || undefined);
128
+ // Private file: generate presigned GET URL (per-database bucket)
129
+ return generatePresignedGetUrl(s3ForDb, key, downloadUrlExpirySeconds, filename || undefined);
105
130
  },
106
131
  }),
107
132
  }, 'PresignedUrlDownloadPlugin adding downloadUrl field');
package/esm/index.d.ts CHANGED
@@ -29,6 +29,6 @@
29
29
  export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
30
30
  export { createDownloadUrlPlugin } from './download-url-field';
31
31
  export { PresignedUrlPreset } from './preset';
32
- export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
32
+ export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
33
  export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
34
- export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, } from './types';
34
+ export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';
package/esm/index.js CHANGED
@@ -29,5 +29,5 @@
29
29
  export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
30
30
  export { createDownloadUrlPlugin } from './download-url-field';
31
31
  export { PresignedUrlPreset } from './preset';
32
- export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
32
+ export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
33
  export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
package/esm/plugin.js CHANGED
@@ -19,7 +19,7 @@
19
19
  import { context as grafastContext, lambda, object } from 'grafast';
20
20
  import { extendSchema, gql } from 'graphile-utils';
21
21
  import { Logger } from '@pgpmjs/logger';
22
- import { getStorageModuleConfig, getBucketConfig } from './storage-module-cache';
22
+ import { getStorageModuleConfig, getBucketConfig, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
23
23
  import { generatePresignedPutUrl, headObject } from './s3-signer';
24
24
  const log = new Logger('graphile-presigned-url:plugin');
25
25
  // --- Protocol-level constants (not configurable) ---
@@ -48,7 +48,9 @@ function buildS3Key(contentHash) {
48
48
  * metaschema query needed.
49
49
  */
50
50
  async function resolveDatabaseId(pgClient) {
51
- const result = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
51
+ const result = await pgClient.query({
52
+ text: `SELECT jwt_private.current_database_id() AS id`,
53
+ });
52
54
  return result.rows[0]?.id ?? null;
53
55
  }
54
56
  // --- Plugin factory ---
@@ -66,6 +68,49 @@ function resolveS3(options) {
66
68
  }
67
69
  return options.s3;
68
70
  }
71
+ /**
72
+ * Build a per-database S3Config by overlaying storage_module overrides
73
+ * onto the global S3Config.
74
+ *
75
+ * - Bucket name: from resolveBucketName(databaseId) if provided, else global
76
+ * - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global
77
+ * - S3 client (credentials, endpoint): always global (shared IAM key)
78
+ */
79
+ function resolveS3ForDatabase(options, storageConfig, databaseId) {
80
+ const globalS3 = resolveS3(options);
81
+ const bucket = options.resolveBucketName
82
+ ? options.resolveBucketName(databaseId)
83
+ : globalS3.bucket;
84
+ const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
85
+ if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
86
+ return globalS3;
87
+ }
88
+ return {
89
+ ...globalS3,
90
+ bucket,
91
+ ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
92
+ };
93
+ }
94
+ /**
95
+ * Ensure the S3 bucket for a database exists, provisioning it lazily if needed.
96
+ *
97
+ * Checks an in-memory Set of known-provisioned bucket names. On the first
98
+ * request for an unseen bucket, calls the `ensureBucketProvisioned` callback
99
+ * (which creates the bucket with correct CORS, policies, etc.), then marks
100
+ * it as provisioned so subsequent requests skip the check entirely.
101
+ *
102
+ * If no `ensureBucketProvisioned` callback is configured, this is a no-op.
103
+ */
104
+ async function ensureS3BucketExists(options, s3BucketName, bucket, databaseId, allowedOrigins) {
105
+ if (!options.ensureBucketProvisioned)
106
+ return;
107
+ if (isS3BucketProvisioned(s3BucketName))
108
+ return;
109
+ log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
110
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
111
+ markS3BucketProvisioned(s3BucketName);
112
+ log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
113
+ }
69
114
  export function createPresignedUrlPlugin(options) {
70
115
  return extendSchema(() => ({
71
116
  typeDefs: gql `
@@ -157,14 +202,13 @@ export function createPresignedUrlPlugin(options) {
157
202
  throw new Error('INVALID_CONTENT_TYPE');
158
203
  }
159
204
  return withPgClient(pgSettings, async (pgClient) => {
160
- await pgClient.query('BEGIN');
161
- try {
205
+ return pgClient.withTransaction(async (txClient) => {
162
206
  // --- Resolve storage module config (all limits come from here) ---
163
- const databaseId = await resolveDatabaseId(pgClient);
207
+ const databaseId = await resolveDatabaseId(txClient);
164
208
  if (!databaseId) {
165
209
  throw new Error('DATABASE_NOT_FOUND');
166
210
  }
167
- const storageConfig = await getStorageModuleConfig(pgClient, databaseId);
211
+ const storageConfig = await getStorageModuleConfig(txClient, databaseId);
168
212
  if (!storageConfig) {
169
213
  throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
170
214
  }
@@ -178,7 +222,7 @@ export function createPresignedUrlPlugin(options) {
178
222
  }
179
223
  }
180
224
  // --- Look up the bucket (cached; first miss queries via RLS) ---
181
- const bucket = await getBucketConfig(pgClient, storageConfig, databaseId, bucketKey);
225
+ const bucket = await getBucketConfig(txClient, storageConfig, databaseId, bucketKey);
182
226
  if (!bucket) {
183
227
  throw new Error('BUCKET_NOT_FOUND');
184
228
  }
@@ -204,20 +248,25 @@ export function createPresignedUrlPlugin(options) {
204
248
  }
205
249
  const s3Key = buildS3Key(contentHash);
206
250
  // --- Dedup check: look for existing file with same content_hash in this bucket ---
207
- const dedupResult = await pgClient.query(`SELECT id, status
251
+ const dedupResult = await txClient.query({
252
+ text: `SELECT id, status
208
253
  FROM ${storageConfig.filesQualifiedName}
209
254
  WHERE content_hash = $1
210
255
  AND bucket_id = $2
211
256
  AND status IN ('ready', 'processed')
212
- LIMIT 1`, [contentHash, bucket.id]);
257
+ LIMIT 1`,
258
+ values: [contentHash, bucket.id],
259
+ });
213
260
  if (dedupResult.rows.length > 0) {
214
261
  const existingFile = dedupResult.rows[0];
215
262
  log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
216
263
  // Track the dedup request
217
- await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
264
+ await txClient.query({
265
+ text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
218
266
  (file_id, bucket_id, key, content_type, content_hash, size, status, expires_at)
219
- VALUES ($1, $2, $3, $4, $5, $6, 'confirmed', NOW())`, [existingFile.id, bucket.id, s3Key, contentType, contentHash, size]);
220
- await pgClient.query('COMMIT');
267
+ VALUES ($1, $2, $3, $4, $5, $6, 'confirmed', NOW())`,
268
+ values: [existingFile.id, bucket.id, s3Key, contentType, contentHash, size],
269
+ });
221
270
  return {
222
271
  uploadUrl: null,
223
272
  fileId: existingFile.id,
@@ -227,28 +276,36 @@ export function createPresignedUrlPlugin(options) {
227
276
  };
228
277
  }
229
278
  // --- Create file record (status=pending) ---
230
- const fileResult = await pgClient.query(`INSERT INTO ${storageConfig.filesQualifiedName}
279
+ const fileResult = await txClient.query({
280
+ text: `INSERT INTO ${storageConfig.filesQualifiedName}
231
281
  (bucket_id, key, content_type, content_hash, size, filename, owner_id, is_public, status)
232
282
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
233
- RETURNING id`, [
234
- bucket.id,
235
- s3Key,
236
- contentType,
237
- contentHash,
238
- size,
239
- filename || null,
240
- bucket.owner_id,
241
- bucket.is_public,
242
- ]);
283
+ RETURNING id`,
284
+ values: [
285
+ bucket.id,
286
+ s3Key,
287
+ contentType,
288
+ contentHash,
289
+ size,
290
+ filename || null,
291
+ bucket.owner_id,
292
+ bucket.is_public,
293
+ ],
294
+ });
243
295
  const fileId = fileResult.rows[0].id;
244
- // --- Generate presigned PUT URL ---
245
- const uploadUrl = await generatePresignedPutUrl(resolveS3(options), s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
296
+ // --- Ensure the S3 bucket exists (lazy provisioning) ---
297
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
298
+ await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
299
+ // --- Generate presigned PUT URL (per-database bucket) ---
300
+ const uploadUrl = await generatePresignedPutUrl(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
246
301
  const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
247
302
  // --- Track the upload request ---
248
- await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
303
+ await txClient.query({
304
+ text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
249
305
  (file_id, bucket_id, key, content_type, content_hash, size, status, expires_at)
250
- VALUES ($1, $2, $3, $4, $5, $6, 'issued', $7)`, [fileId, bucket.id, s3Key, contentType, contentHash, size, expiresAt]);
251
- await pgClient.query('COMMIT');
306
+ VALUES ($1, $2, $3, $4, $5, $6, 'issued', $7)`,
307
+ values: [fileId, bucket.id, s3Key, contentType, contentHash, size, expiresAt],
308
+ });
252
309
  return {
253
310
  uploadUrl,
254
311
  fileId,
@@ -256,11 +313,7 @@ export function createPresignedUrlPlugin(options) {
256
313
  deduplicated: false,
257
314
  expiresAt,
258
315
  };
259
- }
260
- catch (err) {
261
- await pgClient.query('ROLLBACK');
262
- throw err;
263
- }
316
+ });
264
317
  });
265
318
  });
266
319
  },
@@ -279,68 +332,73 @@ export function createPresignedUrlPlugin(options) {
279
332
  throw new Error('INVALID_FILE_ID');
280
333
  }
281
334
  return withPgClient(pgSettings, async (pgClient) => {
282
- await pgClient.query('BEGIN');
283
- try {
335
+ return pgClient.withTransaction(async (txClient) => {
284
336
  // --- Resolve storage module config ---
285
- const databaseId = await resolveDatabaseId(pgClient);
337
+ const databaseId = await resolveDatabaseId(txClient);
286
338
  if (!databaseId) {
287
339
  throw new Error('DATABASE_NOT_FOUND');
288
340
  }
289
- const storageConfig = await getStorageModuleConfig(pgClient, databaseId);
341
+ const storageConfig = await getStorageModuleConfig(txClient, databaseId);
290
342
  if (!storageConfig) {
291
343
  throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
292
344
  }
293
345
  // --- Look up the file (RLS enforced) ---
294
- const fileResult = await pgClient.query(`SELECT id, key, content_type, status, bucket_id
346
+ const fileResult = await txClient.query({
347
+ text: `SELECT id, key, content_type, status, bucket_id
295
348
  FROM ${storageConfig.filesQualifiedName}
296
349
  WHERE id = $1
297
- LIMIT 1`, [fileId]);
350
+ LIMIT 1`,
351
+ values: [fileId],
352
+ });
298
353
  if (fileResult.rows.length === 0) {
299
354
  throw new Error('FILE_NOT_FOUND');
300
355
  }
301
356
  const file = fileResult.rows[0];
302
357
  if (file.status !== 'pending') {
303
358
  // File is already confirmed or processed — idempotent success
304
- await pgClient.query('COMMIT');
305
359
  return {
306
360
  fileId: file.id,
307
361
  status: file.status,
308
362
  success: true,
309
363
  };
310
364
  }
311
- // --- Verify file exists in S3 ---
312
- const s3Head = await headObject(resolveS3(options), file.key, file.content_type);
365
+ // --- Verify file exists in S3 (per-database bucket) ---
366
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
367
+ const s3Head = await headObject(s3ForDb, file.key, file.content_type);
313
368
  if (!s3Head) {
314
369
  throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
315
370
  }
316
371
  // --- Content-type verification ---
317
372
  if (s3Head.contentType && s3Head.contentType !== file.content_type) {
318
373
  // Mark upload_request as rejected
319
- await pgClient.query(`UPDATE ${storageConfig.uploadRequestsQualifiedName}
374
+ await txClient.query({
375
+ text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
320
376
  SET status = 'rejected'
321
- WHERE file_id = $1 AND status = 'issued'`, [fileId]);
322
- await pgClient.query('COMMIT');
377
+ WHERE file_id = $1 AND status = 'issued'`,
378
+ values: [fileId],
379
+ });
323
380
  throw new Error(`CONTENT_TYPE_MISMATCH: expected ${file.content_type}, got ${s3Head.contentType}`);
324
381
  }
325
382
  // --- Transition file to 'ready' ---
326
- await pgClient.query(`UPDATE ${storageConfig.filesQualifiedName}
383
+ await txClient.query({
384
+ text: `UPDATE ${storageConfig.filesQualifiedName}
327
385
  SET status = 'ready'
328
- WHERE id = $1`, [fileId]);
386
+ WHERE id = $1`,
387
+ values: [fileId],
388
+ });
329
389
  // --- Update upload_request to 'confirmed' ---
330
- await pgClient.query(`UPDATE ${storageConfig.uploadRequestsQualifiedName}
390
+ await txClient.query({
391
+ text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
331
392
  SET status = 'confirmed', confirmed_at = NOW()
332
- WHERE file_id = $1 AND status = 'issued'`, [fileId]);
333
- await pgClient.query('COMMIT');
393
+ WHERE file_id = $1 AND status = 'issued'`,
394
+ values: [fileId],
395
+ });
334
396
  return {
335
397
  fileId: file.id,
336
398
  status: 'ready',
337
399
  success: true,
338
400
  };
339
- }
340
- catch (err) {
341
- await pgClient.query('ROLLBACK');
342
- throw err;
343
- }
401
+ });
344
402
  });
345
403
  });
346
404
  },
@@ -7,7 +7,10 @@ import type { StorageModuleConfig, BucketConfig } from './types';
7
7
  * @returns StorageModuleConfig or null if no storage module is provisioned
8
8
  */
9
9
  export declare function getStorageModuleConfig(pgClient: {
10
- query: (sql: string, params: unknown[]) => Promise<{
10
+ query: (opts: {
11
+ text: string;
12
+ values?: unknown[];
13
+ }) => Promise<{
11
14
  rows: unknown[];
12
15
  }>;
13
16
  }, databaseId: string): Promise<StorageModuleConfig | null>;
@@ -24,10 +27,21 @@ export declare function getStorageModuleConfig(pgClient: {
24
27
  * @returns BucketConfig or null if the bucket doesn't exist / isn't accessible
25
28
  */
26
29
  export declare function getBucketConfig(pgClient: {
27
- query: (sql: string, params: unknown[]) => Promise<{
30
+ query: (opts: {
31
+ text: string;
32
+ values?: unknown[];
33
+ }) => Promise<{
28
34
  rows: unknown[];
29
35
  }>;
30
36
  }, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string): Promise<BucketConfig | null>;
37
+ /**
38
+ * Check whether an S3 bucket has already been provisioned (cached).
39
+ */
40
+ export declare function isS3BucketProvisioned(s3BucketName: string): boolean;
41
+ /**
42
+ * Mark an S3 bucket as provisioned in the in-memory cache.
43
+ */
44
+ export declare function markS3BucketProvisioned(s3BucketName: string): void;
31
45
  /**
32
46
  * Clear the storage module cache AND bucket cache.
33
47
  * Useful for testing or schema changes.
@@ -41,6 +41,7 @@ const STORAGE_MODULE_QUERY = `
41
41
  sm.endpoint,
42
42
  sm.public_url_prefix,
43
43
  sm.provider,
44
+ sm.allowed_origins,
44
45
  sm.upload_url_expiry_seconds,
45
46
  sm.download_url_expiry_seconds,
46
47
  sm.default_max_file_size,
@@ -70,7 +71,7 @@ export async function getStorageModuleConfig(pgClient, databaseId) {
70
71
  return cached;
71
72
  }
72
73
  log.debug(`Cache miss for database ${databaseId}, querying metaschema...`);
73
- const result = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
74
+ const result = await pgClient.query({ text: STORAGE_MODULE_QUERY, values: [databaseId] });
74
75
  if (result.rows.length === 0) {
75
76
  log.warn(`No storage module found for database ${databaseId}`);
76
77
  return null;
@@ -89,6 +90,7 @@ export async function getStorageModuleConfig(pgClient, databaseId) {
89
90
  endpoint: row.endpoint,
90
91
  publicUrlPrefix: row.public_url_prefix,
91
92
  provider: row.provider,
93
+ allowedOrigins: row.allowed_origins,
92
94
  uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
93
95
  downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
94
96
  defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
@@ -138,10 +140,13 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
138
140
  return cached;
139
141
  }
140
142
  log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}, querying DB...`);
141
- const result = await pgClient.query(`SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size
143
+ const result = await pgClient.query({
144
+ text: `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size
142
145
  FROM ${storageConfig.bucketsQualifiedName}
143
146
  WHERE key = $1
144
- LIMIT 1`, [bucketKey]);
147
+ LIMIT 1`,
148
+ values: [bucketKey],
149
+ });
145
150
  if (result.rows.length === 0) {
146
151
  return null;
147
152
  }
@@ -159,6 +164,34 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
159
164
  log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
160
165
  return config;
161
166
  }
167
+ // --- S3 bucket existence cache ---
168
+ /**
169
+ * In-memory set of S3 bucket names that are known to exist.
170
+ *
171
+ * Used by the lazy provisioning logic in the presigned URL plugin:
172
+ * before generating a presigned PUT URL, the plugin checks this set.
173
+ * If the bucket name is absent, it calls `ensureBucketProvisioned`
174
+ * to create the S3 bucket, then adds the name here. Subsequent
175
+ * requests for the same bucket skip the provisioning entirely.
176
+ *
177
+ * No TTL needed — S3 buckets are never deleted during normal operation.
178
+ * The set resets on server restart, which is fine because the
179
+ * provisioner's createBucket is idempotent (handles "already exists").
180
+ */
181
+ const provisionedBuckets = new Set();
182
+ /**
183
+ * Check whether an S3 bucket has already been provisioned (cached).
184
+ */
185
+ export function isS3BucketProvisioned(s3BucketName) {
186
+ return provisionedBuckets.has(s3BucketName);
187
+ }
188
+ /**
189
+ * Mark an S3 bucket as provisioned in the in-memory cache.
190
+ */
191
+ export function markS3BucketProvisioned(s3BucketName) {
192
+ provisionedBuckets.add(s3BucketName);
193
+ log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
194
+ }
162
195
  /**
163
196
  * Clear the storage module cache AND bucket cache.
164
197
  * Useful for testing or schema changes.
@@ -166,6 +199,7 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
166
199
  export function clearStorageModuleCache() {
167
200
  storageModuleCache.clear();
168
201
  bucketCache.clear();
202
+ provisionedBuckets.clear();
169
203
  }
170
204
  /**
171
205
  * Clear cached bucket entries for a specific database.
package/esm/types.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface StorageModuleConfig {
37
37
  publicUrlPrefix: string | null;
38
38
  /** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
39
39
  provider: string | null;
40
+ /** CORS allowed origins (per-database override, NULL = use global fallback) */
41
+ allowedOrigins: string[] | null;
40
42
  /** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
41
43
  uploadUrlExpirySeconds: number;
42
44
  /** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
@@ -120,10 +122,50 @@ export interface S3Config {
120
122
  * env-var reads and S3Client creation at module import time.
121
123
  */
122
124
  export type S3ConfigOrGetter = S3Config | (() => S3Config);
125
+ /**
126
+ * Function to derive the actual S3 bucket name for a given database.
127
+ *
128
+ * When provided, the presigned URL plugin calls this on every request
129
+ * to determine which S3 bucket to use — enabling per-database bucket
130
+ * isolation. If not provided, falls back to `s3Config.bucket` (global).
131
+ *
132
+ * @param databaseId - The metaschema database UUID
133
+ * @returns The S3 bucket name for this database
134
+ */
135
+ export type BucketNameResolver = (databaseId: string) => string;
136
+ /**
137
+ * Callback to lazily provision an S3 bucket on first use.
138
+ *
139
+ * Called by the presigned URL plugin before generating a presigned PUT URL
140
+ * when the bucket has not been seen before (tracked in an in-memory cache).
141
+ * The implementation should create and fully configure the S3 bucket
142
+ * (privacy policies, CORS, lifecycle rules, etc.) — or no-op if the
143
+ * bucket already exists.
144
+ *
145
+ * @param bucketName - The S3 bucket name to provision
146
+ * @param accessType - The logical bucket type ('public', 'private', 'temp')
147
+ * @param databaseId - The metaschema database UUID
148
+ * @param allowedOrigins - Per-database CORS origins (from storage_module), or null to use global fallback
149
+ */
150
+ export type EnsureBucketProvisioned = (bucketName: string, accessType: 'public' | 'private' | 'temp', databaseId: string, allowedOrigins: string[] | null) => Promise<void>;
123
151
  /**
124
152
  * Plugin options for the presigned URL plugin.
125
153
  */
126
154
  export interface PresignedUrlPluginOptions {
127
155
  /** S3 configuration (concrete or lazy getter) */
128
156
  s3: S3ConfigOrGetter;
157
+ /**
158
+ * Optional function to resolve S3 bucket name per-database.
159
+ * When set, each database gets its own S3 bucket instead of sharing
160
+ * the global `s3Config.bucket`. The S3 credentials (client) remain shared.
161
+ */
162
+ resolveBucketName?: BucketNameResolver;
163
+ /**
164
+ * Optional callback to lazily provision an S3 bucket on first upload.
165
+ * When set, the plugin calls this before generating a presigned PUT URL
166
+ * for any S3 bucket it hasn't seen yet (tracked in an in-memory cache).
167
+ * This enables graceful bucket creation without requiring buckets to
168
+ * exist at database provisioning time.
169
+ */
170
+ ensureBucketProvisioned?: EnsureBucketProvisioned;
129
171
  }
package/index.d.ts CHANGED
@@ -29,6 +29,6 @@
29
29
  export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
30
30
  export { createDownloadUrlPlugin } from './download-url-field';
31
31
  export { PresignedUrlPreset } from './preset';
32
- export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
32
+ export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
33
  export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
34
- export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, } from './types';
34
+ export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';
package/index.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * ```
29
29
  */
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.headObject = exports.generatePresignedGetUrl = exports.generatePresignedPutUrl = exports.clearBucketCache = exports.clearStorageModuleCache = exports.getBucketConfig = exports.getStorageModuleConfig = exports.PresignedUrlPreset = exports.createDownloadUrlPlugin = exports.createPresignedUrlPlugin = exports.PresignedUrlPlugin = void 0;
31
+ exports.headObject = exports.generatePresignedGetUrl = exports.generatePresignedPutUrl = exports.markS3BucketProvisioned = exports.isS3BucketProvisioned = exports.clearBucketCache = exports.clearStorageModuleCache = exports.getBucketConfig = exports.getStorageModuleConfig = exports.PresignedUrlPreset = exports.createDownloadUrlPlugin = exports.createPresignedUrlPlugin = exports.PresignedUrlPlugin = void 0;
32
32
  var plugin_1 = require("./plugin");
33
33
  Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
34
34
  Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
@@ -41,6 +41,8 @@ Object.defineProperty(exports, "getStorageModuleConfig", { enumerable: true, get
41
41
  Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
42
42
  Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
43
43
  Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
44
+ Object.defineProperty(exports, "isS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.isS3BucketProvisioned; } });
45
+ Object.defineProperty(exports, "markS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.markS3BucketProvisioned; } });
44
46
  var s3_signer_1 = require("./s3-signer");
45
47
  Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });
46
48
  Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-presigned-url-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl, confirmUpload mutations and downloadUrl computed field",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -59,5 +59,5 @@
59
59
  "@types/node": "^22.19.11",
60
60
  "makage": "^0.1.10"
61
61
  },
62
- "gitHead": "fe60f7b81252eea53dce227bb581d5ae2ef0ec36"
62
+ "gitHead": "79cd3e66871804a22c672c7ca2fa5e2105d4b368"
63
63
  }
package/plugin.js CHANGED
@@ -52,7 +52,9 @@ function buildS3Key(contentHash) {
52
52
  * metaschema query needed.
53
53
  */
54
54
  async function resolveDatabaseId(pgClient) {
55
- const result = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
55
+ const result = await pgClient.query({
56
+ text: `SELECT jwt_private.current_database_id() AS id`,
57
+ });
56
58
  return result.rows[0]?.id ?? null;
57
59
  }
58
60
  // --- Plugin factory ---
@@ -70,6 +72,49 @@ function resolveS3(options) {
70
72
  }
71
73
  return options.s3;
72
74
  }
75
+ /**
76
+ * Build a per-database S3Config by overlaying storage_module overrides
77
+ * onto the global S3Config.
78
+ *
79
+ * - Bucket name: from resolveBucketName(databaseId) if provided, else global
80
+ * - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global
81
+ * - S3 client (credentials, endpoint): always global (shared IAM key)
82
+ */
83
+ function resolveS3ForDatabase(options, storageConfig, databaseId) {
84
+ const globalS3 = resolveS3(options);
85
+ const bucket = options.resolveBucketName
86
+ ? options.resolveBucketName(databaseId)
87
+ : globalS3.bucket;
88
+ const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
89
+ if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
90
+ return globalS3;
91
+ }
92
+ return {
93
+ ...globalS3,
94
+ bucket,
95
+ ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
96
+ };
97
+ }
98
+ /**
99
+ * Ensure the S3 bucket for a database exists, provisioning it lazily if needed.
100
+ *
101
+ * Checks an in-memory Set of known-provisioned bucket names. On the first
102
+ * request for an unseen bucket, calls the `ensureBucketProvisioned` callback
103
+ * (which creates the bucket with correct CORS, policies, etc.), then marks
104
+ * it as provisioned so subsequent requests skip the check entirely.
105
+ *
106
+ * If no `ensureBucketProvisioned` callback is configured, this is a no-op.
107
+ */
108
+ async function ensureS3BucketExists(options, s3BucketName, bucket, databaseId, allowedOrigins) {
109
+ if (!options.ensureBucketProvisioned)
110
+ return;
111
+ if ((0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName))
112
+ return;
113
+ log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
114
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
115
+ (0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
116
+ log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
117
+ }
73
118
  function createPresignedUrlPlugin(options) {
74
119
  return (0, graphile_utils_1.extendSchema)(() => ({
75
120
  typeDefs: (0, graphile_utils_1.gql) `
@@ -161,14 +206,13 @@ function createPresignedUrlPlugin(options) {
161
206
  throw new Error('INVALID_CONTENT_TYPE');
162
207
  }
163
208
  return withPgClient(pgSettings, async (pgClient) => {
164
- await pgClient.query('BEGIN');
165
- try {
209
+ return pgClient.withTransaction(async (txClient) => {
166
210
  // --- Resolve storage module config (all limits come from here) ---
167
- const databaseId = await resolveDatabaseId(pgClient);
211
+ const databaseId = await resolveDatabaseId(txClient);
168
212
  if (!databaseId) {
169
213
  throw new Error('DATABASE_NOT_FOUND');
170
214
  }
171
- const storageConfig = await (0, storage_module_cache_1.getStorageModuleConfig)(pgClient, databaseId);
215
+ const storageConfig = await (0, storage_module_cache_1.getStorageModuleConfig)(txClient, databaseId);
172
216
  if (!storageConfig) {
173
217
  throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
174
218
  }
@@ -182,7 +226,7 @@ function createPresignedUrlPlugin(options) {
182
226
  }
183
227
  }
184
228
  // --- Look up the bucket (cached; first miss queries via RLS) ---
185
- const bucket = await (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, bucketKey);
229
+ const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, bucketKey);
186
230
  if (!bucket) {
187
231
  throw new Error('BUCKET_NOT_FOUND');
188
232
  }
@@ -208,20 +252,25 @@ function createPresignedUrlPlugin(options) {
208
252
  }
209
253
  const s3Key = buildS3Key(contentHash);
210
254
  // --- Dedup check: look for existing file with same content_hash in this bucket ---
211
- const dedupResult = await pgClient.query(`SELECT id, status
255
+ const dedupResult = await txClient.query({
256
+ text: `SELECT id, status
212
257
  FROM ${storageConfig.filesQualifiedName}
213
258
  WHERE content_hash = $1
214
259
  AND bucket_id = $2
215
260
  AND status IN ('ready', 'processed')
216
- LIMIT 1`, [contentHash, bucket.id]);
261
+ LIMIT 1`,
262
+ values: [contentHash, bucket.id],
263
+ });
217
264
  if (dedupResult.rows.length > 0) {
218
265
  const existingFile = dedupResult.rows[0];
219
266
  log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
220
267
  // Track the dedup request
221
- await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
268
+ await txClient.query({
269
+ text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
222
270
  (file_id, bucket_id, key, content_type, content_hash, size, status, expires_at)
223
- VALUES ($1, $2, $3, $4, $5, $6, 'confirmed', NOW())`, [existingFile.id, bucket.id, s3Key, contentType, contentHash, size]);
224
- await pgClient.query('COMMIT');
271
+ VALUES ($1, $2, $3, $4, $5, $6, 'confirmed', NOW())`,
272
+ values: [existingFile.id, bucket.id, s3Key, contentType, contentHash, size],
273
+ });
225
274
  return {
226
275
  uploadUrl: null,
227
276
  fileId: existingFile.id,
@@ -231,28 +280,36 @@ function createPresignedUrlPlugin(options) {
231
280
  };
232
281
  }
233
282
  // --- Create file record (status=pending) ---
234
- const fileResult = await pgClient.query(`INSERT INTO ${storageConfig.filesQualifiedName}
283
+ const fileResult = await txClient.query({
284
+ text: `INSERT INTO ${storageConfig.filesQualifiedName}
235
285
  (bucket_id, key, content_type, content_hash, size, filename, owner_id, is_public, status)
236
286
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
237
- RETURNING id`, [
238
- bucket.id,
239
- s3Key,
240
- contentType,
241
- contentHash,
242
- size,
243
- filename || null,
244
- bucket.owner_id,
245
- bucket.is_public,
246
- ]);
287
+ RETURNING id`,
288
+ values: [
289
+ bucket.id,
290
+ s3Key,
291
+ contentType,
292
+ contentHash,
293
+ size,
294
+ filename || null,
295
+ bucket.owner_id,
296
+ bucket.is_public,
297
+ ],
298
+ });
247
299
  const fileId = fileResult.rows[0].id;
248
- // --- Generate presigned PUT URL ---
249
- const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(resolveS3(options), s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
300
+ // --- Ensure the S3 bucket exists (lazy provisioning) ---
301
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
302
+ await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
303
+ // --- Generate presigned PUT URL (per-database bucket) ---
304
+ const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
250
305
  const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
251
306
  // --- Track the upload request ---
252
- await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
307
+ await txClient.query({
308
+ text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
253
309
  (file_id, bucket_id, key, content_type, content_hash, size, status, expires_at)
254
- VALUES ($1, $2, $3, $4, $5, $6, 'issued', $7)`, [fileId, bucket.id, s3Key, contentType, contentHash, size, expiresAt]);
255
- await pgClient.query('COMMIT');
310
+ VALUES ($1, $2, $3, $4, $5, $6, 'issued', $7)`,
311
+ values: [fileId, bucket.id, s3Key, contentType, contentHash, size, expiresAt],
312
+ });
256
313
  return {
257
314
  uploadUrl,
258
315
  fileId,
@@ -260,11 +317,7 @@ function createPresignedUrlPlugin(options) {
260
317
  deduplicated: false,
261
318
  expiresAt,
262
319
  };
263
- }
264
- catch (err) {
265
- await pgClient.query('ROLLBACK');
266
- throw err;
267
- }
320
+ });
268
321
  });
269
322
  });
270
323
  },
@@ -283,68 +336,73 @@ function createPresignedUrlPlugin(options) {
283
336
  throw new Error('INVALID_FILE_ID');
284
337
  }
285
338
  return withPgClient(pgSettings, async (pgClient) => {
286
- await pgClient.query('BEGIN');
287
- try {
339
+ return pgClient.withTransaction(async (txClient) => {
288
340
  // --- Resolve storage module config ---
289
- const databaseId = await resolveDatabaseId(pgClient);
341
+ const databaseId = await resolveDatabaseId(txClient);
290
342
  if (!databaseId) {
291
343
  throw new Error('DATABASE_NOT_FOUND');
292
344
  }
293
- const storageConfig = await (0, storage_module_cache_1.getStorageModuleConfig)(pgClient, databaseId);
345
+ const storageConfig = await (0, storage_module_cache_1.getStorageModuleConfig)(txClient, databaseId);
294
346
  if (!storageConfig) {
295
347
  throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
296
348
  }
297
349
  // --- Look up the file (RLS enforced) ---
298
- const fileResult = await pgClient.query(`SELECT id, key, content_type, status, bucket_id
350
+ const fileResult = await txClient.query({
351
+ text: `SELECT id, key, content_type, status, bucket_id
299
352
  FROM ${storageConfig.filesQualifiedName}
300
353
  WHERE id = $1
301
- LIMIT 1`, [fileId]);
354
+ LIMIT 1`,
355
+ values: [fileId],
356
+ });
302
357
  if (fileResult.rows.length === 0) {
303
358
  throw new Error('FILE_NOT_FOUND');
304
359
  }
305
360
  const file = fileResult.rows[0];
306
361
  if (file.status !== 'pending') {
307
362
  // File is already confirmed or processed — idempotent success
308
- await pgClient.query('COMMIT');
309
363
  return {
310
364
  fileId: file.id,
311
365
  status: file.status,
312
366
  success: true,
313
367
  };
314
368
  }
315
- // --- Verify file exists in S3 ---
316
- const s3Head = await (0, s3_signer_1.headObject)(resolveS3(options), file.key, file.content_type);
369
+ // --- Verify file exists in S3 (per-database bucket) ---
370
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
371
+ const s3Head = await (0, s3_signer_1.headObject)(s3ForDb, file.key, file.content_type);
317
372
  if (!s3Head) {
318
373
  throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
319
374
  }
320
375
  // --- Content-type verification ---
321
376
  if (s3Head.contentType && s3Head.contentType !== file.content_type) {
322
377
  // Mark upload_request as rejected
323
- await pgClient.query(`UPDATE ${storageConfig.uploadRequestsQualifiedName}
378
+ await txClient.query({
379
+ text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
324
380
  SET status = 'rejected'
325
- WHERE file_id = $1 AND status = 'issued'`, [fileId]);
326
- await pgClient.query('COMMIT');
381
+ WHERE file_id = $1 AND status = 'issued'`,
382
+ values: [fileId],
383
+ });
327
384
  throw new Error(`CONTENT_TYPE_MISMATCH: expected ${file.content_type}, got ${s3Head.contentType}`);
328
385
  }
329
386
  // --- Transition file to 'ready' ---
330
- await pgClient.query(`UPDATE ${storageConfig.filesQualifiedName}
387
+ await txClient.query({
388
+ text: `UPDATE ${storageConfig.filesQualifiedName}
331
389
  SET status = 'ready'
332
- WHERE id = $1`, [fileId]);
390
+ WHERE id = $1`,
391
+ values: [fileId],
392
+ });
333
393
  // --- Update upload_request to 'confirmed' ---
334
- await pgClient.query(`UPDATE ${storageConfig.uploadRequestsQualifiedName}
394
+ await txClient.query({
395
+ text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
335
396
  SET status = 'confirmed', confirmed_at = NOW()
336
- WHERE file_id = $1 AND status = 'issued'`, [fileId]);
337
- await pgClient.query('COMMIT');
397
+ WHERE file_id = $1 AND status = 'issued'`,
398
+ values: [fileId],
399
+ });
338
400
  return {
339
401
  fileId: file.id,
340
402
  status: 'ready',
341
403
  success: true,
342
404
  };
343
- }
344
- catch (err) {
345
- await pgClient.query('ROLLBACK');
346
- throw err;
347
- }
405
+ });
348
406
  });
349
407
  });
350
408
  },
@@ -7,7 +7,10 @@ import type { StorageModuleConfig, BucketConfig } from './types';
7
7
  * @returns StorageModuleConfig or null if no storage module is provisioned
8
8
  */
9
9
  export declare function getStorageModuleConfig(pgClient: {
10
- query: (sql: string, params: unknown[]) => Promise<{
10
+ query: (opts: {
11
+ text: string;
12
+ values?: unknown[];
13
+ }) => Promise<{
11
14
  rows: unknown[];
12
15
  }>;
13
16
  }, databaseId: string): Promise<StorageModuleConfig | null>;
@@ -24,10 +27,21 @@ export declare function getStorageModuleConfig(pgClient: {
24
27
  * @returns BucketConfig or null if the bucket doesn't exist / isn't accessible
25
28
  */
26
29
  export declare function getBucketConfig(pgClient: {
27
- query: (sql: string, params: unknown[]) => Promise<{
30
+ query: (opts: {
31
+ text: string;
32
+ values?: unknown[];
33
+ }) => Promise<{
28
34
  rows: unknown[];
29
35
  }>;
30
36
  }, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string): Promise<BucketConfig | null>;
37
+ /**
38
+ * Check whether an S3 bucket has already been provisioned (cached).
39
+ */
40
+ export declare function isS3BucketProvisioned(s3BucketName: string): boolean;
41
+ /**
42
+ * Mark an S3 bucket as provisioned in the in-memory cache.
43
+ */
44
+ export declare function markS3BucketProvisioned(s3BucketName: string): void;
31
45
  /**
32
46
  * Clear the storage module cache AND bucket cache.
33
47
  * Useful for testing or schema changes.
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getStorageModuleConfig = getStorageModuleConfig;
4
4
  exports.getBucketConfig = getBucketConfig;
5
+ exports.isS3BucketProvisioned = isS3BucketProvisioned;
6
+ exports.markS3BucketProvisioned = markS3BucketProvisioned;
5
7
  exports.clearStorageModuleCache = clearStorageModuleCache;
6
8
  exports.clearBucketCache = clearBucketCache;
7
9
  const logger_1 = require("@pgpmjs/logger");
@@ -47,6 +49,7 @@ const STORAGE_MODULE_QUERY = `
47
49
  sm.endpoint,
48
50
  sm.public_url_prefix,
49
51
  sm.provider,
52
+ sm.allowed_origins,
50
53
  sm.upload_url_expiry_seconds,
51
54
  sm.download_url_expiry_seconds,
52
55
  sm.default_max_file_size,
@@ -76,7 +79,7 @@ async function getStorageModuleConfig(pgClient, databaseId) {
76
79
  return cached;
77
80
  }
78
81
  log.debug(`Cache miss for database ${databaseId}, querying metaschema...`);
79
- const result = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
82
+ const result = await pgClient.query({ text: STORAGE_MODULE_QUERY, values: [databaseId] });
80
83
  if (result.rows.length === 0) {
81
84
  log.warn(`No storage module found for database ${databaseId}`);
82
85
  return null;
@@ -95,6 +98,7 @@ async function getStorageModuleConfig(pgClient, databaseId) {
95
98
  endpoint: row.endpoint,
96
99
  publicUrlPrefix: row.public_url_prefix,
97
100
  provider: row.provider,
101
+ allowedOrigins: row.allowed_origins,
98
102
  uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
99
103
  downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
100
104
  defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
@@ -144,10 +148,13 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
144
148
  return cached;
145
149
  }
146
150
  log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}, querying DB...`);
147
- const result = await pgClient.query(`SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size
151
+ const result = await pgClient.query({
152
+ text: `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size
148
153
  FROM ${storageConfig.bucketsQualifiedName}
149
154
  WHERE key = $1
150
- LIMIT 1`, [bucketKey]);
155
+ LIMIT 1`,
156
+ values: [bucketKey],
157
+ });
151
158
  if (result.rows.length === 0) {
152
159
  return null;
153
160
  }
@@ -165,6 +172,34 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
165
172
  log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
166
173
  return config;
167
174
  }
175
+ // --- S3 bucket existence cache ---
176
+ /**
177
+ * In-memory set of S3 bucket names that are known to exist.
178
+ *
179
+ * Used by the lazy provisioning logic in the presigned URL plugin:
180
+ * before generating a presigned PUT URL, the plugin checks this set.
181
+ * If the bucket name is absent, it calls `ensureBucketProvisioned`
182
+ * to create the S3 bucket, then adds the name here. Subsequent
183
+ * requests for the same bucket skip the provisioning entirely.
184
+ *
185
+ * No TTL needed — S3 buckets are never deleted during normal operation.
186
+ * The set resets on server restart, which is fine because the
187
+ * provisioner's createBucket is idempotent (handles "already exists").
188
+ */
189
+ const provisionedBuckets = new Set();
190
+ /**
191
+ * Check whether an S3 bucket has already been provisioned (cached).
192
+ */
193
+ function isS3BucketProvisioned(s3BucketName) {
194
+ return provisionedBuckets.has(s3BucketName);
195
+ }
196
+ /**
197
+ * Mark an S3 bucket as provisioned in the in-memory cache.
198
+ */
199
+ function markS3BucketProvisioned(s3BucketName) {
200
+ provisionedBuckets.add(s3BucketName);
201
+ log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
202
+ }
168
203
  /**
169
204
  * Clear the storage module cache AND bucket cache.
170
205
  * Useful for testing or schema changes.
@@ -172,6 +207,7 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
172
207
  function clearStorageModuleCache() {
173
208
  storageModuleCache.clear();
174
209
  bucketCache.clear();
210
+ provisionedBuckets.clear();
175
211
  }
176
212
  /**
177
213
  * Clear cached bucket entries for a specific database.
package/types.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface StorageModuleConfig {
37
37
  publicUrlPrefix: string | null;
38
38
  /** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
39
39
  provider: string | null;
40
+ /** CORS allowed origins (per-database override, NULL = use global fallback) */
41
+ allowedOrigins: string[] | null;
40
42
  /** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
41
43
  uploadUrlExpirySeconds: number;
42
44
  /** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
@@ -120,10 +122,50 @@ export interface S3Config {
120
122
  * env-var reads and S3Client creation at module import time.
121
123
  */
122
124
  export type S3ConfigOrGetter = S3Config | (() => S3Config);
125
+ /**
126
+ * Function to derive the actual S3 bucket name for a given database.
127
+ *
128
+ * When provided, the presigned URL plugin calls this on every request
129
+ * to determine which S3 bucket to use — enabling per-database bucket
130
+ * isolation. If not provided, falls back to `s3Config.bucket` (global).
131
+ *
132
+ * @param databaseId - The metaschema database UUID
133
+ * @returns The S3 bucket name for this database
134
+ */
135
+ export type BucketNameResolver = (databaseId: string) => string;
136
+ /**
137
+ * Callback to lazily provision an S3 bucket on first use.
138
+ *
139
+ * Called by the presigned URL plugin before generating a presigned PUT URL
140
+ * when the bucket has not been seen before (tracked in an in-memory cache).
141
+ * The implementation should create and fully configure the S3 bucket
142
+ * (privacy policies, CORS, lifecycle rules, etc.) — or no-op if the
143
+ * bucket already exists.
144
+ *
145
+ * @param bucketName - The S3 bucket name to provision
146
+ * @param accessType - The logical bucket type ('public', 'private', 'temp')
147
+ * @param databaseId - The metaschema database UUID
148
+ * @param allowedOrigins - Per-database CORS origins (from storage_module), or null to use global fallback
149
+ */
150
+ export type EnsureBucketProvisioned = (bucketName: string, accessType: 'public' | 'private' | 'temp', databaseId: string, allowedOrigins: string[] | null) => Promise<void>;
123
151
  /**
124
152
  * Plugin options for the presigned URL plugin.
125
153
  */
126
154
  export interface PresignedUrlPluginOptions {
127
155
  /** S3 configuration (concrete or lazy getter) */
128
156
  s3: S3ConfigOrGetter;
157
+ /**
158
+ * Optional function to resolve S3 bucket name per-database.
159
+ * When set, each database gets its own S3 bucket instead of sharing
160
+ * the global `s3Config.bucket`. The S3 credentials (client) remain shared.
161
+ */
162
+ resolveBucketName?: BucketNameResolver;
163
+ /**
164
+ * Optional callback to lazily provision an S3 bucket on first upload.
165
+ * When set, the plugin calls this before generating a presigned PUT URL
166
+ * for any S3 bucket it hasn't seen yet (tracked in an in-memory cache).
167
+ * This enables graceful bucket creation without requiring buckets to
168
+ * exist at database provisioning time.
169
+ */
170
+ ensureBucketProvisioned?: EnsureBucketProvisioned;
129
171
  }