Skip to content

Commit a500e3b

Browse files
authored
Merge pull request #969 from constructive-io/feat/lazy-s3-bucket-provisioning
feat: lazy S3 bucket provisioning on first upload
2 parents e6ec421 + 784ada1 commit a500e3b

8 files changed

Lines changed: 2754 additions & 7433 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: 32 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,32 @@ 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+
allowedOrigins: string[] | null,
131+
): Promise<void> {
132+
if (!options.ensureBucketProvisioned) return;
133+
if (isS3BucketProvisioned(s3BucketName)) return;
134+
135+
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
136+
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
137+
markS3BucketProvisioned(s3BucketName);
138+
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
139+
}
140+
115141
export function createPresignedUrlPlugin(
116142
options: PresignedUrlPluginOptions,
117143
): GraphileConfig.Plugin {
@@ -314,8 +340,11 @@ export function createPresignedUrlPlugin(
314340

315341
const fileId = fileResult.rows[0].id;
316342

317-
// --- Generate presigned PUT URL (per-database bucket) ---
343+
// --- Ensure the S3 bucket exists (lazy provisioning) ---
318344
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
345+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
346+
347+
// --- Generate presigned PUT URL (per-database bucket) ---
319348
const uploadUrl = await generatePresignedPutUrl(
320349
s3ForDb,
321350
s3Key,

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const STORAGE_MODULE_QUERY = `
4747
sm.endpoint,
4848
sm.public_url_prefix,
4949
sm.provider,
50+
sm.allowed_origins,
5051
sm.upload_url_expiry_seconds,
5152
sm.download_url_expiry_seconds,
5253
sm.default_max_file_size,
@@ -74,6 +75,7 @@ interface StorageModuleRow {
7475
endpoint: string | null;
7576
public_url_prefix: string | null;
7677
provider: string | null;
78+
allowed_origins: string[] | null;
7779
upload_url_expiry_seconds: number | null;
7880
download_url_expiry_seconds: number | null;
7981
default_max_file_size: number | null;
@@ -121,6 +123,7 @@ export async function getStorageModuleConfig(
121123
endpoint: row.endpoint,
122124
publicUrlPrefix: row.public_url_prefix,
123125
provider: row.provider,
126+
allowedOrigins: row.allowed_origins,
124127
uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
125128
downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
126129
defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
@@ -220,13 +223,46 @@ export async function getBucketConfig(
220223
return config;
221224
}
222225

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

232268
/**

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export interface StorageModuleConfig {
4242
publicUrlPrefix: string | null;
4343
/** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
4444
provider: string | null;
45+
/** CORS allowed origins (per-database override, NULL = use global fallback) */
46+
allowedOrigins: string[] | null;
4547

4648
// --- Per-database configurable settings ---
4749

@@ -147,6 +149,27 @@ export type S3ConfigOrGetter = S3Config | (() => S3Config);
147149
*/
148150
export type BucketNameResolver = (databaseId: string) => string;
149151

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

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: 7 additions & 3 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, getAllowedOrigins } from '../presigned-url-resolver';
2323
import { getBucketProvisionerConnection } from '../bucket-provisioner-resolver';
2424

2525
/**
@@ -90,10 +90,14 @@ 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(),
97+
}),
9498
BucketProvisionerPreset({
9599
connection: getBucketProvisionerConnection,
96-
allowedOrigins: ['http://localhost:3000'],
100+
allowedOrigins: getAllowedOrigins(),
97101
}),
98102
SqlExpressionValidatorPreset(),
99103
PgTypeMappingsPreset,

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

Lines changed: 64 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,64 @@ export function createBucketNameResolver(): BucketNameResolver {
8183
return `${prefix}-${databaseId}`;
8284
};
8385
}
86+
87+
/**
88+
* Resolve CORS allowed origins from the env/config system.
89+
*
90+
* Reads SERVER_ORIGIN from the standard env hierarchy
91+
* (pgpmDefaults → config file → env vars) and wraps it in an array.
92+
* Falls back to ['http://localhost:3000'] for local development.
93+
*/
94+
export function getAllowedOrigins(): string[] {
95+
const { server } = getEnvOptions();
96+
if (server?.origin) return [server.origin];
97+
return ['*'];
98+
}
99+
100+
/**
101+
* Create a lazy bucket provisioner callback for the presigned URL plugin.
102+
*
103+
* On the first upload to an S3 bucket that doesn't exist yet, this callback
104+
* uses the BucketProvisioner to create and fully configure the bucket
105+
* (Block Public Access, CORS, policies, lifecycle rules for temp buckets).
106+
*
107+
* Uses the same S3 connection config as the bucket provisioner plugin
108+
* (getBucketProvisionerConnection) and reads CORS origins from
109+
* SERVER_ORIGIN env var (falls back to localhost for local dev).
110+
*/
111+
export function createEnsureBucketProvisioned(): EnsureBucketProvisioned {
112+
let provisioner: BucketProvisioner | null = null;
113+
114+
return async (
115+
bucketName: string,
116+
accessType: 'public' | 'private' | 'temp',
117+
databaseId: string,
118+
allowedOrigins: string[] | null,
119+
): Promise<void> => {
120+
// Per-database origins from storage_module, falling back to global SERVER_ORIGIN
121+
const effectiveOrigins = (allowedOrigins && allowedOrigins.length > 0)
122+
? allowedOrigins
123+
: getAllowedOrigins();
124+
125+
if (!provisioner) {
126+
provisioner = new BucketProvisioner({
127+
connection: getBucketProvisionerConnection(),
128+
allowedOrigins: effectiveOrigins,
129+
});
130+
}
131+
132+
log.info(
133+
`[lazy-provision] Provisioning S3 bucket "${bucketName}" ` +
134+
`(type=${accessType}) for database ${databaseId}`,
135+
);
136+
137+
await provisioner.provision({
138+
bucketName,
139+
accessType,
140+
versioning: false,
141+
allowedOrigins: effectiveOrigins,
142+
});
143+
144+
log.info(`[lazy-provision] S3 bucket "${bucketName}" provisioned successfully`);
145+
};
146+
}

0 commit comments

Comments
 (0)