Skip to content

Commit a03ee0f

Browse files
committed
feat: lazy S3 bucket provisioning on first upload
When a presigned PUT URL is requested for a database whose S3 bucket doesn't exist yet, the plugin now automatically provisions it with the correct privacy policies, CORS rules, and lifecycle settings. Changes: - Add EnsureBucketProvisioned callback type to plugin options - Add in-memory Set cache (provisionedBuckets) in storage-module-cache to track which S3 buckets are known to exist — no TTL needed since buckets are never deleted; resets on server restart (idempotent) - Wire ensureS3BucketExists into requestUploadUrl before generating the presigned PUT URL — first request provisions, subsequent skip - Add createEnsureBucketProvisioned factory in presigned-url-resolver that uses BucketProvisioner for full bucket setup - Wire into ConstructivePreset with same allowedOrigins as bucket provisioner plugin
1 parent e6ec421 commit a03ee0f

8 files changed

Lines changed: 2728 additions & 7432 deletions

File tree

graphile/graphile-presigned-url-plugin/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
3131
export { createDownloadUrlPlugin } from './download-url-field';
3232
export { PresignedUrlPreset } from './preset';
33-
export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
33+
export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
3434
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
3535
export type {
3636
BucketConfig,
@@ -43,4 +43,5 @@ export type {
4343
S3ConfigOrGetter,
4444
PresignedUrlPluginOptions,
4545
BucketNameResolver,
46+
EnsureBucketProvisioned,
4647
} from './types';

graphile/graphile-presigned-url-plugin/src/plugin.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import type { GraphileConfig } from 'graphile-config';
2222
import { extendSchema, gql } from 'graphile-utils';
2323
import { Logger } from '@pgpmjs/logger';
2424

25-
import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
26-
import { getStorageModuleConfig, getBucketConfig } from './storage-module-cache';
25+
import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig, BucketConfig } from './types';
26+
import { getStorageModuleConfig, getBucketConfig, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
2727
import { generatePresignedPutUrl, headObject } from './s3-signer';
2828

2929
const log = new Logger('graphile-presigned-url:plugin');
@@ -112,6 +112,31 @@ function resolveS3ForDatabase(
112112
};
113113
}
114114

115+
/**
116+
* Ensure the S3 bucket for a database exists, provisioning it lazily if needed.
117+
*
118+
* Checks an in-memory Set of known-provisioned bucket names. On the first
119+
* request for an unseen bucket, calls the `ensureBucketProvisioned` callback
120+
* (which creates the bucket with correct CORS, policies, etc.), then marks
121+
* it as provisioned so subsequent requests skip the check entirely.
122+
*
123+
* If no `ensureBucketProvisioned` callback is configured, this is a no-op.
124+
*/
125+
async function ensureS3BucketExists(
126+
options: PresignedUrlPluginOptions,
127+
s3BucketName: string,
128+
bucket: BucketConfig,
129+
databaseId: string,
130+
): Promise<void> {
131+
if (!options.ensureBucketProvisioned) return;
132+
if (isS3BucketProvisioned(s3BucketName)) return;
133+
134+
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
135+
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId);
136+
markS3BucketProvisioned(s3BucketName);
137+
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
138+
}
139+
115140
export function createPresignedUrlPlugin(
116141
options: PresignedUrlPluginOptions,
117142
): GraphileConfig.Plugin {
@@ -314,8 +339,11 @@ export function createPresignedUrlPlugin(
314339

315340
const fileId = fileResult.rows[0].id;
316341

317-
// --- Generate presigned PUT URL (per-database bucket) ---
342+
// --- Ensure the S3 bucket exists (lazy provisioning) ---
318343
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
344+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId);
345+
346+
// --- Generate presigned PUT URL (per-database bucket) ---
319347
const uploadUrl = await generatePresignedPutUrl(
320348
s3ForDb,
321349
s3Key,

graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,46 @@ export async function getBucketConfig(
220220
return config;
221221
}
222222

223+
// --- S3 bucket existence cache ---
224+
225+
/**
226+
* In-memory set of S3 bucket names that are known to exist.
227+
*
228+
* Used by the lazy provisioning logic in the presigned URL plugin:
229+
* before generating a presigned PUT URL, the plugin checks this set.
230+
* If the bucket name is absent, it calls `ensureBucketProvisioned`
231+
* to create the S3 bucket, then adds the name here. Subsequent
232+
* requests for the same bucket skip the provisioning entirely.
233+
*
234+
* No TTL needed — S3 buckets are never deleted during normal operation.
235+
* The set resets on server restart, which is fine because the
236+
* provisioner's createBucket is idempotent (handles "already exists").
237+
*/
238+
const provisionedBuckets = new Set<string>();
239+
240+
/**
241+
* Check whether an S3 bucket has already been provisioned (cached).
242+
*/
243+
export function isS3BucketProvisioned(s3BucketName: string): boolean {
244+
return provisionedBuckets.has(s3BucketName);
245+
}
246+
247+
/**
248+
* Mark an S3 bucket as provisioned in the in-memory cache.
249+
*/
250+
export function markS3BucketProvisioned(s3BucketName: string): void {
251+
provisionedBuckets.add(s3BucketName);
252+
log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
253+
}
254+
223255
/**
224256
* Clear the storage module cache AND bucket cache.
225257
* Useful for testing or schema changes.
226258
*/
227259
export function clearStorageModuleCache(): void {
228260
storageModuleCache.clear();
229261
bucketCache.clear();
262+
provisionedBuckets.clear();
230263
}
231264

232265
/**

graphile/graphile-presigned-url-plugin/src/types.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,25 @@ export type S3ConfigOrGetter = S3Config | (() => S3Config);
147147
*/
148148
export type BucketNameResolver = (databaseId: string) => string;
149149

150+
/**
151+
* Callback to lazily provision an S3 bucket on first use.
152+
*
153+
* Called by the presigned URL plugin before generating a presigned PUT URL
154+
* when the bucket has not been seen before (tracked in an in-memory cache).
155+
* The implementation should create and fully configure the S3 bucket
156+
* (privacy policies, CORS, lifecycle rules, etc.) — or no-op if the
157+
* bucket already exists.
158+
*
159+
* @param bucketName - The S3 bucket name to provision
160+
* @param accessType - The logical bucket type ('public', 'private', 'temp')
161+
* @param databaseId - The metaschema database UUID
162+
*/
163+
export type EnsureBucketProvisioned = (
164+
bucketName: string,
165+
accessType: 'public' | 'private' | 'temp',
166+
databaseId: string,
167+
) => Promise<void>;
168+
150169
/**
151170
* Plugin options for the presigned URL plugin.
152171
*/
@@ -160,4 +179,13 @@ export interface PresignedUrlPluginOptions {
160179
* the global `s3Config.bucket`. The S3 credentials (client) remain shared.
161180
*/
162181
resolveBucketName?: BucketNameResolver;
182+
183+
/**
184+
* Optional callback to lazily provision an S3 bucket on first upload.
185+
* When set, the plugin calls this before generating a presigned PUT URL
186+
* for any S3 bucket it hasn't seen yet (tracked in an in-memory cache).
187+
* This enables graceful bucket creation without requiring buckets to
188+
* exist at database provisioning time.
189+
*/
190+
ensureBucketProvisioned?: EnsureBucketProvisioned;
163191
}

graphile/graphile-settings/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
},
3131
"dependencies": {
3232
"@aws-sdk/client-s3": "^3.1009.0",
33+
"@constructive-io/bucket-provisioner": "workspace:^",
3334
"@constructive-io/graphql-env": "workspace:^",
3435
"@constructive-io/graphql-types": "workspace:^",
3536
"@constructive-io/s3-streamer": "workspace:^",

graphile/graphile-settings/src/presets/constructive-preset.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { PresignedUrlPreset } from 'graphile-presigned-url-plugin';
1919
import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin';
2020
import { SqlExpressionValidatorPreset } from 'graphile-sql-expression-validator';
2121
import { constructiveUploadFieldDefinitions } from '../upload-resolver';
22-
import { getPresignedUrlS3Config, createBucketNameResolver } from '../presigned-url-resolver';
22+
import { getPresignedUrlS3Config, createBucketNameResolver, createEnsureBucketProvisioned } from '../presigned-url-resolver';
2323
import { getBucketProvisionerConnection } from '../bucket-provisioner-resolver';
2424

2525
/**
@@ -90,7 +90,11 @@ export const ConstructivePreset: GraphileConfig.Preset = {
9090
uploadFieldDefinitions: constructiveUploadFieldDefinitions,
9191
maxFileSize: 10 * 1024 * 1024, // 10MB
9292
}),
93-
PresignedUrlPreset({ s3: getPresignedUrlS3Config, resolveBucketName: createBucketNameResolver() }),
93+
PresignedUrlPreset({
94+
s3: getPresignedUrlS3Config,
95+
resolveBucketName: createBucketNameResolver(),
96+
ensureBucketProvisioned: createEnsureBucketProvisioned(['http://localhost:3000']),
97+
}),
9498
BucketProvisionerPreset({
9599
connection: getBucketProvisionerConnection,
96100
allowedOrigins: ['http://localhost:3000'],

graphile/graphile-settings/src/presigned-url-resolver.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
import { S3Client } from '@aws-sdk/client-s3';
1515
import { getEnvOptions } from '@constructive-io/graphql-env';
1616
import { Logger } from '@pgpmjs/logger';
17-
import type { S3Config, BucketNameResolver } from 'graphile-presigned-url-plugin';
17+
import type { S3Config, BucketNameResolver, EnsureBucketProvisioned } from 'graphile-presigned-url-plugin';
18+
import { BucketProvisioner } from '@constructive-io/bucket-provisioner';
19+
import { getBucketProvisionerConnection } from './bucket-provisioner-resolver';
1820

1921
const log = new Logger('presigned-url-resolver');
2022

@@ -81,3 +83,47 @@ export function createBucketNameResolver(): BucketNameResolver {
8183
return `${prefix}-${databaseId}`;
8284
};
8385
}
86+
87+
/**
88+
* Create a lazy bucket provisioner callback for the presigned URL plugin.
89+
*
90+
* On the first upload to an S3 bucket that doesn't exist yet, this callback
91+
* uses the BucketProvisioner to create and fully configure the bucket
92+
* (Block Public Access, CORS, policies, lifecycle rules for temp buckets).
93+
*
94+
* Uses the same S3 connection config as the bucket provisioner plugin
95+
* (getBucketProvisionerConnection) and the same CORS origins.
96+
*
97+
* @param allowedOrigins - CORS origins for presigned URL uploads
98+
*/
99+
export function createEnsureBucketProvisioned(
100+
allowedOrigins: string[],
101+
): EnsureBucketProvisioned {
102+
let provisioner: BucketProvisioner | null = null;
103+
104+
return async (
105+
bucketName: string,
106+
accessType: 'public' | 'private' | 'temp',
107+
databaseId: string,
108+
): Promise<void> => {
109+
if (!provisioner) {
110+
provisioner = new BucketProvisioner({
111+
connection: getBucketProvisionerConnection(),
112+
allowedOrigins,
113+
});
114+
}
115+
116+
log.info(
117+
`[lazy-provision] Provisioning S3 bucket "${bucketName}" ` +
118+
`(type=${accessType}) for database ${databaseId}`,
119+
);
120+
121+
await provisioner.provision({
122+
bucketName,
123+
accessType,
124+
versioning: false,
125+
});
126+
127+
log.info(`[lazy-provision] S3 bucket "${bucketName}" provisioned successfully`);
128+
};
129+
}

0 commit comments

Comments
 (0)