diff --git a/graphile/graphile-bucket-provisioner-plugin/README.md b/graphile/graphile-bucket-provisioner-plugin/README.md
index 83110bca90..1c686a8550 100644
--- a/graphile/graphile-bucket-provisioner-plugin/README.md
+++ b/graphile/graphile-bucket-provisioner-plugin/README.md
@@ -1,27 +1,12 @@
# graphile-bucket-provisioner-plugin
-
-
-
+PostGraphile v5 plugin that exposes a `provisionBucket` mutation for manually
+queueing storage reconciliation.
-
-
-
-
-
-
-
-
-PostGraphile v5 plugin that explicitly provisions S3-compatible buckets through a GraphQL mutation using [`@constructive-io/bucket-provisioner`](../packages/bucket-provisioner).
-
-## Features
-
-- **Explicit `provisionBucket` mutation** — GraphQL mutation for manual/retry provisioning of any bucket
-- **3-tier CORS resolution** — Bucket-level `allowed_origins` → storage module-level `allowed_origins` → plugin config `allowedOrigins`
-- **Wildcard CORS** — Set `allowed_origins = ['*']` on a bucket for fully open CDN/public deployments
-- **Per-database overrides** — Reads `endpoint`, `provider`, `public_url_prefix`, and `allowed_origins` from the `storage_module` table for multi-tenant setups
-- **Lazy S3 config** — Connection config can be a function (evaluated once, cached) to avoid eager env-var reads at import time
-- **Deployment-controlled naming** — Requires a `resolveBucketName` policy for tenant-aware physical bucket names
+The constructive-db `storage:provision_bucket` job is the only component that
+mints physical S3 bucket names, provisions S3, and records `physical_name`.
+This plugin resolves the logical bucket under RLS and enqueues that job; it
+does not call S3 or derive a physical name.
## Installation
@@ -29,137 +14,43 @@ PostGraphile v5 plugin that explicitly provisions S3-compatible buckets through
pnpm add graphile-bucket-provisioner-plugin
```
-## Quick Start
-
-```typescript
-import { createBucketProvisionerPlugin } from 'graphile-bucket-provisioner-plugin';
-
-const BucketProvisionerPlugin = createBucketProvisionerPlugin({
- connection: {
- provider: 'minio',
- region: 'us-east-1',
- endpoint: 'http://minio:9000',
- accessKeyId: process.env.S3_ACCESS_KEY_ID!,
- secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
- },
- allowedOrigins: ['https://app.example.com'],
-});
-
-// Add to your PostGraphile preset
-const preset: GraphileConfig.Preset = {
- plugins: [BucketProvisionerPlugin],
-};
-```
-
-Or use the convenience preset:
+## Usage
```typescript
import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin';
-const preset: GraphileConfig.Preset = {
- extends: [
- BucketProvisionerPreset({
- connection: () => ({
- provider: 'minio',
- region: 'us-east-1',
- endpoint: process.env.S3_ENDPOINT!,
- accessKeyId: process.env.S3_ACCESS_KEY_ID!,
- secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
- }),
- allowedOrigins: ['https://app.example.com'],
- }),
- ],
+const preset = {
+ extends: [BucketProvisionerPreset()],
};
```
-## How It Works
-
-### Auto-Provisioning (default)
-
-When a `createBucket` mutation runs on a table tagged with `@storageBuckets`:
-
-1. The original resolver runs first (creates the DB row via RLS)
-2. The plugin reads the bucket's `key` and `type` from the mutation input
-3. It reads the `storage_module` config for per-database endpoint/provider overrides
-4. It calls `BucketProvisioner.provision()` to create and configure the S3 bucket
-5. If provisioning fails, the error is logged but the mutation result is returned normally
-
-### Explicit Mutation
-
-The plugin also adds a `provisionBucket` mutation for manual provisioning or retrying failed provisions:
+The mutation accepts a logical bucket key and optionally an owner entity ID:
```graphql
mutation {
provisionBucket(input: { bucketKey: "public" }) {
- success
- bucketName
- accessType
- provider
- endpoint
- error
+ bucketId
+ bucketKey
+ physicalName
+ jobId
}
}
```
-This mutation:
-1. Reads the bucket row from the database (protected by RLS)
-2. Reads the storage module config for the current database
-3. Provisions the S3 bucket with the appropriate settings
-4. Returns a success/error payload
-
-## API
-
-### `createBucketProvisionerPlugin(options)`
-
-Creates the plugin instance. Returns a `GraphileConfig.Plugin`.
-
-| Option | Type | Description |
-|--------|------|-------------|
-| `connection` | `StorageConnectionConfig \| () => StorageConnectionConfig` | S3 connection config (static or lazy getter) |
-| `allowedOrigins` | `string[]` | CORS allowed origins for bucket configuration |
-| `resolveBucketName` | `(databaseId, bucketKey) => string` | Deployment policy for deriving a tenant-aware physical bucket name |
-| `versioning` | `boolean?` | Enable S3 versioning on provisioned buckets (default: `false`) |
-
-### `BucketProvisionerPreset(options)`
-
-Convenience function that wraps the plugin in a `GraphileConfig.Preset`.
-
-### Connection Config
-
-```typescript
-interface StorageConnectionConfig {
- provider: 's3' | 'minio' | 'r2' | 'gcs' | 'spaces';
- region: string;
- endpoint?: string;
- accessKeyId: string;
- secretAccessKey: string;
-}
-```
-
-### Smart Tag Detection
-
-The plugin detects tables tagged with `@storageBuckets` (set by the storage module generator in constructive-db):
-
-```sql
-COMMENT ON TABLE app_public.buckets IS E'@storageBuckets\nStorage buckets table';
-```
-
-The plugin wraps `create*` mutations for auto-provisioning and `update*` mutations for CORS change detection. Delete mutations are not wrapped.
-
-## Error Handling
-
-The plugin is designed to never break mutations:
-
-- **Auto-provisioning errors** are caught and logged. The mutation result is returned normally. The admin can retry via the `provisionBucket` mutation.
-- **Explicit `provisionBucket` errors** return a structured payload with `success: false` and an `error` message.
-- **Validation errors** (`INVALID_BUCKET_KEY`, `DATABASE_NOT_FOUND`, `STORAGE_MODULE_NOT_PROVISIONED`, `BUCKET_NOT_FOUND`) are thrown as exceptions since they indicate configuration issues.
-
-## Multi-Tenant Support
+`provisionBucket` resolves the bucket row under RLS and enqueues the same
+`storage:provision_bucket` job used by the database INSERT trigger. Database
+scope jobs include `database_id`, use the `bucket:` queue, and have
+25 attempts. Platform scope jobs use the platform trigger payload. Enqueue
+failures are returned as GraphQL errors.
-The plugin reads per-database overrides from the `storage_module` table:
+The payload fields are:
-- `endpoint` — Override the S3 endpoint for this database
-- `provider` — Override the storage provider for this database
-- `public_url_prefix` — CDN/public URL prefix for public buckets
+| Field | Description |
+|-------|-------------|
+| `bucketId` | Logical bucket row ID |
+| `bucketKey` | Logical bucket key |
+| `physicalName` | Recorded physical name, or `null` while reconciliation is pending |
+| `jobId` | ID of the queued reconciler job |
-This allows different tenants to use different storage backends while sharing the same plugin configuration.
+There are no plugin options: S3 connection details, naming, CORS, versioning,
+and physical-name recording belong to the reconciler.
diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts
index 5d48364b27..8b7b1d949b 100644
--- a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts
+++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts
@@ -1,27 +1,9 @@
/**
- * Tests for the explicit bucket provisioning mutation.
+ * Tests for the explicit bucket reconciliation enqueue mutation.
*/
-const mockProvision = jest.fn();
-const mockBucketProvisionerConstructor = jest.fn();
-
-jest.mock('@constructive-io/bucket-provisioner', () => ({
- BucketProvisioner: jest.fn().mockImplementation((opts: any) => {
- mockBucketProvisionerConstructor(opts);
- return { provision: mockProvision };
- }),
-}));
-
-jest.mock('@pgpmjs/logger', () => ({
- Logger: jest.fn().mockImplementation(() => ({
- info: jest.fn(),
- debug: jest.fn(),
- warn: jest.fn(),
- error: jest.fn(),
- })),
-}));
-
let capturedLambdaCallback: Function | null = null;
+
jest.mock('grafast', () => ({
context: jest.fn(() => ({
get: jest.fn((key: string) => `mock-${key}`),
@@ -51,433 +33,282 @@ jest.mock('graphile-utils', () => ({
}));
import { createBucketProvisionerPlugin } from '../src/plugin';
-import type { BucketProvisionerPluginOptions } from '../src/types';
-
-function createDefaultOptions(
- overrides: Partial = {},
-): BucketProvisionerPluginOptions {
- return {
- connection: {
- provider: 'minio',
- region: 'us-east-1',
- endpoint: 'http://minio:9000',
- accessKeyId: 'minioadmin',
- secretAccessKey: 'minioadmin',
- },
- allowedOrigins: ['https://app.example.com'],
- resolveBucketName: (databaseId, bucketKey) => `tenant-${databaseId}-${bucketKey}`,
- ...overrides,
- };
+
+const DATABASE_ID = 'db-uuid-123';
+const BUCKET_ID = 'bucket-uuid-789';
+const BUCKETS_TABLE_ID = 'buckets-table-uuid';
+const OWNER_ID = 'owner-uuid-222';
+const JOB_ID = 'job-uuid-999';
+
+interface MockOptions {
+ scope: string;
+ entityField: string | null;
+ physicalName?: string | null;
+ scopeKey?: string | null;
+ orgResolver?: {
+ entity_type: string;
+ get_org_fn_schema: string;
+ get_org_fn: string;
+ } | null;
+ bucketFound?: boolean;
}
-function createMockPgClient(overrides: Record = {}) {
- const defaultQueries: Record = {
- 'jwt_private.current_database_id': {
- rows: [{ id: 'db-uuid-123' }],
- },
- 'metaschema_modules_public.storage_module': {
- rows: [{
- id: 'sm-uuid-456',
- scope: 'app',
- entity_table_id: null,
- buckets_schema: 'app_public',
- buckets_table: 'buckets',
- endpoint: null,
- public_url_prefix: null,
- provider: null,
- allowed_origins: null,
- entity_schema: null,
- entity_table: null,
- }],
- },
- app_public: {
- rows: [{
- id: 'bucket-uuid-789',
- key: 'public',
- type: 'public',
- is_public: true,
- allowed_origins: null,
- physical_name: null,
- }],
- },
- };
-
- return {
- query: jest.fn((arg: any) => {
- const sql: string = typeof arg === 'string' ? arg : arg.text;
- for (const [key, value] of Object.entries({ ...defaultQueries, ...overrides })) {
- if (sql.includes(key)) return Promise.resolve(value);
- }
- return Promise.resolve({ rows: [] });
- }),
- };
+function createMockPgClient({
+ scope,
+ entityField,
+ physicalName = null,
+ scopeKey = null,
+ orgResolver = null,
+ bucketFound = true,
+}: MockOptions) {
+ const query = jest.fn((arg: any) => {
+ const sql: string = typeof arg === 'string' ? arg : arg.text;
+ if (sql.includes('jwt_private.current_database_id')) {
+ return Promise.resolve({ rows: [{ id: DATABASE_ID }] });
+ }
+ if (sql.includes('metaschema_modules_public.storage_module')) {
+ return Promise.resolve({
+ rows: [{
+ id: 'sm-uuid-456',
+ database_id: DATABASE_ID,
+ buckets_table_id: BUCKETS_TABLE_ID,
+ scope,
+ entity_field: entityField,
+ entity_table_id: entityField === 'owner_id' ? 'entity-table-uuid' : null,
+ buckets_schema: 'app_public',
+ buckets_table: 'buckets',
+ endpoint: null,
+ public_url_prefix: null,
+ provider: null,
+ allowed_origins: null,
+ entity_schema: entityField === 'owner_id' ? 'app_public' : null,
+ entity_table: entityField === 'owner_id' ? 'accounts' : null,
+ }],
+ });
+ }
+ if (sql.includes('FROM app_public.accounts')) {
+ return Promise.resolve({ rows: [{ id: OWNER_ID }] });
+ }
+ if (sql.includes('FROM app_public.buckets')) {
+ return Promise.resolve({
+ rows: bucketFound
+ ? [{
+ id: BUCKET_ID,
+ key: 'public',
+ physical_name: physicalName,
+ scope_key: scopeKey,
+ }]
+ : [],
+ });
+ }
+ if (sql.includes('metaschema.resolve_entity_context_by_field')) {
+ return Promise.resolve({ rows: orgResolver ? [orgResolver] : [{
+ entity_type: scope,
+ get_org_fn_schema: null,
+ get_org_fn: null,
+ }] });
+ }
+ if (sql.includes('app_jobs.add_job')) {
+ return Promise.resolve({ rows: [{ id: JOB_ID }] });
+ }
+ throw new Error(`unexpected query: ${sql}`);
+ });
+ return { query };
+}
+
+async function invoke(pgClient: any, input: Record = { bucketKey: 'public' }) {
+ const withPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
+ return capturedLambdaCallback!({
+ input,
+ withPgClient,
+ pgSettings: { role: 'admin' },
+ });
+}
+
+function enqueueCall(pgClient: any): { text: string; values: unknown[] } {
+ const call = pgClient.query.mock.calls.find((args: any[]) =>
+ args[0]?.text?.includes('app_jobs.add_job'));
+ expect(call).toBeDefined();
+ return call[0];
}
describe('createBucketProvisionerPlugin', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockProvision.mockReset();
- mockBucketProvisionerConstructor.mockReset();
capturedLambdaCallback = null;
- mockProvision.mockResolvedValue({
- bucketName: 'tenant-db-uuid-123-public',
- accessType: 'public',
- endpoint: 'http://minio:9000',
- provider: 'minio',
- region: 'us-east-1',
- publicUrlPrefix: null,
- blockPublicAccess: false,
- versioning: false,
- corsRules: [],
- lifecycleRules: [],
- });
});
it('returns a mutation-only plugin', () => {
- const plugin = createBucketProvisionerPlugin(createDefaultOptions());
+ const plugin = createBucketProvisionerPlugin();
expect(plugin).toBeDefined();
expect(plugin.name).toBe('ExtendSchemaPlugin');
expect(plugin.schema).toBeDefined();
- expect(plugin.schema!.hooks).toEqual({});
- });
-
- it('provisions a public bucket successfully', async () => {
- createBucketProvisionerPlugin(createDefaultOptions());
- const pgClient = createMockPgClient();
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- const result = await capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: { role: 'admin' },
- });
-
- expect(result.success).toBe(true);
- expect(result.bucketName).toBe('tenant-db-uuid-123-public');
- expect(result.accessType).toBe('public');
- expect(result.provider).toBe('minio');
- expect(result.error).toBeNull();
- expect(mockProvision).toHaveBeenCalledWith(
- expect.objectContaining({ bucketName: 'tenant-db-uuid-123-public' }),
- );
});
- it('uses the database-first resolver order and never passes the bare key', async () => {
- const resolveBucketName = jest.fn(
- (databaseId: string, bucketKey: string) => `physical-${databaseId}-${bucketKey}`,
- );
- createBucketProvisionerPlugin(createDefaultOptions({ resolveBucketName }));
- const pgClient = createMockPgClient();
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- await capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
+ it('enqueues the global-scope reconciliation job without entity attribution', async () => {
+ createBucketProvisionerPlugin();
+ const pgClient = createMockPgClient({ scope: 'app', entityField: null });
+
+ const result = await invoke(pgClient);
+ const enqueue = enqueueCall(pgClient);
+
+ expect(enqueue.text).toContain("identifier => 'storage:provision_bucket'");
+ expect(enqueue.text).toContain("'id', $1::uuid");
+ expect(enqueue.text).toContain("'scope', $2::text");
+ expect(enqueue.text).toContain("queue_name => 'bucket:' || $1::text");
+ expect(enqueue.text).toContain('max_attempts => 25');
+ expect(enqueue.text).toContain('priority => 0');
+ expect(enqueue.text).not.toContain('entity_id =>');
+ expect(enqueue.text).not.toContain('organization_id =>');
+ expect(enqueue.values).toEqual([BUCKET_ID, 'app']);
+ expect(result).toEqual({
+ bucketId: BUCKET_ID,
+ bucketKey: 'public',
+ physicalName: null,
+ jobId: JOB_ID,
});
-
- expect(resolveBucketName).toHaveBeenCalledWith('db-uuid-123', 'public');
- expect(mockProvision).toHaveBeenCalledWith(
- expect.objectContaining({ bucketName: 'physical-db-uuid-123-public' }),
- );
- expect(mockProvision).not.toHaveBeenCalledWith(
- expect.objectContaining({ bucketName: 'public' }),
- );
});
- it('throws when no physical bucket naming policy is configured', async () => {
- createBucketProvisionerPlugin(createDefaultOptions({ resolveBucketName: undefined }));
- const pgClient = createMockPgClient();
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- await expect(
- capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
- }),
- ).rejects.toThrow('STORAGE_BUCKET_NAME_POLICY_MISSING');
- expect(mockProvision).not.toHaveBeenCalled();
- });
-
- it('provisions a private bucket', async () => {
- mockProvision.mockResolvedValue({
- bucketName: 'tenant-db-uuid-123-private',
- accessType: 'private',
- endpoint: 'http://minio:9000',
- provider: 'minio',
- region: 'us-east-1',
- publicUrlPrefix: null,
- blockPublicAccess: true,
- versioning: false,
- corsRules: [],
- lifecycleRules: [],
- });
- createBucketProvisionerPlugin(createDefaultOptions());
+ it('enqueues the platform global-scope reconciliation job', async () => {
+ createBucketProvisionerPlugin();
const pgClient = createMockPgClient({
- app_public: {
- rows: [{
- id: 'bucket-uuid-private',
- key: 'private',
- type: 'private',
- is_public: false,
- allowed_origins: null,
- physical_name: null,
- }],
- },
+ scope: 'platform',
+ entityField: null,
+ physicalName: 'existing-physical-name',
});
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
- const result = await capturedLambdaCallback!({
- input: { bucketKey: 'private' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
- });
+ const result = await invoke(pgClient);
+ const enqueue = enqueueCall(pgClient);
- expect(result.success).toBe(true);
- expect(result.accessType).toBe('private');
+ expect(enqueue.text).not.toContain('db_id =>');
+ expect(enqueue.text).not.toContain('entity_type =>');
+ expect(enqueue.values).toEqual([BUCKET_ID, 'platform']);
+ expect(result.physicalName).toBe('existing-physical-name');
});
- it('throws INVALID_BUCKET_KEY for an empty key', async () => {
- createBucketProvisionerPlugin(createDefaultOptions());
-
- await expect(
- capturedLambdaCallback!({
- input: { bucketKey: '' },
- withPgClient: jest.fn(),
- pgSettings: {},
- }),
- ).rejects.toThrow('INVALID_BUCKET_KEY');
- });
-
- it('throws DATABASE_NOT_FOUND when database_id is null', async () => {
- createBucketProvisionerPlugin(createDefaultOptions());
- const pgClient = createMockPgClient({
- 'jwt_private.current_database_id': { rows: [{ id: null }] },
- });
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- await expect(
- capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
- }),
- ).rejects.toThrow('DATABASE_NOT_FOUND');
- });
-
- it('throws STORAGE_MODULE_NOT_PROVISIONED when no storage modules exist', async () => {
- createBucketProvisionerPlugin(createDefaultOptions());
+ it('enqueues the exact database-scope reconciliation job', async () => {
+ createBucketProvisionerPlugin();
const pgClient = createMockPgClient({
- 'metaschema_modules_public.storage_module': { rows: [] },
- });
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- await expect(
- capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
- }),
- ).rejects.toThrow('STORAGE_MODULE_NOT_PROVISIONED');
- });
-
- it('throws BUCKET_NOT_FOUND when the bucket does not exist', async () => {
- createBucketProvisionerPlugin(createDefaultOptions());
- const pgClient = createMockPgClient({ app_public: { rows: [] } });
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- await expect(
- capturedLambdaCallback!({
- input: { bucketKey: 'missing' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
- }),
- ).rejects.toThrow('BUCKET_NOT_FOUND');
- });
-
- it('returns an error payload when provisioning fails', async () => {
- mockProvision.mockRejectedValue(new Error('S3 connection refused'));
- createBucketProvisionerPlugin(createDefaultOptions());
- const pgClient = createMockPgClient();
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- const result = await capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
+ scope: 'database',
+ entityField: 'database_id',
+ scopeKey: DATABASE_ID,
});
- expect(result.success).toBe(false);
- expect(result.error).toBe('S3 connection refused');
- expect(result.bucketName).toBe('tenant-db-uuid-123-public');
+ const result = await invoke(pgClient);
+ const enqueue = enqueueCall(pgClient);
+ const bucketLookup = pgClient.query.mock.calls.find((args: any[]) =>
+ args[0]?.text?.includes('FROM app_public.buckets'));
+
+ expect(bucketLookup[0].text).toContain('database_id AS scope_key');
+ expect(bucketLookup[0].values).toEqual(['public']);
+ expect(enqueue.text).toContain("'database_id', $2::uuid");
+ expect(enqueue.text).toContain("'id', $1::uuid");
+ expect(enqueue.text).toContain("'scope', $3::text");
+ expect(enqueue.text).toContain('db_id => $2');
+ expect(enqueue.text).toContain('entity_id => $2');
+ expect(enqueue.text).toContain('organization_id => NULL');
+ expect(enqueue.text).toContain('entity_type => $3');
+ expect(enqueue.values).toEqual([BUCKET_ID, DATABASE_ID, 'database']);
+ expect(result.jobId).toBe(JOB_ID);
});
- it('records the physical name with the record-once guard', async () => {
- createBucketProvisionerPlugin(createDefaultOptions());
- const pgClient = createMockPgClient();
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- await capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: { role: 'admin' },
- });
-
- const update = pgClient.query.mock.calls.find(
- (call: any[]) => call[0]?.text?.includes('SET physical_name'),
- );
- expect(update).toBeDefined();
- expect(update![0].text).toContain('physical_name IS NULL');
- expect(update![0].values).toEqual(['tenant-db-uuid-123-public', 'bucket-uuid-789']);
- expect(mockWithPgClient).toHaveBeenCalledWith(null, expect.any(Function));
- });
-
- it('provisions the stored physical name verbatim', async () => {
- createBucketProvisionerPlugin(createDefaultOptions());
+ it('enqueues an entity-scope job with the resolved organization function', async () => {
+ createBucketProvisionerPlugin();
const pgClient = createMockPgClient({
- app_public: {
- rows: [{
- id: 'bucket-uuid-789',
- key: 'public',
- type: 'public',
- is_public: true,
- allowed_origins: null,
- physical_name: 'preexisting-cdn-bucket',
- }],
+ scope: 'org',
+ entityField: 'owner_id',
+ scopeKey: OWNER_ID,
+ orgResolver: {
+ entity_type: 'org',
+ get_org_fn_schema: 'org_private',
+ get_org_fn: 'get_organization_id',
},
});
- mockProvision.mockResolvedValue({
- bucketName: 'preexisting-cdn-bucket',
- accessType: 'public',
- endpoint: 'http://minio:9000',
- provider: 'minio',
- region: 'us-east-1',
- publicUrlPrefix: null,
- blockPublicAccess: false,
- versioning: false,
- corsRules: [],
- lifecycleRules: [],
- });
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- const result = await capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
- });
- expect(result.success).toBe(true);
- expect(mockProvision).toHaveBeenCalledWith(
- expect.objectContaining({ bucketName: 'preexisting-cdn-bucket' }),
+ await invoke(pgClient, { bucketKey: 'public', ownerId: OWNER_ID });
+ const enqueue = enqueueCall(pgClient);
+ const bucketLookup = pgClient.query.mock.calls.find((args: any[]) =>
+ args[0]?.text?.includes('FROM app_public.buckets'));
+ const resolverLookup = pgClient.query.mock.calls.find((args: any[]) =>
+ args[0]?.text?.includes('metaschema.resolve_entity_context_by_field'));
+
+ expect(bucketLookup[0].text).toContain('owner_id AS scope_key');
+ expect(bucketLookup[0].values).toEqual(['public', OWNER_ID]);
+ expect(resolverLookup[0].values).toEqual([
+ DATABASE_ID,
+ BUCKETS_TABLE_ID,
+ 'owner_id',
+ ]);
+ expect(enqueue.text).toContain("'id', $1::uuid");
+ expect(enqueue.text).toContain("'owner_id', $2::uuid");
+ expect(enqueue.text).toContain("'scope', $3::text");
+ expect(enqueue.text).toContain('entity_id => $2');
+ expect(enqueue.text).toContain(
+ 'organization_id => org_private.get_organization_id($3::text, $2::uuid)',
);
- const update = pgClient.query.mock.calls.find(
- (call: any[]) => call[0]?.text?.includes('SET physical_name'),
- );
- expect(update).toBeDefined();
- expect(update![0].text).toContain('physical_name IS NULL');
+ expect(enqueue.text).toContain('entity_type => $3');
+ expect(enqueue.values).toEqual([BUCKET_ID, OWNER_ID, 'org']);
});
- it('does not record a name when provisioning fails', async () => {
- mockProvision.mockRejectedValue(new Error('S3 connection refused'));
- createBucketProvisionerPlugin(createDefaultOptions());
- const pgClient = createMockPgClient();
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- await capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
+ it('enqueues an entity-scope job with NULL organization without a resolver', async () => {
+ createBucketProvisionerPlugin();
+ const pgClient = createMockPgClient({
+ scope: 'user',
+ entityField: 'owner_id',
+ scopeKey: OWNER_ID,
});
- const update = pgClient.query.mock.calls.find(
- (call: any[]) => call[0]?.text?.includes('SET physical_name'),
- );
- expect(update).toBeUndefined();
+ await invoke(pgClient, { bucketKey: 'public', ownerId: OWNER_ID });
+ const enqueue = enqueueCall(pgClient);
+ const resolverLookup = pgClient.query.mock.calls.find((args: any[]) =>
+ args[0]?.text?.includes('metaschema.resolve_entity_context_by_field'));
+
+ expect(resolverLookup[0].values).toEqual([
+ DATABASE_ID,
+ BUCKETS_TABLE_ID,
+ 'owner_id',
+ ]);
+ expect(enqueue.text).toContain("'owner_id', $2::uuid");
+ expect(enqueue.text).toContain('entity_id => $2');
+ expect(enqueue.text).toContain('organization_id => NULL');
+ expect(enqueue.text).toContain('entity_type => $3');
+ expect(enqueue.values).toEqual([BUCKET_ID, OWNER_ID, 'user']);
});
- it('applies storage-module endpoint and public URL overrides', async () => {
- createBucketProvisionerPlugin(createDefaultOptions());
+ it('propagates enqueue failures as GraphQL errors', async () => {
+ createBucketProvisionerPlugin();
const pgClient = createMockPgClient({
- 'metaschema_modules_public.storage_module': {
- rows: [{
- id: 'sm-uuid-456',
- scope: 'app',
- entity_table_id: null,
- buckets_schema: 'app_public',
- buckets_table: 'buckets',
- endpoint: 'http://custom-minio:9000',
- public_url_prefix: 'https://cdn.example.com',
- provider: 'minio',
- allowed_origins: null,
- entity_schema: null,
- entity_table: null,
- }],
- },
+ scope: 'database',
+ entityField: 'database_id',
+ scopeKey: DATABASE_ID,
});
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- await capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
+ pgClient.query.mockImplementation((arg: any): Promise => {
+ const sql: string = typeof arg === 'string' ? arg : arg.text;
+ if (sql.includes('app_jobs.add_job')) {
+ return Promise.reject(new Error('enqueue failed'));
+ }
+ return createMockPgClient({
+ scope: 'database',
+ entityField: 'database_id',
+ scopeKey: DATABASE_ID,
+ }).query(arg);
});
- expect(mockBucketProvisionerConstructor).toHaveBeenCalledWith(
- expect.objectContaining({
- connection: expect.objectContaining({
- endpoint: 'http://custom-minio:9000',
- provider: 'minio',
- }),
- }),
- );
- expect(mockProvision).toHaveBeenCalledWith(
- expect.objectContaining({ publicUrlPrefix: 'https://cdn.example.com' }),
- );
+ await expect(invoke(pgClient)).rejects.toThrow('enqueue failed');
});
- it('passes the versioning option to the provisioner', async () => {
- createBucketProvisionerPlugin(createDefaultOptions({ versioning: true }));
- const pgClient = createMockPgClient();
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- await capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
- });
+ it('throws for invalid and missing bucket inputs', async () => {
+ createBucketProvisionerPlugin();
- expect(mockProvision).toHaveBeenCalledWith(
- expect.objectContaining({ versioning: true }),
- );
- });
+ await expect(invoke({ query: jest.fn() }, { bucketKey: '' }))
+ .rejects.toThrow('INVALID_BUCKET_KEY');
- it('caches a lazy connection getter', async () => {
- const connection = {
- provider: 'minio' as const,
- region: 'us-east-1',
- endpoint: 'http://minio:9000',
- accessKeyId: 'minioadmin',
- secretAccessKey: 'minioadmin',
- };
- const getter = jest.fn(() => connection);
- const options = createDefaultOptions({ connection: getter });
- createBucketProvisionerPlugin(options);
- const pgClient = createMockPgClient();
- const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient));
-
- await capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
- });
- await capturedLambdaCallback!({
- input: { bucketKey: 'public' },
- withPgClient: mockWithPgClient,
- pgSettings: {},
+ const pgClient = createMockPgClient({
+ scope: 'app',
+ entityField: null,
+ bucketFound: false,
});
-
- expect(getter).toHaveBeenCalledTimes(1);
+ await expect(invoke(pgClient)).rejects.toThrow('BUCKET_NOT_FOUND');
});
});
diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts
index 1500b907af..46eda8289f 100644
--- a/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts
+++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts
@@ -1,26 +1,9 @@
/**
- * Tests for the bucket provisioner preset.
+ * Tests for the bucket reconciliation preset.
*/
-jest.mock('@constructive-io/bucket-provisioner', () => ({
- BucketProvisioner: jest.fn().mockImplementation(() => ({
- provision: jest.fn(),
- })),
-}));
-
-jest.mock('@pgpmjs/logger', () => ({
- Logger: jest.fn().mockImplementation(() => ({
- info: jest.fn(),
- debug: jest.fn(),
- warn: jest.fn(),
- error: jest.fn(),
- })),
-}));
-
jest.mock('grafast', () => ({
- context: jest.fn(() => ({
- get: jest.fn((key: string) => `mock-${key}`),
- })),
+ context: jest.fn(),
lambda: jest.fn(),
object: jest.fn((obj: any) => obj),
}));
@@ -41,53 +24,10 @@ jest.mock('graphile-utils', () => ({
import { BucketProvisionerPreset } from '../src/preset';
describe('BucketProvisionerPreset', () => {
- it('returns a preset with plugins array', () => {
- const preset = BucketProvisionerPreset({
- connection: {
- provider: 'minio',
- region: 'us-east-1',
- endpoint: 'http://minio:9000',
- accessKeyId: 'test',
- secretAccessKey: 'test',
- },
- allowedOrigins: ['https://app.example.com'],
- });
-
- expect(preset).toBeDefined();
- expect(preset.plugins).toBeDefined();
- expect(preset.plugins).toHaveLength(1);
- });
-
- it('passes options through to the plugin', () => {
- const preset = BucketProvisionerPreset({
- connection: {
- provider: 's3',
- region: 'us-west-2',
- accessKeyId: 'key',
- secretAccessKey: 'secret',
- },
- allowedOrigins: ['https://app.example.com'],
- versioning: true,
- });
-
- expect(preset.plugins).toHaveLength(1);
- // The plugin should be the BucketProvisionerPlugin
- const plugin = preset.plugins![0];
- expect(plugin).toBeDefined();
- });
-
- it('creates a preset with lazy connection getter', () => {
- const preset = BucketProvisionerPreset({
- connection: () => ({
- provider: 'minio',
- region: 'us-east-1',
- endpoint: 'http://minio:9000',
- accessKeyId: 'test',
- secretAccessKey: 'test',
- }),
- allowedOrigins: ['https://app.example.com'],
- });
+ it('returns a preset with the reconciliation plugin', () => {
+ const preset = BucketProvisionerPreset();
expect(preset.plugins).toHaveLength(1);
+ expect(preset.plugins![0]).toBeDefined();
});
});
diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts
index 8fd78d139d..dd7f01894a 100644
--- a/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts
+++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts
@@ -1,188 +1,33 @@
/**
- * Tests for the bucket provisioner plugin types.
- *
- * Validates type definitions, interfaces, and re-exports are correct.
+ * Tests for the bucket reconciliation mutation types.
*/
-import type {
- BucketAccessType,
- BucketNameResolver,
- BucketProvisionerPluginOptions,
- ConnectionConfigOrGetter,
- ProvisionBucketInput,
- ProvisionBucketPayload,
- ProvisionResult,
- StorageConnectionConfig,
- StorageProvider,
-} from '../src/types';
-
-describe('BucketProvisionerPluginOptions', () => {
- it('accepts static connection config', () => {
- const options: BucketProvisionerPluginOptions = {
- connection: {
- provider: 'minio',
- region: 'us-east-1',
- endpoint: 'http://minio:9000',
- accessKeyId: 'test',
- secretAccessKey: 'test',
- },
- allowedOrigins: ['https://app.example.com'],
- };
-
- expect(options.connection).toBeDefined();
- expect(options.allowedOrigins).toHaveLength(1);
- });
-
- it('accepts lazy getter connection config', () => {
- const options: BucketProvisionerPluginOptions = {
- connection: () => ({
- provider: 's3',
- region: 'us-west-2',
- accessKeyId: 'key',
- secretAccessKey: 'secret',
- }),
- allowedOrigins: ['https://app.example.com'],
- };
-
- expect(typeof options.connection).toBe('function');
- });
-
- it('accepts all optional fields', () => {
- const options: BucketProvisionerPluginOptions = {
- connection: {
- provider: 'r2',
- region: 'auto',
- endpoint: 'https://xxx.r2.cloudflarestorage.com',
- accessKeyId: 'key',
- secretAccessKey: 'secret',
- },
- allowedOrigins: ['https://app.example.com', 'http://localhost:3000'],
- resolveBucketName: (dbId, key) => `${dbId}-${key}`,
- versioning: true,
- };
-
- expect(options.resolveBucketName).toBeDefined();
- expect(options.versioning).toBe(true);
- });
-});
-
-describe('ConnectionConfigOrGetter', () => {
- it('can be a static StorageConnectionConfig', () => {
- const config: ConnectionConfigOrGetter = {
- provider: 'minio',
- region: 'us-east-1',
- endpoint: 'http://minio:9000',
- accessKeyId: 'test',
- secretAccessKey: 'test',
- };
-
- expect(typeof config).toBe('object');
- });
-
- it('can be a function returning StorageConnectionConfig', () => {
- const getter: ConnectionConfigOrGetter = () => ({
- provider: 's3',
- region: 'us-east-1',
- accessKeyId: 'key',
- secretAccessKey: 'secret',
- });
-
- expect(typeof getter).toBe('function');
- const result = getter();
- expect(result.provider).toBe('s3');
- });
-});
-
-describe('BucketNameResolver', () => {
- it('takes databaseId and bucketKey and returns a string', () => {
- const resolver: BucketNameResolver = (databaseId, bucketKey) =>
- `org-${databaseId}-${bucketKey}`;
-
- expect(resolver('db-123', 'public')).toBe('org-db-123-public');
- expect(resolver('db-456', 'private')).toBe('org-db-456-private');
- });
-});
+import type { ProvisionBucketInput, ProvisionBucketPayload } from '../src/types';
describe('ProvisionBucketInput', () => {
- it('has a bucketKey field', () => {
+ it('has a bucket key and optional owner id', () => {
const input: ProvisionBucketInput = {
bucketKey: 'public',
+ ownerId: 'owner-123',
};
- expect(input.bucketKey).toBe('public');
+ expect(input).toEqual({
+ bucketKey: 'public',
+ ownerId: 'owner-123',
+ });
});
});
describe('ProvisionBucketPayload', () => {
- it('represents a successful provisioning result', () => {
- const payload: ProvisionBucketPayload = {
- success: true,
- bucketName: 'myapp-public',
- accessType: 'public',
- provider: 'minio',
- endpoint: 'http://minio:9000',
- error: null,
- };
-
- expect(payload.success).toBe(true);
- expect(payload.error).toBeNull();
- });
-
- it('represents a failed provisioning result', () => {
+ it('represents a queued reconciliation job', () => {
const payload: ProvisionBucketPayload = {
- success: false,
- bucketName: 'myapp-public',
- accessType: 'public',
- provider: 'minio',
- endpoint: 'http://minio:9000',
- error: 'S3 connection refused',
- };
-
- expect(payload.success).toBe(false);
- expect(payload.error).toBe('S3 connection refused');
- });
-});
-
-describe('re-exported types from @constructive-io/bucket-provisioner', () => {
- it('StorageProvider includes all supported providers', () => {
- const providers: StorageProvider[] = ['s3', 'minio', 'r2', 'gcs', 'spaces'];
- expect(providers).toHaveLength(5);
- });
-
- it('BucketAccessType includes all access types', () => {
- const types: BucketAccessType[] = ['public', 'private', 'temp'];
- expect(types).toHaveLength(3);
- });
-
- it('StorageConnectionConfig has required fields', () => {
- const config: StorageConnectionConfig = {
- provider: 'minio',
- region: 'us-east-1',
- endpoint: 'http://minio:9000',
- accessKeyId: 'key',
- secretAccessKey: 'secret',
- };
-
- expect(config.provider).toBe('minio');
- expect(config.region).toBe('us-east-1');
- expect(config.endpoint).toBe('http://minio:9000');
- });
-
- it('ProvisionResult has all expected fields', () => {
- const result: ProvisionResult = {
- bucketName: 'test',
- accessType: 'private',
- endpoint: null,
- provider: 's3',
- region: 'us-east-1',
- publicUrlPrefix: null,
- blockPublicAccess: true,
- versioning: false,
- corsRules: [],
- lifecycleRules: [],
+ bucketId: 'bucket-123',
+ bucketKey: 'public',
+ physicalName: null,
+ jobId: 'job-123',
};
- expect(result.blockPublicAccess).toBe(true);
- expect(result.corsRules).toEqual([]);
+ expect(payload.physicalName).toBeNull();
+ expect(payload.jobId).toBe('job-123');
});
});
diff --git a/graphile/graphile-bucket-provisioner-plugin/src/index.ts b/graphile/graphile-bucket-provisioner-plugin/src/index.ts
index 6541939a94..ede59b325e 100644
--- a/graphile/graphile-bucket-provisioner-plugin/src/index.ts
+++ b/graphile/graphile-bucket-provisioner-plugin/src/index.ts
@@ -1,46 +1,13 @@
/**
* Bucket Provisioner Plugin for PostGraphile v5
*
- * Provides an explicit `provisionBucket` mutation for PostGraphile v5.
- *
- * @example
- * ```typescript
- * import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin';
- * import { getEnvOptions } from '@constructive-io/graphql-env';
- *
- * // Use a lazy getter so env vars are read at runtime, not import time
- * function getConnection() {
- * const { cdn } = getEnvOptions();
- * return {
- * provider: cdn?.provider || 'minio',
- * region: cdn?.awsRegion || 'us-east-1',
- * endpoint: cdn?.endpoint || 'http://minio:9000',
- * accessKeyId: cdn?.awsAccessKey!,
- * secretAccessKey: cdn?.awsSecretKey!,
- * };
- * }
- *
- * const preset = {
- * extends: [
- * BucketProvisionerPreset({
- * connection: getConnection, // pass function ref, NOT getConnection()
- * allowedOrigins: ['https://app.example.com'],
- * }),
- * ],
- * };
- * ```
+ * Provides an explicit `provisionBucket` reconciliation enqueue mutation for
+ * PostGraphile v5.
*/
export { BucketProvisionerPlugin, createBucketProvisionerPlugin } from './plugin';
export { BucketProvisionerPreset } from './preset';
export type {
- BucketAccessType,
- BucketNameResolver,
- BucketProvisionerPluginOptions,
- ConnectionConfigOrGetter,
ProvisionBucketInput,
ProvisionBucketPayload,
- ProvisionResult,
- StorageConnectionConfig,
- StorageProvider,
} from './types';
diff --git a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts
index 03e435bcd5..16714cf632 100644
--- a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts
+++ b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts
@@ -3,13 +3,9 @@
*
* Adds S3 bucket provisioning support to PostGraphile v5:
*
- * 1. `provisionBucket` mutation — explicitly provision an S3 bucket for a
+ * 1. `provisionBucket` mutation — explicitly enqueue reconciliation for a
* logical bucket row in the database. Reads the bucket config via RLS,
- * then calls BucketProvisioner to create and configure the S3 bucket.
- *
- * This plugin uses `@constructive-io/bucket-provisioner` for the actual
- * S3 operations (bucket creation, Block Public Access, CORS, policies,
- * versioning, lifecycle rules).
+ * then queues the same storage reconciler job used by the INSERT trigger.
*
* Detection: Uses the `@storageBuckets` smart tag on the codec (table).
* The storage module generator in constructive-db sets this tag on the
@@ -17,23 +13,11 @@
* COMMENT ON TABLE buckets IS E'@storageBuckets\nStorage buckets table';
*/
-import type { ProvisionResult,StorageConnectionConfig } from '@constructive-io/bucket-provisioner';
-import {
- BucketProvisioner,
-} from '@constructive-io/bucket-provisioner';
-import { Logger } from '@pgpmjs/logger';
import { QuoteUtils } from '@pgsql/quotes';
import { context as grafastContext, lambda, object } from 'grafast';
import type { GraphileConfig } from 'graphile-config';
-import { recordPhysicalName as recordPhysicalBucketName } from 'graphile-storage-registry';
import { extendSchema, gql } from 'graphile-utils';
-import type {
- BucketProvisionerPluginOptions,
-} from './types';
-
-const log = new Logger('graphile-bucket-provisioner:plugin');
-
// --- Storage module queries ---
/**
@@ -43,7 +27,10 @@ const log = new Logger('graphile-bucket-provisioner:plugin');
const ALL_STORAGE_MODULES_QUERY = `
SELECT
sm.id,
+ sm.database_id,
+ sm.buckets_table_id,
sm.scope,
+ sm.entity_field,
sm.entity_table_id,
bs.schema_name AS buckets_schema,
bt.name AS buckets_table,
@@ -63,7 +50,10 @@ const ALL_STORAGE_MODULES_QUERY = `
interface StorageModuleRow {
id: string;
+ database_id: string;
+ buckets_table_id: string;
scope: string;
+ entity_field: string | null;
entity_table_id: string | null;
buckets_schema: string;
buckets_table: string;
@@ -91,6 +81,21 @@ function runQuery(
return pgClient.query(values === undefined ? { text } : { text, values });
}
+function scopeKeySelect(storageModule: StorageModuleRow): string {
+ return storageModule.entity_field === null
+ ? ''
+ : `, ${QuoteUtils.quoteIdentifier(storageModule.entity_field)} AS scope_key`;
+}
+
+function scopeKeyColumn(storageModule: StorageModuleRow): string {
+ if (storageModule.entity_field === null) {
+ throw new Error(
+ `STORAGE_BUCKET_SCOPE_KEY_MISSING: storage module ${storageModule.id} has no entity field`,
+ );
+ }
+ return QuoteUtils.quoteIdentifier(storageModule.entity_field);
+}
+
/**
* The explicit provisionBucket mutation's resolution: find the plane that
* actually holds the named bucket row.
@@ -130,9 +135,9 @@ async function resolveBucketByKey(
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
const bucketResult = await runQuery(
pgClient,
- `SELECT id, key, type, is_public, allowed_origins, physical_name
+ `SELECT id, key, type, is_public, allowed_origins, physical_name${scopeKeySelect(mod)}
FROM ${bucketsTable}
- WHERE key = $1 AND owner_id = $2
+ WHERE key = $1 AND ${scopeKeyColumn(mod)} = $2
LIMIT 1`,
[bucketKey, ownerId],
);
@@ -147,7 +152,7 @@ async function resolveBucketByKey(
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
const bucketResult = await runQuery(
pgClient,
- `SELECT id, key, type, is_public, allowed_origins, physical_name
+ `SELECT id, key, type, is_public, allowed_origins, physical_name${scopeKeySelect(mod)}
FROM ${bucketsTable}
WHERE key = $1
LIMIT 1`,
@@ -172,61 +177,12 @@ async function resolveBucketByKey(
interface BucketRow {
id: string;
key: string;
- type: string;
- is_public: boolean;
- allowed_origins: string[] | null;
physical_name: string | null;
-}
-
-/**
- * Normalize the recorded physical coordinate at the DB boundary.
- *
- * A bucket row either carries a recorded coordinate or it does not; SQL nulls
- * and absent columns both mean "never provisioned". Collapsing them here is
- * the single place that shape is interpreted — callers branch on `string`
- * vs `null` and never coalesce a bucket name into existence.
- */
-function storedPhysicalName(row: Pick): string | null {
- return row.physical_name == null ? null : row.physical_name;
+ scope_key: string | null;
}
// --- Helpers ---
-/**
- * Resolve the connection config from the options. If the option is a lazy
- * getter function, call it (and cache the result).
- */
-function resolveConnection(
- options: BucketProvisionerPluginOptions,
-): StorageConnectionConfig {
- if (typeof options.connection === 'function') {
- const resolved = options.connection();
- // Cache so subsequent calls don't re-evaluate
- options.connection = resolved;
- return resolved;
- }
- return options.connection;
-}
-
-/**
- * Resolve the S3 bucket name from a logical bucket key.
- */
-function resolveBucketName(
- databaseId: string,
- bucketKey: string,
- options: BucketProvisionerPluginOptions,
-): string {
- if (!options.resolveBucketName) {
- throw new Error(
- 'STORAGE_BUCKET_NAME_POLICY_MISSING: no resolveBucketName was configured, so there is ' +
- `no name to provision for bucket "${bucketKey}" of database ${databaseId}. ` +
- 'Physical bucket naming is a deployment policy; the configured s3.bucket is a ' +
- 'connection default and is never a tenant bucket.',
- );
- }
- return options.resolveBucketName(databaseId, bucketKey);
-}
-
/**
* Resolve the database_id from the JWT context.
*/
@@ -238,91 +194,111 @@ async function resolveDatabaseId(pgClient: any): Promise {
return result.rows[0]?.id ?? null;
}
-/**
- * Resolve the effective CORS allowed origins using the 3-tier hierarchy:
- * 1. Bucket-level allowed_origins (per-bucket override)
- * 2. Storage-module-level allowed_origins (per-database default)
- * 3. Plugin config allowedOrigins (global fallback)
- */
-function resolveAllowedOrigins(
- bucketOrigins: string[] | null | undefined,
- storageModuleOrigins: string[] | null | undefined,
- pluginOrigins: string[],
-): string[] {
- if (bucketOrigins && bucketOrigins.length > 0) {
- return bucketOrigins;
- }
- if (storageModuleOrigins && storageModuleOrigins.length > 0) {
- return storageModuleOrigins;
- }
- return pluginOrigins;
-}
-
-/**
- * Build a BucketProvisioner with per-database connection overrides.
- */
-function buildProvisioner(
- options: BucketProvisionerPluginOptions,
- storageModule: StorageModuleRow | null,
- effectiveOrigins: string[],
-): BucketProvisioner {
- const connection = resolveConnection(options);
- const effectiveConnection: StorageConnectionConfig = {
- ...connection,
- ...(storageModule?.endpoint ? { endpoint: storageModule.endpoint } : {}),
- ...(storageModule?.provider
- ? { provider: storageModule.provider as StorageConnectionConfig['provider'] }
- : {}),
+async function resolveEntityContext(
+ pgClient: any,
+ storageModule: StorageModuleRow,
+): Promise<{ entity_type: string | null; get_org_fn_schema: string | null; get_org_fn: string | null }> {
+ const result = await runQuery(
+ pgClient,
+ `SELECT r.entity_type, r.get_org_fn_schema, r.get_org_fn
+ FROM metaschema.resolve_entity_context_by_field($1::uuid, $2::uuid, $3::text) r`,
+ [storageModule.database_id, storageModule.buckets_table_id, storageModule.entity_field],
+ );
+ return result.rows[0] ?? {
+ entity_type: null,
+ get_org_fn_schema: null,
+ get_org_fn: null,
};
-
- return new BucketProvisioner({
- connection: effectiveConnection,
- allowedOrigins: effectiveOrigins,
- });
}
/**
- * Core provisioning logic for the explicit mutation.
+ * Mirror the storage module generator's data_job_trigger shape from
+ * `packages/metaschema-generators/deploy/schemas/metaschema_generators/procedures/storage_module.sql`
+ * and the add_job argument assembly in
+ * `packages/ast-plpgsql/deploy/schemas/ast_plpgsql_helpers/procedures/triggers/job_trigger.sql`.
+ * A callable enqueue function beside the trigger would be the durable fix for
+ * this deliberate mirroring, but is out of scope here.
*/
-async function provisionBucketForRow(
+async function enqueueReconciliationJob(
+ pgClient: any,
storageModule: StorageModuleRow,
- databaseId: string,
- bucketKey: string,
- bucketType: string,
- bucketAllowedOrigins: string[] | null | undefined,
- options: BucketProvisionerPluginOptions,
- s3BucketName: string,
-): Promise {
- const accessType = bucketType as 'public' | 'private' | 'temp';
-
- // Resolve CORS origins using the 3-tier hierarchy
- const effectiveOrigins = resolveAllowedOrigins(
- bucketAllowedOrigins,
- storageModule?.allowed_origins,
- options.allowedOrigins,
- );
-
- const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
-
- log.info(
- `Provisioning S3 bucket "${s3BucketName}" (key="${bucketKey}", type="${accessType}", ` +
- `origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`,
- );
-
- const result = await provisioner.provision({
- bucketName: s3BucketName,
- accessType,
- versioning: options.versioning ?? false,
- publicUrlPrefix: storageModule?.public_url_prefix ?? undefined,
- allowedOrigins: effectiveOrigins,
- });
-
- log.info(
- `Successfully provisioned S3 bucket "${s3BucketName}" ` +
- `(provider=${result.provider}, blockPublicAccess=${result.blockPublicAccess})`,
- );
+ bucket: BucketRow,
+): Promise {
+ const scope = storageModule.scope;
+ const entityField = storageModule.entity_field;
+
+ let text: string;
+ let values: unknown[];
+
+ if (entityField === null) {
+ text = `SELECT (app_jobs.add_job(
+ identifier => 'storage:provision_bucket',
+ payload => json_build_object(
+ 'id', $1::uuid,
+ 'scope', $2::text
+ ),
+ queue_name => 'bucket:' || $1::text,
+ max_attempts => 25,
+ priority => 0
+ )).id AS id`;
+ values = [bucket.id, scope];
+ } else if (entityField === 'database_id') {
+ const databaseId = bucket.scope_key;
+ if (!databaseId) {
+ throw new Error(`STORAGE_BUCKET_SCOPE_KEY_MISSING: bucket ${bucket.id} has no database_id`);
+ }
+ text = `SELECT (app_jobs.add_job(
+ identifier => 'storage:provision_bucket',
+ payload => json_build_object(
+ 'database_id', $2::uuid,
+ 'id', $1::uuid,
+ 'scope', $3::text
+ ),
+ db_id => $2,
+ queue_name => 'bucket:' || $1::text,
+ max_attempts => 25,
+ priority => 0,
+ entity_id => $2,
+ organization_id => NULL,
+ entity_type => $3
+ )).id AS id`;
+ values = [bucket.id, databaseId, scope];
+ } else if (entityField === 'owner_id') {
+ const ownerId = bucket.scope_key;
+ if (!ownerId) {
+ throw new Error(`STORAGE_BUCKET_SCOPE_KEY_MISSING: bucket ${bucket.id} has no owner_id`);
+ }
+ const context = await resolveEntityContext(pgClient, storageModule);
+ const orgFunction = context.get_org_fn_schema && context.get_org_fn
+ ? `${QuoteUtils.quoteQualifiedIdentifier(context.get_org_fn_schema, context.get_org_fn)}($3::text, $2::uuid)`
+ : 'NULL';
+ text = `SELECT (app_jobs.add_job(
+ identifier => 'storage:provision_bucket',
+ payload => json_build_object(
+ 'id', $1::uuid,
+ 'owner_id', $2::uuid,
+ 'scope', $3::text
+ ),
+ queue_name => 'bucket:' || $1::text,
+ max_attempts => 25,
+ priority => 0,
+ entity_id => $2,
+ organization_id => ${orgFunction},
+ entity_type => $3
+ )).id AS id`;
+ values = [bucket.id, ownerId, scope];
+ } else {
+ throw new Error(
+ `STORAGE_BUCKET_ENTITY_FIELD_UNSUPPORTED: ${entityField}`,
+ );
+ }
- return result;
+ const result = await runQuery(pgClient, text, values);
+ const jobId = result.rows[0]?.id;
+ if (!jobId) {
+ throw new Error(`STORAGE_BUCKET_JOB_ID_MISSING: bucket ${bucket.id}`);
+ }
+ return jobId;
}
// --- Plugin factory ---
@@ -330,17 +306,14 @@ async function provisionBucketForRow(
/**
* Creates the bucket provisioner plugin.
*
- * This plugin provides one provisioning pathway:
+ * This plugin provides one reconciliation pathway:
*
* 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
- * bucket key to provision (or re-provision) the S3 bucket. Protected
- * by RLS on the buckets table.
+ * bucket key to enqueue reconciliation (or re-reconciliation) for the
+ * bucket. Protected by RLS on the buckets table.
*
- * @param options - Plugin configuration (S3 credentials, CORS origins, naming)
*/
-export function createBucketProvisionerPlugin(
- options: BucketProvisionerPluginOptions,
-): GraphileConfig.Plugin {
+export function createBucketProvisionerPlugin(): GraphileConfig.Plugin {
// The extendSchema plugin adds the explicit provisionBucket mutation
const mutationPlugin = extendSchema(() => ({
typeDefs: gql`
@@ -355,26 +328,22 @@ export function createBucketProvisionerPlugin(
}
type ProvisionBucketPayload {
- """Whether provisioning succeeded"""
- success: Boolean!
- """The S3 bucket name that was provisioned"""
- bucketName: String!
- """The access type applied"""
- accessType: String!
- """The storage provider used"""
- provider: String!
- """The S3 endpoint (null for AWS S3 default)"""
- endpoint: String
- """Error message if provisioning failed"""
- error: String
+ """The logical bucket row that was queued for reconciliation."""
+ bucketId: UUID!
+ bucketKey: String!
+ """The physical bucket name already recorded, or null when reconciliation has not completed."""
+ physicalName: String
+ """The reconciler job enqueued to provision this bucket."""
+ jobId: UUID!
}
extend type Mutation {
"""
- Provision an S3 bucket for a logical bucket in the database.
- Reads the bucket config via RLS, then creates and configures
- the S3 bucket with the appropriate privacy policies, CORS rules,
- and lifecycle settings.
+ Reconcile an S3 bucket for a logical bucket in the database.
+ Reads the bucket config via RLS, then enqueues the same
+ storage:provision_bucket job used by the INSERT trigger. This is
+ idempotent for an already-reconciled bucket; enqueue failures become
+ GraphQL errors.
"""
provisionBucket(
input: ProvisionBucketInput!
@@ -414,56 +383,14 @@ export function createBucketProvisionerPlugin(
throw new Error('BUCKET_NOT_FOUND');
}
const { storageModule, bucket } = resolution;
- const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
-
- // First provision mints a name; afterwards the stored coordinate
- // is authoritative and the naming hook is never consulted again.
- const recorded = storedPhysicalName(bucket);
- const s3BucketName = recorded === null
- ? resolveBucketName(databaseId, bucket.key, options)
- : recorded;
-
- try {
- const result = await provisionBucketForRow(
- storageModule,
- databaseId,
- bucket.key,
- bucket.type,
- bucket.allowed_origins,
- options,
- s3BucketName,
- );
-
- // Record the exact provisioned name on the source row.
- await withPgClient(null, (client: any) =>
- recordPhysicalBucketName(
- (query) => runQuery(client, query.text, query.values),
- bucketsTable,
- bucket.id,
- result.bucketName,
- ),
- );
- log.info(`Recorded physical_name="${result.bucketName}" on bucket ${bucket.id}`);
-
- return {
- success: true,
- bucketName: result.bucketName,
- accessType: result.accessType,
- provider: result.provider,
- endpoint: result.endpoint,
- error: null,
- };
- } catch (err: any) {
- log.error(`Failed to provision bucket "${bucketKey}": ${err.message}`);
- return {
- success: false,
- bucketName: s3BucketName,
- accessType: bucket.type,
- provider: resolveConnection(options).provider,
- endpoint: resolveConnection(options).endpoint ?? null,
- error: err.message,
- };
- }
+ const jobId = await enqueueReconciliationJob(pgClient, storageModule, bucket);
+
+ return {
+ bucketId: bucket.id,
+ bucketKey: bucket.key,
+ physicalName: bucket.physical_name,
+ jobId,
+ };
});
});
},
diff --git a/graphile/graphile-bucket-provisioner-plugin/src/preset.ts b/graphile/graphile-bucket-provisioner-plugin/src/preset.ts
index 446c447bad..89d01aea9c 100644
--- a/graphile/graphile-bucket-provisioner-plugin/src/preset.ts
+++ b/graphile/graphile-bucket-provisioner-plugin/src/preset.ts
@@ -8,43 +8,13 @@
import type { GraphileConfig } from 'graphile-config';
import { createBucketProvisionerPlugin } from './plugin';
-import type { BucketProvisionerPluginOptions } from './types';
/**
- * Creates a preset that includes the bucket provisioner plugin with the given options.
- *
- * @example
- * ```typescript
- * import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin';
- * import { getEnvOptions } from '@constructive-io/graphql-env';
- *
- * // Use a lazy getter so env vars are read at runtime, not import time
- * function getConnection() {
- * const { cdn } = getEnvOptions();
- * return {
- * provider: cdn?.provider || 'minio',
- * region: cdn?.awsRegion || 'us-east-1',
- * endpoint: cdn?.endpoint || 'http://minio:9000',
- * accessKeyId: cdn?.awsAccessKey!,
- * secretAccessKey: cdn?.awsSecretKey!,
- * };
- * }
- *
- * const preset = {
- * extends: [
- * BucketProvisionerPreset({
- * connection: getConnection, // pass function ref, NOT getConnection()
- * allowedOrigins: ['https://app.example.com'],
- * }),
- * ],
- * };
- * ```
+ * Creates a preset that includes the bucket reconciliation plugin.
*/
-export function BucketProvisionerPreset(
- options: BucketProvisionerPluginOptions,
-): GraphileConfig.Preset {
+export function BucketProvisionerPreset(): GraphileConfig.Preset {
return {
- plugins: [createBucketProvisionerPlugin(options)],
+ plugins: [createBucketProvisionerPlugin()],
};
}
diff --git a/graphile/graphile-bucket-provisioner-plugin/src/types.ts b/graphile/graphile-bucket-provisioner-plugin/src/types.ts
index 5761b5052c..92829b76cb 100644
--- a/graphile/graphile-bucket-provisioner-plugin/src/types.ts
+++ b/graphile/graphile-bucket-provisioner-plugin/src/types.ts
@@ -2,66 +2,6 @@
* Types for the bucket provisioner plugin.
*/
-import type {
- BucketAccessType,
- ProvisionResult,
- StorageConnectionConfig,
- StorageProvider,
-} from '@constructive-io/bucket-provisioner';
-
-// Re-export types that consumers will need
-export type { BucketAccessType, ProvisionResult,StorageConnectionConfig, StorageProvider };
-
-/**
- * S3 connection configuration or a lazy getter that returns it on first use.
- *
- * When a function is provided, it will only be called when the first
- * provisioning operation actually needs the S3 client — avoiding eager
- * env-var reads and S3Client creation at module import time.
- */
-export type ConnectionConfigOrGetter =
- | StorageConnectionConfig
- | (() => StorageConnectionConfig);
-
-/**
- * Function to derive the actual S3 bucket name from a logical bucket key.
- *
- * @param databaseId - The metaschema database UUID
- * @param bucketKey - The logical bucket key from the database (e.g., "public", "private")
- * @returns The S3 bucket name to create/configure
- */
-export type BucketNameResolver = (databaseId: string, bucketKey: string) => string;
-
-/**
- * Plugin options for the bucket provisioner plugin.
- */
-export interface BucketProvisionerPluginOptions {
- /**
- * S3 connection configuration (credentials, endpoint, provider).
- * Can be a concrete object or a lazy getter function.
- */
- connection: ConnectionConfigOrGetter;
-
- /**
- * Allowed origins for CORS rules on provisioned buckets.
- * These are the domains where your app runs (e.g., ["https://app.example.com"]).
- * Required for browser-based presigned URL uploads.
- */
- allowedOrigins: string[];
-
- /**
- * Optional custom function to derive S3 bucket names from logical bucket keys.
- * Naming is a deployment policy and must be supplied by the caller.
- */
- resolveBucketName?: BucketNameResolver;
-
- /**
- * Whether to enable versioning on provisioned buckets.
- * Default: false
- */
- versioning?: boolean;
-}
-
/**
* Input for the provisionBucket mutation.
*/
@@ -76,19 +16,15 @@ export interface ProvisionBucketInput {
}
/**
- * Result of the provisionBucket mutation.
+ * Result of the provisionBucket reconciliation enqueue mutation.
*/
export interface ProvisionBucketPayload {
- /** Whether provisioning succeeded */
- success: boolean;
- /** The S3 bucket name that was provisioned */
- bucketName: string;
- /** The access type applied */
- accessType: string;
- /** The storage provider used */
- provider: string;
- /** The S3 endpoint (null for AWS S3 default) */
- endpoint: string | null;
- /** Error message if provisioning failed */
- error: string | null;
+ /** The logical bucket row queued for reconciliation */
+ bucketId: string;
+ /** The logical bucket key */
+ bucketKey: string;
+ /** The physical name already recorded, or null while reconciliation is pending */
+ physicalName: string | null;
+ /** The queued reconciler job */
+ jobId: string;
}
diff --git a/graphile/graphile-presigned-url-plugin/__tests__/managed-upload.test.ts b/graphile/graphile-presigned-url-plugin/__tests__/managed-upload.test.ts
index 56578087f1..e7a011d85e 100644
--- a/graphile/graphile-presigned-url-plugin/__tests__/managed-upload.test.ts
+++ b/graphile/graphile-presigned-url-plugin/__tests__/managed-upload.test.ts
@@ -117,7 +117,6 @@ function options(): PresignedUrlPluginOptions {
region: 'us-east-1',
publicUrlPrefix: 'https://cdn.example.com',
},
- resolveBucketName: (databaseId: string, bucketKey: string) => `myapp-${bucketKey}-${databaseId}`,
};
}
@@ -265,33 +264,32 @@ describe('resolveManagedUploadTarget', () => {
).rejects.toThrow('BUCKET_PATH_KEYED');
});
- it('records the physical name on first provision instead of re-minting it', async () => {
+ it('rejects an unreconciled bucket without calling S3 or provisioning', async () => {
const { resolveManagedUploadTarget } = await import('../src/managed-upload');
- const ensureBucketProvisioned = jest.fn().mockResolvedValue(undefined);
+ const send = jest.fn();
+ const baseS3 = options().s3 as S3Config;
const db = fakeDb([
SET_CONFIG,
NO_REGISTRY_ROW,
STORAGE_MODULES,
{ match: /resolve_default_bucket/, rows: () => [{ bucket_id: BUCKET_ID, resolved_key: 'default-public', bucket_type: 'public', physical_name: null }] },
{ match: /SELECT id, key, type/, rows: () => [bucketRow({ physical_name: null })] },
- { match: /UPDATE storage_public\.app_buckets/, rows: () => [] },
]);
- const target = await resolveManagedUploadTarget({
- options: { ...options(), ensureBucketProvisioned },
+ await expect(resolveManagedUploadTarget({
+ options: {
+ ...options(),
+ s3: { ...baseS3, client: { send } as any },
+ },
withPgClient: db.withPgClient,
pgSettings: null,
databaseId: DATABASE_ID,
field: FIELD,
defaultPublicAccess: true,
- });
+ })).rejects.toThrow('STORAGE_BUCKET_NOT_RECONCILED');
- expect(target.physicalName).toBe(`myapp-default-public-${DATABASE_ID}`);
- expect(ensureBucketProvisioned).toHaveBeenCalledWith(
- `myapp-default-public-${DATABASE_ID}`, 'public', DATABASE_ID, null,
- );
- const update = db.queries.find((q) => /UPDATE/.test(q.text));
- expect(update?.values).toEqual([`myapp-default-public-${DATABASE_ID}`, BUCKET_ID]);
+ expect(send).not.toHaveBeenCalled();
+ expect(db.queries.some((q) => /UPDATE/.test(q.text))).toBe(false);
});
it('raises when the database has no storage module to default to', async () => {
diff --git a/graphile/graphile-presigned-url-plugin/__tests__/s3-failure.test.ts b/graphile/graphile-presigned-url-plugin/__tests__/s3-failure.test.ts
index 1fb4c8e620..d9c7485d24 100644
--- a/graphile/graphile-presigned-url-plugin/__tests__/s3-failure.test.ts
+++ b/graphile/graphile-presigned-url-plugin/__tests__/s3-failure.test.ts
@@ -9,11 +9,13 @@
* reachable as `cause`.
*/
-import { provisionAndRecordPhysicalBucket } from '../src/physical-bucket';
+import {
+ assertBucketReconciled,
+ StorageBucketNotReconciledError,
+} from '../src/physical-bucket';
import { describeS3Failure, s3FailureError } from '../src/s3-failure';
import { generatePresignedPutUrl } from '../src/s3-signer';
-import { clearStorageModuleCache } from '../src/storage-module-cache';
-import type { BucketConfig, PresignedUrlPluginOptions, StorageModuleConfig } from '../src/types';
+import type { BucketConfig } from '../src/types';
/** An unreachable endpoint, as undici surfaces it: no message of its own. */
function connectionRefused(): AggregateError {
@@ -113,36 +115,32 @@ describe('the upload lane', () => {
).rejects.toThrow(/PRESIGN_PUT_FAILED.*endpoint=http:\/\/localhost:9000/s);
});
- it('reports an unreachable object store when provisioning a bucket, naming the endpoint', async () => {
- clearStorageModuleCache();
- const cause = connectionRefused();
- const options: PresignedUrlPluginOptions = {
- s3: { client: {} as any, bucket: 'connection-default', endpoint: 'http://localhost:9000' },
- resolveBucketName: () => 'app-public-abc',
- ensureBucketProvisioned: () => Promise.reject(cause),
- };
- const withPgClient = jest.fn();
+ it('reports an unreconciled bucket with a retryable typed error', () => {
+ const bucket = {
+ id: 'bucket-1',
+ key: 'public',
+ physical_name: null,
+ } as BucketConfig;
+
+ expect(() => assertBucketReconciled(
+ bucket,
+ '00000000-0000-0000-0000-0000000000db',
+ )).toThrow('STORAGE_BUCKET_NOT_RECONCILED');
- let thrown: any;
try {
- await provisionAndRecordPhysicalBucket(
- options,
- withPgClient as any,
- { bucketsQualifiedName: 'app_public.buckets' } as StorageModuleConfig,
- '00000000-0000-0000-0000-0000000000db',
- { id: 'bucket-1', key: 'public', type: 'public', physical_name: null } as BucketConfig,
- null,
- );
- } catch (err) {
- thrown = err;
+ assertBucketReconciled(bucket, '00000000-0000-0000-0000-0000000000db');
+ } catch (err: any) {
+ expect(err).toBeInstanceOf(StorageBucketNotReconciledError);
+ expect(err.code).toBe('STORAGE_BUCKET_NOT_RECONCILED');
+ expect(err.retryable).toBe(true);
+ expect(err.extensions).toEqual({
+ code: 'STORAGE_BUCKET_NOT_RECONCILED',
+ retryable: true,
+ });
+ expect(err.message).toContain('public');
+ expect(err.message).toContain('bucket-1');
+ expect(err.message).toContain('00000000-0000-0000-0000-0000000000db');
+ expect(err.message).toContain('reconciler has not yet recorded a physical name');
}
-
- expect(thrown?.message).toContain('BUCKET_PROVISION_FAILED');
- expect(thrown?.message).toContain('endpoint=http://localhost:9000');
- expect(thrown?.message).toContain('bucket=app-public-abc');
- expect(thrown?.message).toContain('ECONNREFUSED');
- expect(thrown?.cause).toBe(cause);
- // A failed provision must not record a physical name.
- expect(withPgClient).not.toHaveBeenCalled();
});
});
diff --git a/graphile/graphile-presigned-url-plugin/src/index.ts b/graphile/graphile-presigned-url-plugin/src/index.ts
index dcb4dde5d5..d4c1963c5d 100644
--- a/graphile/graphile-presigned-url-plugin/src/index.ts
+++ b/graphile/graphile-presigned-url-plugin/src/index.ts
@@ -47,17 +47,20 @@ export {
type ManagedUploadTarget,
resolveManagedUploadTarget,
} from './managed-upload';
-export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
+export {
+ assertBucketReconciled,
+ resolveS3,
+ resolveS3ForDatabase,
+ StorageBucketNotReconciledError,
+} from './physical-bucket';
export { createPresignedUrlPlugin,PresignedUrlPlugin } from './plugin';
export { PresignedUrlPreset } from './preset';
export { type WithPgClient, withRequestPgClient } from './request-pg-client';
export { describeS3Failure, s3FailureError } from './s3-failure';
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
-export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned,resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
+export { clearBucketCache, clearStorageModuleCache, getBucketConfig, loadAllStorageModules,resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
export type {
BucketConfig,
- BucketNameResolver,
- EnsureBucketProvisioned,
PresignedUrlPluginOptions,
RequestUploadUrlInput,
RequestUploadUrlPayload,
diff --git a/graphile/graphile-presigned-url-plugin/src/managed-upload.ts b/graphile/graphile-presigned-url-plugin/src/managed-upload.ts
index 659a403236..25dbb318c1 100644
--- a/graphile/graphile-presigned-url-plugin/src/managed-upload.ts
+++ b/graphile/graphile-presigned-url-plugin/src/managed-upload.ts
@@ -23,7 +23,7 @@ import { Logger } from '@pgpmjs/logger';
import { resolveDefaultBucket } from './default-bucket';
import { isLiveFileRow, statusSelectFragment } from './file-lifecycle';
import { type FileRefFieldBinding, getFileRefFieldBinding } from './file-ref-registry';
-import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
+import { assertBucketReconciled, resolveS3ForDatabase } from './physical-bucket';
import { type WithPgClient, withRequestPgClient } from './request-pg-client';
import { copyS3Object, deleteS3Object } from './s3-signer';
import { recordManagedFile } from './storage-file-recorder';
@@ -212,11 +212,7 @@ export async function resolveManagedUploadTarget(args: {
);
}
- const physicalName = bucket.physical_name === null
- ? await provisionAndRecordPhysicalBucket(
- options, withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins,
- )
- : bucket.physical_name;
+ const physicalName = assertBucketReconciled(bucket, databaseId);
return {
databaseId,
diff --git a/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts b/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts
index cf04907ee0..9778ef73e8 100644
--- a/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts
+++ b/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts
@@ -1,23 +1,31 @@
/**
- * Physical bucket coordinates: minting a name once, recording it, and building
- * an S3 config against a *known* name.
+ * Physical bucket coordinates: reading the reconciler's recorded name and
+ * building an S3 config against that known name.
*
* A logical bucket belongs to a tenant; a physical bucket is an S3 name. The
- * mapping is recorded on the bucket row the first time it is provisioned, and
- * from then on the recorded value is the only coordinate anything reads — no
- * name is ever recomputed from a prefix convention, and there is no
- * environment-level bucket standing in for a tenant's.
+ * mapping is recorded on the bucket row by the storage reconciler, and that
+ * value is the only coordinate anything reads — no name is ever recomputed.
*/
-import { Logger } from '@pgpmjs/logger';
-import { recordPhysicalName } from 'graphile-storage-registry';
-
-import { type WithPgClient, withRequestPgClient } from './request-pg-client';
-import { s3FailureError } from './s3-failure';
-import { isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
import type { BucketConfig, PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
-const log = new Logger('graphile-presigned-url:physical-bucket');
+export class StorageBucketNotReconciledError extends Error {
+ readonly code = 'STORAGE_BUCKET_NOT_RECONCILED';
+ readonly retryable = true;
+ readonly extensions = {
+ code: 'STORAGE_BUCKET_NOT_RECONCILED',
+ retryable: true,
+ };
+
+ constructor(bucket: BucketConfig, databaseId: string) {
+ super(
+ `STORAGE_BUCKET_NOT_RECONCILED: bucket "${bucket.key}" (id=${bucket.id}) ` +
+ `for database ${databaseId} has not yet been reconciled; the reconciler has ` +
+ 'not yet recorded a physical name',
+ );
+ this.name = 'StorageBucketNotReconciledError';
+ }
+}
/**
* Resolve the plugin's S3 connection (credentials, endpoint, region), memoizing
@@ -36,38 +44,11 @@ export function resolveS3(options: PresignedUrlPluginOptions): S3Config {
return options.s3;
}
-/**
- * Mint the physical S3 bucket name for a logical bucket's first provision.
- *
- * This is a naming *policy*, consulted exactly once per bucket — before the
- * physical bucket exists. Once provisioned, the recorded `physical_name` on the
- * row is authoritative and this function must not be consulted again.
- *
- * There is no fallback to the configured `s3.bucket`: a deployment-wide bucket
- * name is not a tenant's storage, and silently minting one is how objects ended
- * up in a bucket no database owned. A deployment that wants per-tenant buckets
- * must supply the policy.
- */
-export function mintPhysicalBucketName(
- options: PresignedUrlPluginOptions,
- databaseId: string,
- bucketKey: string,
-): string {
- if (!options.resolveBucketName) {
- throw new Error(
- 'STORAGE_BUCKET_NAME_POLICY_MISSING: no resolveBucketName was configured, so there is ' +
- `no name to provision for bucket "${bucketKey}" of database ${databaseId}. ` +
- 'Physical bucket naming is a deployment policy; the configured s3.bucket is a ' +
- 'connection default and is never a tenant bucket.',
- );
- }
- return options.resolveBucketName(databaseId, bucketKey);
-}
/**
* Build the S3 config for a *known* physical bucket. `physicalName` is
- * required — callers must resolve the coordinate (stored row value, or a
- * freshly provisioned name) before getting here. No name is ever recomputed.
+ * required — callers must resolve the coordinate from the stored row value
+ * before getting here. No name is ever recomputed.
*/
export function resolveS3ForDatabase(
options: PresignedUrlPluginOptions,
@@ -91,68 +72,11 @@ export function resolveS3ForDatabase(
}
/**
- * First provision of a logical bucket: mint a name, create the physical S3
- * bucket, and record the exact name on the source row. Returns the recorded
- * physical name.
- *
- * Only called when the row has no `physical_name` yet. Afterwards the stored
- * value is the durable coordinate: route resolution and every later read use
- * it verbatim; nothing is recomputed.
- *
- * The record write runs in the system lane (privileged role, so it bypasses the
- * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
- * bookkeeping, not request data. It still carries the tenant `database_id`
- * claim, because the buckets table's catalog-sync trigger calls
- * `jwt_private.current_database_id()` and would otherwise raise
- * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
- * write's transaction without switching off the privileged role.
- * `bucket` (the cached config) is mutated in place so subsequent reads observe
- * the recorded name without a DB round-trip.
+ * Return the reconciler's recorded physical name, or fail with a typed,
+ * retryable error while reconciliation is still pending.
*/
-export async function provisionAndRecordPhysicalBucket(
- options: PresignedUrlPluginOptions,
- withPgClient: WithPgClient,
- storageConfig: StorageModuleConfig,
- databaseId: string,
- bucket: BucketConfig,
- allowedOrigins: string[] | null,
-): Promise {
- const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
-
- if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) {
- log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
- try {
- await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
- } catch (err) {
- // The first upload to a bucket is where an unreachable object store is
- // discovered, and the transport's own message is routinely empty: name the
- // endpoint it could not reach so the response says what is misconfigured.
- throw s3FailureError(
- 'BUCKET_PROVISION_FAILED',
- { endpoint: resolveS3(options).endpoint, bucket: s3BucketName, databaseId },
- err,
- );
- }
- markS3BucketProvisioned(s3BucketName);
- log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
- }
+export function assertBucketReconciled(bucket: BucketConfig, databaseId: string): string {
+ if (bucket.physical_name !== null) return bucket.physical_name;
- // Record the physical coordinate on the source row. The `physical_name IS NULL`
- // guard keeps this idempotent and race-safe across concurrent first uploads.
- // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
- // write runs under the resolved database claim (privileged role preserved).
- await withRequestPgClient(
- withPgClient,
- { 'jwt.claims.database_id': databaseId },
- (client) =>
- recordPhysicalName(
- (query) => client.query(query),
- storageConfig.bucketsQualifiedName,
- bucket.id,
- s3BucketName,
- ),
- );
- bucket.physical_name = s3BucketName;
- log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
- return s3BucketName;
+ throw new StorageBucketNotReconciledError(bucket, databaseId);
}
diff --git a/graphile/graphile-presigned-url-plugin/src/plugin.ts b/graphile/graphile-presigned-url-plugin/src/plugin.ts
index 8b846e102c..544a65295b 100644
--- a/graphile/graphile-presigned-url-plugin/src/plugin.ts
+++ b/graphile/graphile-presigned-url-plugin/src/plugin.ts
@@ -31,7 +31,7 @@ import { validateCustomKey } from './custom-key';
import { resolveDefaultBucket } from './default-bucket';
import { isLiveFileRow, statusSelectFragment } from './file-lifecycle';
import { buildFileProjection, type FileProjection } from './managed-upload';
-import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
+import { assertBucketReconciled, resolveS3ForDatabase } from './physical-bucket';
import { withRequestPgClient } from './request-pg-client';
import { deleteS3Object,generatePresignedPutUrl } from './s3-signer';
import { recordManagedFile } from './storage-file-recorder';
@@ -272,11 +272,9 @@ export function createPresignedUrlPlugin(
);
if (!bucket) throw new Error('BUCKET_NOT_FOUND');
- // First provision mints + records the coordinate; afterwards the
- // stored physical_name is authoritative and nothing is recomputed.
- const physicalName = bucket.physical_name === null
- ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
- : bucket.physical_name;
+ // The reconciler records the coordinate; consumers never
+ // recompute it from the logical bucket row.
+ const physicalName = assertBucketReconciled(bucket, databaseId);
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
// File row INSERT under the request role (RLS enforced).
@@ -407,11 +405,9 @@ export function createPresignedUrlPlugin(
);
}
- // First provision mints + records the coordinate; afterwards the
- // stored physical_name is authoritative and nothing is recomputed.
- const physicalName = bucket.physical_name === null
- ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
- : bucket.physical_name;
+ // The reconciler records the coordinate; consumers never
+ // recompute it from the logical bucket row.
+ const physicalName = assertBucketReconciled(bucket, databaseId);
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
// File row INSERTs under the request role (RLS enforced).
diff --git a/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts b/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts
index 9c4c77542c..54e74b5422 100644
--- a/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts
+++ b/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts
@@ -347,38 +347,6 @@ export async function getBucketConfig(
return config;
}
-// --- S3 bucket existence cache ---
-
-/**
- * In-memory set of S3 bucket names that are known to exist.
- *
- * Used by the lazy provisioning logic in the presigned URL plugin:
- * before generating a presigned PUT URL, the plugin checks this set.
- * If the bucket name is absent, it calls `ensureBucketProvisioned`
- * to create the S3 bucket, then adds the name here. Subsequent
- * requests for the same bucket skip the provisioning entirely.
- *
- * No TTL needed — S3 buckets are never deleted during normal operation.
- * The set resets on server restart, which is fine because the
- * provisioner's createBucket is idempotent (handles "already exists").
- */
-const provisionedBuckets = new Set();
-
-/**
- * Check whether an S3 bucket has already been provisioned (cached).
- */
-export function isS3BucketProvisioned(s3BucketName: string): boolean {
- return provisionedBuckets.has(s3BucketName);
-}
-
-/**
- * Mark an S3 bucket as provisioned in the in-memory cache.
- */
-export function markS3BucketProvisioned(s3BucketName: string): void {
- provisionedBuckets.add(s3BucketName);
- log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
-}
-
/**
* Clear the storage module cache AND bucket cache.
* Useful for testing or schema changes.
@@ -386,7 +354,6 @@ export function markS3BucketProvisioned(s3BucketName: string): void {
export function clearStorageModuleCache(): void {
storageModuleCache.clear();
bucketCache.clear();
- provisionedBuckets.clear();
}
/**
diff --git a/graphile/graphile-presigned-url-plugin/src/types.ts b/graphile/graphile-presigned-url-plugin/src/types.ts
index c64d7436ce..510bda872e 100644
--- a/graphile/graphile-presigned-url-plugin/src/types.ts
+++ b/graphile/graphile-presigned-url-plugin/src/types.ts
@@ -13,10 +13,9 @@ export interface BucketConfig {
max_file_size: number | null;
allow_custom_keys: boolean;
/**
- * The physical S3/MinIO bucket name recorded when the physical bucket was
- * first provisioned. NULL until the first upload provisions it. Once set,
- * it is the source of truth for the physical bucket — reads never
- * reconstruct the name from a prefix convention.
+ * The physical S3/MinIO bucket name recorded by reconciliation. NULL until
+ * reconciliation completes. Once set, it is the source of truth for the
+ * physical bucket — reads never reconstruct the name.
*/
physical_name: string | null;
}
@@ -198,40 +197,6 @@ export interface S3Config {
*/
export type S3ConfigOrGetter = S3Config | (() => S3Config);
-/**
- * Function to derive the actual S3 bucket name for a given database and bucket key.
- *
- * When provided, the presigned URL plugin calls this on every request
- * to determine which S3 bucket to use — enabling per-(database, bucketKey)
- * isolation. If not provided, falls back to `s3Config.bucket` (global).
- *
- * @param databaseId - The metaschema database UUID
- * @param bucketKey - The logical bucket key (e.g., "public", "private")
- * @returns The S3 bucket name for this database + bucket key
- */
-export type BucketNameResolver = (databaseId: string, bucketKey: string) => string;
-
-/**
- * Callback to lazily provision an S3 bucket on first use.
- *
- * Called by the presigned URL plugin before generating a presigned PUT URL
- * when the bucket has not been seen before (tracked in an in-memory cache).
- * The implementation should create and fully configure the S3 bucket
- * (privacy policies, CORS, lifecycle rules, etc.) — or no-op if the
- * bucket already exists.
- *
- * @param bucketName - The S3 bucket name to provision
- * @param accessType - The logical bucket type ('public', 'private', 'temp')
- * @param databaseId - The metaschema database UUID
- * @param allowedOrigins - Per-database CORS origins (from storage_module), or null to use global fallback
- */
-export type EnsureBucketProvisioned = (
- bucketName: string,
- accessType: 'public' | 'private' | 'temp',
- databaseId: string,
- allowedOrigins: string[] | null,
-) => Promise;
-
/**
* Plugin options for the presigned URL plugin.
*/
@@ -239,19 +204,4 @@ export interface PresignedUrlPluginOptions {
/** S3 configuration (concrete or lazy getter) */
s3: S3ConfigOrGetter;
- /**
- * Optional function to resolve S3 bucket name per-database.
- * When set, each database gets its own S3 bucket instead of sharing
- * the global `s3Config.bucket`. The S3 credentials (client) remain shared.
- */
- resolveBucketName?: BucketNameResolver;
-
- /**
- * Optional callback to lazily provision an S3 bucket on first upload.
- * When set, the plugin calls this before generating a presigned PUT URL
- * for any S3 bucket it hasn't seen yet (tracked in an in-memory cache).
- * This enables graceful bucket creation without requiring buckets to
- * exist at database provisioning time.
- */
- ensureBucketProvisioned?: EnsureBucketProvisioned;
}
diff --git a/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts b/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts
deleted file mode 100644
index e9ba0d31b5..0000000000
--- a/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * Unit test: ConstructivePreset wires the tenant-aware bucket-name resolver
- * into BucketProvisionerPreset (not just into the presigned URL plugin).
- *
- * Regression guard for the bug where eager `provisionBucket` fell back to the
- * bare logical bucket key because the provisioner preset was left without a
- * resolveBucketName — diverging from the lazy first-upload path and risking
- * cross-tenant bucket-name collisions.
- */
-
-const captured: { bucketProvisionerOptions?: any } = {};
-
-// Capture the options handed to BucketProvisionerPreset without pulling in the
-// real plugin (and its S3 machinery).
-jest.mock('graphile-bucket-provisioner-plugin', () => ({
- BucketProvisionerPreset: jest.fn((options: any) => {
- captured.bucketProvisionerOptions = options;
- return { plugins: [] as any[] };
- }),
-}));
-
-// The preset reads CDN config eagerly when building the presigned/provisioner
-// plugin options; provide a prefix so name minting is deterministic.
-const PREFIX = 'test-bucket';
-jest.mock('@constructive-io/graphql-env', () => ({
- getEnvOptions: jest.fn(() => ({
- cdn: {
- bucketName: PREFIX,
- provider: 'minio',
- awsRegion: 'us-east-1',
- awsAccessKey: 'test',
- awsSecretKey: 'test',
- endpoint: 'http://localhost:9000',
- },
- })),
-}));
-
-import { createConstructivePreset } from '../src/presets/constructive-preset';
-
-const DATABASE_ID = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9';
-
-describe('ConstructivePreset bucket-provisioner wiring', () => {
- beforeEach(() => {
- captured.bucketProvisionerOptions = undefined;
- });
-
- it('passes a resolveBucketName into BucketProvisionerPreset when presigned uploads are enabled', () => {
- createConstructivePreset();
-
- const options = captured.bucketProvisionerOptions;
- expect(options).toBeDefined();
- expect(typeof options.resolveBucketName).toBe('function');
- });
-
- it('the wired resolver mints the tenant-aware {prefix}-{bucketKey}-{digest} name', () => {
- createConstructivePreset();
-
- const { resolveBucketName } = captured.bucketProvisionerOptions;
- // Both plugins use the signature: (databaseId, bucketKey)
- expect(resolveBucketName(DATABASE_ID, 'public')).toMatch(
- new RegExp(`^${PREFIX}-public-[a-f0-9]{12}$`),
- );
- expect(resolveBucketName(DATABASE_ID, 'private')).toMatch(
- new RegExp(`^${PREFIX}-private-[a-f0-9]{12}$`),
- );
- // The digest is what carries the tenant, so two databases cannot collide.
- expect(resolveBucketName(DATABASE_ID, 'public')).not.toBe(
- resolveBucketName('11111111-2222-3333-4444-555555555555', 'public'),
- );
- });
-
- it('does not wire the provisioner preset when presigned uploads are disabled', () => {
- createConstructivePreset({ enablePresignedUploads: false });
- expect(captured.bucketProvisionerOptions).toBeUndefined();
- });
-});
diff --git a/graphile/graphile-settings/__tests__/presigned-url-resolver.test.ts b/graphile/graphile-settings/__tests__/presigned-url-resolver.test.ts
index 4c148388b6..49ecd6b151 100644
--- a/graphile/graphile-settings/__tests__/presigned-url-resolver.test.ts
+++ b/graphile/graphile-settings/__tests__/presigned-url-resolver.test.ts
@@ -1,19 +1,15 @@
/**
- * Unit tests for the bucket-name resolvers.
- *
- * The presigned (lazy) upload path and the bucket-provisioner (eager) path must
- * mint the *same* physical S3 bucket name for a given (database, bucket key)
- * pair — `{prefix}-{bucketKey}-{digest}` — so a bucket's physical coordinate is
- * identical regardless of which path first provisions it.
- *
- * Both plugins consume the same resolver with the same argument order. The
- * remaining tests pin the properties S3 enforces on a bucket name: bounded
- * length, a restricted alphabet, and — because the name is truncated — a tail
- * that still separates identities the readable part can no longer distinguish.
+ * Unit tests for the connection-default S3 configuration.
*/
interface CdnOptions {
+ provider?: string;
bucketName?: string;
+ awsRegion?: string;
+ awsAccessKey?: string;
+ awsSecretKey?: string;
+ endpoint?: string;
+ publicUrlPrefix?: string;
}
async function loadResolverModule(cdn: CdnOptions | undefined) {
@@ -22,74 +18,61 @@ async function loadResolverModule(cdn: CdnOptions | undefined) {
jest.doMock('@constructive-io/graphql-env', () => ({
getEnvOptions: jest.fn(() => ({ cdn })),
}));
+ jest.doMock('@constructive-io/s3-utils', () => ({
+ createS3Client: jest.fn(() => ({ send: jest.fn() })),
+ }));
+ jest.doMock('@pgpmjs/logger', () => ({
+ Logger: jest.fn().mockImplementation(() => ({ info: jest.fn() })),
+ }));
return import('../src/presigned-url-resolver');
}
-const PREFIX = 'test-bucket';
-const DATABASE_ID = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9';
-const S3_BUCKET_NAME = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
-
-describe('bucket-name resolvers', () => {
- it('presigned resolver mints {prefix}-{bucketKey}-{digest}', async () => {
- const { createBucketNameResolver } = await loadResolverModule({ bucketName: PREFIX });
- const resolve = createBucketNameResolver();
-
- // presigned plugin signature: (databaseId, bucketKey)
- expect(resolve(DATABASE_ID, 'public')).toMatch(/^test-bucket-public-[a-f0-9]{12}$/);
- expect(resolve(DATABASE_ID, 'private')).toMatch(/^test-bucket-private-[a-f0-9]{12}$/);
- });
-
- it('mints the identical name used by the bucket provisioner', async () => {
- const { createBucketNameResolver } = await loadResolverModule({ bucketName: PREFIX });
- const resolve = createBucketNameResolver();
-
- expect(resolve(DATABASE_ID, 'public')).toBe(resolve(DATABASE_ID, 'public'));
+const BASE_CDN: CdnOptions = {
+ provider: 'minio',
+ bucketName: 'connection-default',
+ awsRegion: 'us-east-1',
+ awsAccessKey: 'access',
+ awsSecretKey: 'secret',
+ endpoint: 'http://localhost:9000',
+ publicUrlPrefix: 'https://cdn.example.com',
+};
+
+describe('getPresignedUrlS3Config', () => {
+ it('returns the configured connection-default bucket', async () => {
+ const { getPresignedUrlS3Config } = await loadResolverModule(BASE_CDN);
+
+ expect(getPresignedUrlS3Config()).toEqual(expect.objectContaining({
+ bucket: 'connection-default',
+ region: 'us-east-1',
+ endpoint: 'http://localhost:9000',
+ publicUrlPrefix: 'https://cdn.example.com',
+ }));
});
- it('names are stable across calls and resolver instances', async () => {
- const { createBucketNameResolver } = await loadResolverModule({ bucketName: PREFIX });
+ it('caches the initialized S3 configuration', async () => {
+ const { getPresignedUrlS3Config } = await loadResolverModule(BASE_CDN);
- const first = createBucketNameResolver();
- const second = createBucketNameResolver();
-
- expect(first(DATABASE_ID, 'public')).toBe(first(DATABASE_ID, 'public'));
- expect(second(DATABASE_ID, 'public')).toBe(first(DATABASE_ID, 'public'));
+ expect(getPresignedUrlS3Config()).toBe(getPresignedUrlS3Config());
});
- it('stays inside S3 length and alphabet limits for oversized, mixed-case inputs', async () => {
- const { createBucketNameResolver } = await loadResolverModule({
- bucketName: 'Some_Very.Long CDN Prefix That Nobody Would Choose',
+ it('requires a CDN bucket name for the connection default', async () => {
+ const { getPresignedUrlS3Config } = await loadResolverModule({
+ ...BASE_CDN,
+ bucketName: undefined,
});
- const resolve = createBucketNameResolver();
-
- const name = resolve(DATABASE_ID, 'Marketing_Site/Assets — 2024'.repeat(5));
- expect(name.length).toBeLessThanOrEqual(63);
- expect(name.length).toBeGreaterThanOrEqual(3);
- expect(name).toMatch(S3_BUCKET_NAME);
+ expect(() => getPresignedUrlS3Config()).toThrow(/CDN_BUCKET_NAME/);
});
- it('separates identities that survive truncation identically', async () => {
- const { createBucketNameResolver } = await loadResolverModule({ bucketName: PREFIX });
- const resolve = createBucketNameResolver();
-
- const shared = 'a'.repeat(80);
- // Same truncated prefix, different full keys.
- expect(resolve(DATABASE_ID, `${shared}-one`)).not.toBe(resolve(DATABASE_ID, `${shared}-two`));
- // Same key, different tenant.
- expect(resolve(DATABASE_ID, 'public')).not.toBe(
- resolve('11111111-2222-3333-4444-555555555555', 'public'),
- );
- });
+ it('requires CDN configuration and credentials', async () => {
+ const missingConfig = await loadResolverModule(undefined);
+ expect(() => missingConfig.getPresignedUrlS3Config()).toThrow(/CDN config not found/);
- it('presigned resolver throws (no default bucket name) when the prefix is missing', async () => {
- const { createBucketNameResolver } = await loadResolverModule({});
- expect(() => createBucketNameResolver()).toThrow(/CDN_BUCKET_NAME/);
- });
-
- it('throws when CDN config is entirely absent', async () => {
- const { createBucketNameResolver } = await loadResolverModule(undefined);
- expect(() => createBucketNameResolver()).toThrow(/CDN_BUCKET_NAME/);
+ const missingCredentials = await loadResolverModule({
+ ...BASE_CDN,
+ awsAccessKey: undefined,
+ });
+ expect(() => missingCredentials.getPresignedUrlS3Config()).toThrow(/S3 credentials/);
});
});
diff --git a/graphile/graphile-settings/src/bucket-provisioner-resolver.ts b/graphile/graphile-settings/src/bucket-provisioner-resolver.ts
deleted file mode 100644
index 28107a0d6d..0000000000
--- a/graphile/graphile-settings/src/bucket-provisioner-resolver.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * Bucket provisioner resolver for the Constructive bucket provisioner plugin.
- *
- * Reads CDN/S3 configuration from the standard env system
- * (getEnvOptions -> pgpmDefaults + config files + env vars) and lazily
- * returns a StorageConnectionConfig on first use.
- *
- * Follows the same lazy-init pattern as presigned-url-resolver.ts.
- */
-
-import { getEnvOptions } from '@constructive-io/graphql-env';
-import { Logger } from '@pgpmjs/logger';
-import type { StorageConnectionConfig } from 'graphile-bucket-provisioner-plugin';
-
-const log = new Logger('bucket-provisioner-resolver');
-
-let connectionConfig: StorageConnectionConfig | null = null;
-
-/**
- * Lazily initialize and return the StorageConnectionConfig for the
- * bucket provisioner plugin.
- *
- * Reads CDN config on first call via getEnvOptions() (which already merges
- * pgpmDefaults -> config file -> env vars) and caches the result.
- * Same CDN config source as presigned-url-resolver.ts.
- */
-export function getBucketProvisionerConnection(): StorageConnectionConfig {
- if (connectionConfig) return connectionConfig;
-
- const { cdn } = getEnvOptions();
-
- if (!cdn) {
- throw new Error(
- '[bucket-provisioner-resolver] CDN config not found. ' +
- 'Ensure CDN environment variables (AWS_ACCESS_KEY, AWS_SECRET_KEY, etc.) ' +
- 'are set or that pgpmDefaults provides CDN fields.',
- );
- }
-
- const { provider, awsRegion, awsAccessKey, awsSecretKey, endpoint } = cdn;
-
- if (!awsAccessKey || !awsSecretKey) {
- throw new Error(
- '[bucket-provisioner-resolver] Missing S3 credentials. ' +
- 'Set AWS_ACCESS_KEY and AWS_SECRET_KEY environment variables.',
- );
- }
-
- log.info(
- `[bucket-provisioner-resolver] Initializing: provider=${provider} endpoint=${endpoint}`,
- );
-
- connectionConfig = {
- provider: (provider as StorageConnectionConfig['provider']) || 'minio',
- region: awsRegion || 'us-east-1',
- accessKeyId: awsAccessKey,
- secretAccessKey: awsSecretKey,
- ...(endpoint ? { endpoint, forcePathStyle: true } : {}),
- };
-
- return connectionConfig;
-}
diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts
index afa9154a82..44cdb45c6b 100644
--- a/graphile/graphile-settings/src/index.ts
+++ b/graphile/graphile-settings/src/index.ts
@@ -60,6 +60,3 @@ export { makePgService };
// Presigned URL utilities
export { getPresignedUrlS3Config } from './presigned-url-resolver';
-
-// Bucket provisioner utilities
-export { getBucketProvisionerConnection } from './bucket-provisioner-resolver';
diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts
index fcd08fd78a..bf15e4cb24 100644
--- a/graphile/graphile-settings/src/presets/constructive-preset.ts
+++ b/graphile/graphile-settings/src/presets/constructive-preset.ts
@@ -13,7 +13,6 @@ import { RealtimeSubscriptionsPreset } from 'graphile-realtime-subscriptions';
import { createMatchesOperatorFactory, createTrgmOperatorFactories,UnifiedSearchPreset } from 'graphile-search';
import { UploadPreset } from 'graphile-upload-plugin';
-import { getBucketProvisionerConnection } from '../bucket-provisioner-resolver';
import {
ConflictDetectorPreset,
EnableAllFilterColumnsPreset,
@@ -26,7 +25,7 @@ import {
PgTypeMappingsPreset,
RequiredInputPreset
} from '../plugins';
-import { createBucketNameResolver, createEnsureBucketProvisioned, getAllowedOrigins,getPresignedUrlS3Config } from '../presigned-url-resolver';
+import { getPresignedUrlS3Config } from '../presigned-url-resolver';
import { constructiveUploadFieldDefinitions } from '../upload-resolver';
/**
@@ -201,14 +200,8 @@ export function createConstructivePreset(
presets.push(
PresignedUrlPreset({
s3: getPresignedUrlS3Config,
- resolveBucketName: createBucketNameResolver(),
- ensureBucketProvisioned: createEnsureBucketProvisioned()
}),
- BucketProvisionerPreset({
- connection: getBucketProvisionerConnection,
- allowedOrigins: getAllowedOrigins(),
- resolveBucketName: createBucketNameResolver()
- })
+ BucketProvisionerPreset()
);
}
diff --git a/graphile/graphile-settings/src/presigned-url-resolver.ts b/graphile/graphile-settings/src/presigned-url-resolver.ts
index 821cb0156c..5e12f141e6 100644
--- a/graphile/graphile-settings/src/presigned-url-resolver.ts
+++ b/graphile/graphile-settings/src/presigned-url-resolver.ts
@@ -5,19 +5,13 @@
* (getEnvOptions → pgpmDefaults + config files + env vars) and lazily
* initializes an S3Client on first use.
*
- * Also provides a per-database bucket name resolver that derives the
- * S3 bucket name from the database UUID + a configurable prefix.
- *
* Follows the same lazy-init pattern as upload-resolver.ts.
*/
-import { BucketProvisioner, mintPhysicalBucketName } from '@constructive-io/bucket-provisioner';
import { getEnvOptions } from '@constructive-io/graphql-env';
import { createS3Client } from '@constructive-io/s3-utils';
import { Logger } from '@pgpmjs/logger';
-import type { BucketNameResolver, EnsureBucketProvisioned,S3Config } from 'graphile-presigned-url-plugin';
-
-import { getBucketProvisionerConnection } from './bucket-provisioner-resolver';
+import type { S3Config } from 'graphile-presigned-url-plugin';
const log = new Logger('presigned-url-resolver');
@@ -32,8 +26,8 @@ let s3Config: S3Config | null = null;
*
* NOTE: The `bucket` field here is only the connection's default and is never
* uploaded to. Every managed upload names its bucket explicitly, resolved from
- * the tenant's logical bucket row via `resolveBucketName`; there is no
- * environment-global upload bucket.
+ * the tenant's logical bucket row; there is no environment-global upload
+ * bucket.
*/
export function getPresignedUrlS3Config(): S3Config {
if (s3Config) return s3Config;
@@ -86,100 +80,3 @@ export function getPresignedUrlS3Config(): S3Config {
return s3Config;
}
-
-/**
- * Read the configured physical-bucket-name prefix (CDN_BUCKET_NAME).
- *
- * There is no default: a missing prefix throws, mirroring
- * getPresignedUrlS3Config, so an untenanted bucket name can never be minted.
- */
-function getBucketNamePrefix(): string {
- const { cdn } = getEnvOptions();
- const prefix = cdn?.bucketName;
-
- if (!prefix) {
- throw new Error(
- '[presigned-url-resolver] Missing CDN bucket name prefix. ' +
- 'Set CDN_BUCKET_NAME environment variable; there is no default bucket name.',
- );
- }
-
- return prefix;
-}
-
-/**
- * Create a per-(database, bucketKey) bucket name resolver for the presigned
- * URL plugin (argument order: `(databaseId, bucketKey)`).
- *
- * Uses CDN_BUCKET_NAME as a prefix. For each (database, bucketKey) pair, the
- * S3 bucket name becomes `{prefix}-{bucketKey}-{digest}`.
- *
- * This aligns with the bucket provisioner plugin which creates separate
- * S3 buckets per logical bucket key.
- */
-export function createBucketNameResolver(): BucketNameResolver {
- const prefix = getBucketNamePrefix();
- return (databaseId: string, bucketKey: string): string =>
- mintPhysicalBucketName(prefix, databaseId, bucketKey);
-}
-
-/**
- * Resolve CORS allowed origins from the env/config system.
- *
- * Reads SERVER_ORIGIN from the standard env hierarchy
- * (pgpmDefaults → config file → env vars) and wraps it in an array.
- * Falls back to ['http://localhost:3000'] for local development.
- */
-export function getAllowedOrigins(): string[] {
- const { server } = getEnvOptions();
- if (server?.origin) return [server.origin];
- return ['*'];
-}
-
-/**
- * Create a lazy bucket provisioner callback for the presigned URL plugin.
- *
- * On the first upload to an S3 bucket that doesn't exist yet, this callback
- * uses the BucketProvisioner to create and fully configure the bucket
- * (Block Public Access, CORS, policies, lifecycle rules for temp buckets).
- *
- * Uses the same S3 connection config as the bucket provisioner plugin
- * (getBucketProvisionerConnection) and reads CORS origins from
- * SERVER_ORIGIN env var (falls back to localhost for local dev).
- */
-export function createEnsureBucketProvisioned(): EnsureBucketProvisioned {
- let provisioner: BucketProvisioner | null = null;
-
- return async (
- bucketName: string,
- accessType: 'public' | 'private' | 'temp',
- databaseId: string,
- allowedOrigins: string[] | null,
- ): Promise => {
- // Per-database origins from storage_module, falling back to global SERVER_ORIGIN
- const effectiveOrigins = (allowedOrigins && allowedOrigins.length > 0)
- ? allowedOrigins
- : getAllowedOrigins();
-
- if (!provisioner) {
- provisioner = new BucketProvisioner({
- connection: getBucketProvisionerConnection(),
- allowedOrigins: effectiveOrigins,
- });
- }
-
- log.info(
- `[lazy-provision] Provisioning S3 bucket "${bucketName}" ` +
- `(type=${accessType}) for database ${databaseId}`,
- );
-
- await provisioner.provision({
- bucketName,
- accessType,
- versioning: false,
- allowedOrigins: effectiveOrigins,
- });
-
- log.info(`[lazy-provision] S3 bucket "${bucketName}" provisioned successfully`);
- };
-}
diff --git a/graphile/graphile-settings/src/upload-resolver.ts b/graphile/graphile-settings/src/upload-resolver.ts
index 104f1459cb..6cfe7d6847 100644
--- a/graphile/graphile-settings/src/upload-resolver.ts
+++ b/graphile/graphile-settings/src/upload-resolver.ts
@@ -44,11 +44,7 @@ import type {
import { checkTypeAgreement } from 'mime-bytes';
import { Transform } from 'stream';
-import {
- createBucketNameResolver,
- createEnsureBucketProvisioned,
- getPresignedUrlS3Config,
-} from './presigned-url-resolver';
+import { getPresignedUrlS3Config } from './presigned-url-resolver';
const log = new Logger('upload-resolver');
const DEFAULT_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml'];
@@ -87,12 +83,10 @@ function getStreamer(): Streamer {
/**
* The upload lane's view of the presigned plugin's options: the same S3
- * connection, physical-name policy, and provisioning hook the presigned lane
- * uses, so both transports resolve identical coordinates for a bucket.
+ * connection the presigned lane uses, so both transports resolve identical
+ * coordinates for a bucket.
*
- * Built on first upload rather than at import time — `createBucketNameResolver`
- * throws on a missing name prefix, and that must surface as a failed upload, not
- * as a server that will not boot.
+ * Built on first upload rather than at import time.
*/
let managedOptions: PresignedUrlPluginOptions | null = null;
@@ -100,8 +94,6 @@ function getManagedOptions(): PresignedUrlPluginOptions {
if (!managedOptions) {
managedOptions = {
s3: getPresignedUrlS3Config,
- resolveBucketName: createBucketNameResolver(),
- ensureBucketProvisioned: createEnsureBucketProvisioned(),
};
}
return managedOptions;
diff --git a/graphql/server-test/__fixtures__/seed/db-scope-storage/test-data.sql b/graphql/server-test/__fixtures__/seed/db-scope-storage/test-data.sql
index cc20136d05..8f531bc6ed 100644
--- a/graphql/server-test/__fixtures__/seed/db-scope-storage/test-data.sql
+++ b/graphql/server-test/__fixtures__/seed/db-scope-storage/test-data.sql
@@ -88,10 +88,10 @@ VALUES (
true
) ON CONFLICT (id) DO NOTHING;
-INSERT INTO "tess-storage-public".buckets (id, key, type, is_public)
+INSERT INTO "tess-storage-public".buckets (id, key, type, is_public, physical_name)
VALUES
- ('ce557000-0000-4000-8000-000000000001', 'public', 'public', true),
- ('ce557000-0000-4000-8000-000000000002', 'private', 'private', false)
+ ('ce557000-0000-4000-8000-000000000001', 'public', 'public', true, 'database-ce551000-public-d12a972ce5d3'),
+ ('ce557000-0000-4000-8000-000000000002', 'private', 'private', false, 'database-ce551000-private-5509785f9933')
ON CONFLICT (id) DO NOTHING;
INSERT INTO routing_public.database_settings (id, database_id)
diff --git a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql
index 91a5443af6..f56bbe68cc 100644
--- a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql
+++ b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql
@@ -95,10 +95,10 @@ VALUES (
-- ALICE BUCKET SEED DATA
-- =====================================================
-INSERT INTO "simple-storage-public".app_buckets (id, key, type, is_public)
+INSERT INTO "simple-storage-public".app_buckets (id, key, type, is_public, physical_name)
VALUES
- ('d0000001-0000-0000-0000-000000000001', 'public', 'public', true),
- ('d0000001-0000-0000-0000-000000000002', 'private', 'private', false)
+ ('d0000001-0000-0000-0000-000000000001', 'public', 'public', true, 'app-80a2eaaf-public-c7156cb19fe3'),
+ ('d0000001-0000-0000-0000-000000000002', 'private', 'private', false, 'app-80a2eaaf-private-cc079c527e7d')
ON CONFLICT (id) DO NOTHING;
-- =====================================================
@@ -194,10 +194,10 @@ VALUES (
-- BOB BUCKET SEED DATA
-- =====================================================
-INSERT INTO "bob-storage-public".app_buckets (id, key, type, is_public)
+INSERT INTO "bob-storage-public".app_buckets (id, key, type, is_public, physical_name)
VALUES
- ('d2d2d2d2-0000-0000-0000-000000000001', 'public', 'public', true),
- ('d2d2d2d2-0000-0000-0000-000000000002', 'private', 'private', false)
+ ('d2d2d2d2-0000-0000-0000-000000000001', 'public', 'public', true, 'app-a1a1a1a1-public-4db6afbff1a2'),
+ ('d2d2d2d2-0000-0000-0000-000000000002', 'private', 'private', false, 'app-a1a1a1a1-private-a83152dee184')
ON CONFLICT (id) DO NOTHING;
-- Pre-seed a file in Bob's public bucket for mutation attack testing
@@ -323,10 +323,10 @@ VALUES (
-- MALLORY BUCKET SEED DATA
-- =====================================================
-INSERT INTO "mallory-storage-public".app_buckets (id, key, type, is_public)
+INSERT INTO "mallory-storage-public".app_buckets (id, key, type, is_public, physical_name)
VALUES
- ('fa77fa77-0000-0000-0000-000000000001', 'public', 'public', true),
- ('fa77fa77-0000-0000-0000-000000000002', 'private', 'private', false)
+ ('fa77fa77-0000-0000-0000-000000000001', 'public', 'public', true, 'app-fa11fa11-public-c1aac75adc22'),
+ ('fa77fa77-0000-0000-0000-000000000002', 'private', 'private', false, 'app-fa11fa11-private-c8db3baa1b20')
ON CONFLICT (id) DO NOTHING;
-- Pre-seed files in Mallory's buckets for RLS testing
diff --git a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap
index cb40656a7d..ac8ffcafd4 100644
--- a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap
+++ b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap
@@ -1118,10 +1118,11 @@ type Mutation {
): DeleteUserPayload
"""
- Provision an S3 bucket for a logical bucket in the database.
- Reads the bucket config via RLS, then creates and configures
- the S3 bucket with the appropriate privacy policies, CORS rules,
- and lifecycle settings.
+ Reconcile an S3 bucket for a logical bucket in the database.
+ Reads the bucket config via RLS, then enqueues the same
+ storage:provision_bucket job used by the INSERT trigger. This is
+ idempotent for an already-reconciled bucket; enqueue failures become
+ GraphQL errors.
"""
provisionBucket(
"""
@@ -1609,23 +1610,17 @@ input ProvisionBucketInput {
}
type ProvisionBucketPayload {
- """The access type applied"""
- accessType: String!
-
- """The S3 bucket name that was provisioned"""
- bucketName: String!
-
- """The S3 endpoint (null for AWS S3 default)"""
- endpoint: String
-
- """Error message if provisioning failed"""
- error: String
+ """The logical bucket row that was queued for reconciliation."""
+ bucketId: UUID!
+ bucketKey: String!
- """The storage provider used"""
- provider: String!
+ """The reconciler job enqueued to provision this bucket."""
+ jobId: UUID!
- """Whether provisioning succeeded"""
- success: Boolean!
+ """
+ The physical bucket name already recorded, or null when reconciliation has not completed.
+ """
+ physicalName: String
}
"""The root query type which gives access points into the data universe."""
diff --git a/graphql/server-test/__tests__/db-scope-upload.integration.test.ts b/graphql/server-test/__tests__/db-scope-upload.integration.test.ts
index 76f2983c72..afe417ea00 100644
--- a/graphql/server-test/__tests__/db-scope-upload.integration.test.ts
+++ b/graphql/server-test/__tests__/db-scope-upload.integration.test.ts
@@ -15,6 +15,7 @@
* pnpm test -- --testPathPattern=db-scope-upload
*/
+import { createS3Bucket, createS3Client } from '@constructive-io/s3-utils';
import { hashContent, putToPresignedUrl } from '@constructive-io/upload-client';
import path from 'path';
import type { PgTestClient } from 'pgsql-test';
@@ -98,6 +99,13 @@ describe('database-scope upload surface', () => {
let request: supertest.Agent;
let pg: PgTestClient;
let teardown: () => Promise;
+ const s3Client = createS3Client({
+ provider: 'minio',
+ region: 'us-east-1',
+ endpoint: 'http://localhost:9000',
+ accessKeyId: 'minioadmin',
+ secretAccessKey: 'minioadmin'
+ });
const post = (
databaseId: string,
@@ -125,10 +133,19 @@ describe('database-scope upload surface', () => {
},
seedAdapters
));
+ const result = await createS3Bucket(
+ s3Client,
+ 'database-ce551000-public-d12a972ce5d3',
+ { provider: 'minio' }
+ );
+ if (!result.success) {
+ throw new Error('Failed to create test S3 bucket "database-ce551000-public-d12a972ce5d3"');
+ }
});
afterAll(async () => {
if (teardown) await teardown();
+ s3Client.destroy();
});
describe('mutation generation', () => {
@@ -193,7 +210,7 @@ describe('database-scope upload surface', () => {
);
const physicalName: string | null = stored.rows[0]?.physical_name ?? null;
expect(physicalName).toBeTruthy();
- expect(physicalName).toMatch(/-public-[a-f0-9]{12}$/);
+ expect(physicalName).toBe('database-ce551000-public-d12a972ce5d3');
expect(bucketFromPresignedUrl(uploadUrl)).toBe(physicalName);
});
diff --git a/graphql/server-test/__tests__/upload.integration.test.ts b/graphql/server-test/__tests__/upload.integration.test.ts
index b7f516a669..b07a235bb5 100644
--- a/graphql/server-test/__tests__/upload.integration.test.ts
+++ b/graphql/server-test/__tests__/upload.integration.test.ts
@@ -4,8 +4,8 @@
* Exercises the file-centric upload pipeline:
* uploadAppFile mutation -> presigned PUT URL -> PUT to S3
*
- * Uses real MinIO (available in CI as minio_cdn service) and lazy bucket
- * provisioning.
+ * Uses real MinIO (available in CI as minio_cdn service) and reconciled
+ * physical bucket fixtures.
*
* Three actors (single beforeAll, single server -- stays fast):
* Alice -- baseline tenant, no RLS (wide-open schema)
@@ -28,6 +28,7 @@
* pnpm test -- --testPathPattern=upload.integration
*/
+import { createS3Bucket, createS3Client } from '@constructive-io/s3-utils';
import { hashContent, putToPresignedUrl } from '@constructive-io/upload-client';
import path from 'path';
import type { PgTestClient } from 'pgsql-test';
@@ -99,11 +100,10 @@ const UPLOAD_APP_FILE = `
const PROVISION_BUCKET = `
mutation ProvisionBucket($input: ProvisionBucketInput!) {
provisionBucket(input: $input) {
- success
- bucketName
- accessType
- provider
- error
+ bucketId
+ bucketKey
+ physicalName
+ jobId
}
}
`;
@@ -280,6 +280,22 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
let request: supertest.Agent;
let pg: PgTestClient;
let teardown: () => Promise;
+ const s3Client = createS3Client({
+ provider: 'minio',
+ region: 'us-east-1',
+ endpoint: 'http://localhost:9000',
+ accessKeyId: 'minioadmin',
+ secretAccessKey: 'minioadmin'
+ });
+
+ const ensureS3Buckets = async (bucketNames: string[]): Promise => {
+ for (const bucketName of bucketNames) {
+ const result = await createS3Bucket(s3Client, bucketName, { provider: 'minio' });
+ if (!result.success) {
+ throw new Error(`Failed to create test S3 bucket "${bucketName}"`);
+ }
+ }
+ };
const postGraphQL = (payload: {
query: string;
@@ -338,10 +354,19 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
},
seedAdapters
));
+ await ensureS3Buckets([
+ 'app-80a2eaaf-public-c7156cb19fe3',
+ 'app-80a2eaaf-private-cc079c527e7d',
+ 'app-a1a1a1a1-public-4db6afbff1a2',
+ 'app-a1a1a1a1-private-a83152dee184',
+ 'app-fa11fa11-public-c1aac75adc22',
+ 'app-fa11fa11-private-c8db3baa1b20'
+ ]);
});
afterAll(async () => {
if (teardown) await teardown();
+ s3Client.destroy();
});
// ==========================================================================
@@ -465,12 +490,12 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
// ==========================================================================
// 1b. physical_name persistence (Alice)
//
- // The provisioner records the exact physical S3 bucket name on the source
- // bucket row at first-provision time; every later read uses that stored
- // coordinate instead of recomputing it from a prefix convention.
+ // The reconciler records the exact physical S3 bucket name on the source
+ // bucket row; every later read uses that stored coordinate instead of
+ // recomputing it from a prefix convention.
// ==========================================================================
- describe('physical_name persistence (Alice)', () => {
+ describe('recorded physical_name usage (Alice)', () => {
const aliceBucketsTable = `"${aliceSchemas[0]}".app_buckets`;
const physicalNameFor = async (key: string): Promise => {
@@ -485,7 +510,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
const bucketFromPresignedUrl = (url: string): string =>
new URL(url).pathname.replace(/^\/+/, '').split('/')[0];
- it('records physical_name on the row after upload, matching the presigned URL bucket', async () => {
+ it('uses the reconciler-recorded physical_name for the presigned URL bucket', async () => {
const fileContent = 'physical-name coordinate check';
const contentHash = await hashContent(fileContent);
@@ -506,10 +531,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
const stored = await physicalNameFor('public');
expect(stored).toBeTruthy();
- // Matches the resolver contract: {prefix}-{bucketKey}-{digest}, bounded
- // to S3's 63-character limit.
+ expect(stored).toBe('app-80a2eaaf-public-c7156cb19fe3');
expect(stored).toContain('public');
- expect(stored).toMatch(/-public-[a-f0-9]{12}$/);
expect(stored!.length).toBeLessThanOrEqual(63);
// ...and is exactly the bucket the presigned PUT targets.
expect(bucketFromPresignedUrl(payload.uploadUrl)).toBe(stored);
@@ -540,6 +563,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
it('honors a preexisting custom physical_name verbatim (resolver never consulted)', async () => {
const customPhysical = 'preexisting-custom-cdn-bucket';
+ await ensureS3Buckets([customPhysical]);
await pg.query(
`INSERT INTO ${aliceBucketsTable} (key, type, is_public, physical_name)
VALUES ($1, 'public', true, $2)`,
@@ -570,16 +594,10 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
});
// ==========================================================================
- // 1c. Eager provisioning via the provisionBucket mutation (Alice)
- //
- // The explicit provisionBucket mutation must mint the SAME tenant-aware
- // physical name the lazy first-upload path would (`{prefix}-{key}-{digest}`) and
- // persist it on the bucket row — never the bare logical key. This is the
- // regression guard for BucketProvisionerPreset being wired without a
- // resolveBucketName.
+ // 1c. Reconciliation enqueue via the provisionBucket mutation (Alice)
// ==========================================================================
- describe('Eager provisioning via provisionBucket (Alice)', () => {
+ describe('Reconciliation enqueue via provisionBucket (Alice)', () => {
const aliceBucketsTable = `"${aliceSchemas[0]}".app_buckets`;
const physicalNameFor = async (key: string): Promise => {
@@ -590,10 +608,6 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
return res.rows[0]?.physical_name ?? null;
};
- // MinIO uses path-style URLs: http://host:9000//?...
- const bucketFromPresignedUrl = (url: string): string =>
- new URL(url).pathname.replace(/^\/+/, '').split('/')[0];
-
// Seed a fresh, never-provisioned bucket row (physical_name IS NULL).
const seedBucket = async (key: string): Promise => {
await pg.query(
@@ -602,57 +616,24 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
);
};
- it('mints {prefix}-{key}-{digest} and records it, matching what the lazy path would mint', async () => {
- // 1. Derive the naming prefix from a bucket the LAZY path provisions.
- const lazyKey = 'eager-lazy';
- await seedBucket(lazyKey);
- const lazyRes = await postGraphQL({
- query: UPLOAD_APP_FILE,
- variables: {
- input: {
- bucketKey: lazyKey,
- contentHash: await hashContent('eager-lazy-probe'),
- contentType: 'text/plain',
- size: 16,
- filename: 'eager-lazy.txt'
- }
- }
- });
- const lazyUrl = expectSuccess(lazyRes).uploadAppFile.uploadUrl;
- expect(lazyUrl).toBeTruthy();
- const lazyPhysical = await physicalNameFor(lazyKey);
- expect(lazyPhysical).toBeTruthy();
- // The lazy path records the exact bucket the presigned PUT targets.
- expect(bucketFromPresignedUrl(lazyUrl)).toBe(lazyPhysical);
-
- // The shared convention: {prefix}-{key}-{digest}, bounded to S3's 63 chars.
- const lazyMatch = new RegExp(`^(.+)-${lazyKey}-[a-f0-9]{12}$`).exec(lazyPhysical!);
- expect(lazyMatch).not.toBeNull();
- expect(lazyPhysical!.length).toBeLessThanOrEqual(63);
- const prefix = lazyMatch![1];
-
- // 2. EAGER path: a fresh bucket row, provisioned via the mutation.
- const eagerKey = 'eager-prov';
- await seedBucket(eagerKey);
- expect(await physicalNameFor(eagerKey)).toBeNull();
+ it('enqueues reconciliation and leaves the physical name untouched', async () => {
+ const key = 'reconcile-now';
+ await seedBucket(key);
const provRes = await postGraphQL({
query: PROVISION_BUCKET,
- variables: { input: { bucketKey: eagerKey } }
+ variables: { input: { bucketKey: key } }
});
const payload = expectSuccess(provRes).provisionBucket;
- expect(payload.error).toBeNull();
- expect(payload.success).toBe(true);
-
- // Eager mints the tenant-aware name — NOT the bare logical key.
- expect(payload.bucketName).toMatch(new RegExp(`^${prefix}-${eagerKey}-[a-f0-9]{12}$`));
- expect(payload.bucketName).not.toBe(eagerKey);
- // ...and persists it on the row (physical_name IS NULL-guarded record).
- expect(await physicalNameFor(eagerKey)).toBe(payload.bucketName);
+ expect(payload.bucketKey).toBe(key);
+ expect(payload.bucketId).toBeTruthy();
+ expect(payload.physicalName).toBeNull();
+ expect(payload.jobId).toBeTruthy();
+ expect(await physicalNameFor(key)).toBeNull();
});
- it('does not clobber a physical_name recorded by a prior provision', async () => {
- const key = 'eager-idem';
+ it('can enqueue reconciliation repeatedly without changing the recorded name', async () => {
+ const key = 'reconcile-idem';
await seedBucket(key);
const firstRes = await postGraphQL({
@@ -660,29 +641,23 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
variables: { input: { bucketKey: key } }
});
const firstPayload = expectSuccess(firstRes).provisionBucket;
- expect(firstPayload.success).toBe(true);
- const recorded = await physicalNameFor(key);
- expect(recorded).toBe(firstPayload.bucketName);
+ expect(firstPayload.physicalName).toBeNull();
+ expect(firstPayload.jobId).toBeTruthy();
const secondRes = await postGraphQL({
query: PROVISION_BUCKET,
variables: { input: { bucketKey: key } }
});
const secondPayload = expectSuccess(secondRes).provisionBucket;
- expect(secondPayload.success).toBe(true);
- // The stored coordinate is authoritative and left untouched.
- expect(await physicalNameFor(key)).toBe(recorded);
+ expect(secondPayload.physicalName).toBeNull();
+ expect(secondPayload.jobId).toBeTruthy();
+ expect(secondPayload.jobId).not.toBe(firstPayload.jobId);
+ expect(await physicalNameFor(key)).toBeNull();
});
});
// ==========================================================================
- // 1d. Auto-provision-on-create is disabled (Alice)
- //
- // ConstructivePreset wires BucketProvisionerPreset with autoProvision:false,
- // so creating a bucket row via the createAppBucket GraphQL mutation records
- // the row WITHOUT eagerly minting an S3 bucket. Buckets are provisioned only
- // lazily (first upload) or explicitly (provisionBucket) — no empty-bucket
- // sprawl on every create call.
+ // 1d. Uploads require reconciliation (Alice)
// ==========================================================================
describe('Auto-provision on bucket create is disabled (Alice)', () => {
@@ -696,10 +671,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
return res.rows[0]?.physical_name ?? null;
};
- const bucketFromPresignedUrl = (url: string): string =>
- new URL(url).pathname.replace(/^\/+/, '').split('/')[0];
-
- it('createAppBucket records the row without minting an S3 bucket; first upload provisions lazily', async () => {
+ it('createAppBucket records an unreconciled row and uploads reject it', async () => {
const key = 'no-eager';
// Create the bucket ROW via the GraphQL mutation.
@@ -712,11 +684,10 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
const created = expectSuccess(createRes).createAppBucket.appBucket;
expect(created.key).toBe(key);
- // autoProvision:false => the create hook never runs, so no S3 bucket is
- // minted and nothing is recorded on the row.
+ // The logical row remains unreconciled until the job runs.
expect(await physicalNameFor(key)).toBeNull();
- // The lazy path still provisions the bucket on first upload.
+ // Uploads fail while reconciliation has not recorded a physical name.
const uploadRes = await postGraphQL({
query: UPLOAD_APP_FILE,
variables: {
@@ -729,12 +700,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
}
}
});
- const uploadUrl = expectSuccess(uploadRes).uploadAppFile.uploadUrl;
- const physical = await physicalNameFor(key);
- expect(physical).toBeTruthy();
- // Lazy mints the same tenant-aware name the presigned PUT targets.
- expect(bucketFromPresignedUrl(uploadUrl)).toBe(physical);
- expect(physical).toMatch(new RegExp(`-${key}-[a-f0-9]{12}$`));
+ expect(uploadRes.body.errors?.[0]?.message).toContain('STORAGE_BUCKET_NOT_RECONCILED');
+ expect(await physicalNameFor(key)).toBeNull();
});
});
@@ -1107,4 +1074,3 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
});
});
});
-
diff --git a/graphql/server-test/package.json b/graphql/server-test/package.json
index 65b6bdc62b..e409e496c2 100644
--- a/graphql/server-test/package.json
+++ b/graphql/server-test/package.json
@@ -33,6 +33,7 @@
"@agentic-kit/ollama": "workspace:*",
"@constructive-io/graphql-codegen": "workspace:^",
"@constructive-io/graphql-query": "workspace:^",
+ "@constructive-io/s3-utils": "workspace:^",
"@types/express": "^5.0.6",
"@types/pg": "^8.20.4",
"@types/supertest": "^7.2.1",
diff --git a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap
index 2e46d83f68..c6adaf6724 100644
--- a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap
+++ b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap
@@ -3874,10 +3874,11 @@ based pagination. May not be used with \`last\`.",
},
],
"deprecationReason": null,
- "description": "Provision an S3 bucket for a logical bucket in the database.
-Reads the bucket config via RLS, then creates and configures
-the S3 bucket with the appropriate privacy policies, CORS rules,
-and lifecycle settings.",
+ "description": "Reconcile an S3 bucket for a logical bucket in the database.
+Reads the bucket config via RLS, then enqueues the same
+storage:provision_bucket job used by the INSERT trigger. This is
+idempotent for an already-reconciled bucket; enqueue failures become
+GraphQL errors.",
"isDeprecated": false,
"name": "provisionBucket",
"type": {
@@ -4383,15 +4384,15 @@ Omit for app-level (database-wide) storage.",
{
"args": [],
"deprecationReason": null,
- "description": "Whether provisioning succeeded",
+ "description": "The logical bucket row that was queued for reconciliation.",
"isDeprecated": false,
- "name": "success",
+ "name": "bucketId",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
- "name": "Boolean",
+ "name": "UUID",
"ofType": null,
},
},
@@ -4399,9 +4400,9 @@ Omit for app-level (database-wide) storage.",
{
"args": [],
"deprecationReason": null,
- "description": "The S3 bucket name that was provisioned",
+ "description": null,
"isDeprecated": false,
- "name": "bucketName",
+ "name": "bucketKey",
"type": {
"kind": "NON_NULL",
"name": null,
@@ -4415,59 +4416,31 @@ Omit for app-level (database-wide) storage.",
{
"args": [],
"deprecationReason": null,
- "description": "The access type applied",
+ "description": "The physical bucket name already recorded, or null when reconciliation has not completed.",
"isDeprecated": false,
- "name": "accessType",
+ "name": "physicalName",
"type": {
- "kind": "NON_NULL",
- "name": null,
- "ofType": {
- "kind": "SCALAR",
- "name": "String",
- "ofType": null,
- },
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null,
},
},
{
"args": [],
"deprecationReason": null,
- "description": "The storage provider used",
+ "description": "The reconciler job enqueued to provision this bucket.",
"isDeprecated": false,
- "name": "provider",
+ "name": "jobId",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
- "name": "String",
+ "name": "UUID",
"ofType": null,
},
},
},
- {
- "args": [],
- "deprecationReason": null,
- "description": "The S3 endpoint (null for AWS S3 default)",
- "isDeprecated": false,
- "name": "endpoint",
- "type": {
- "kind": "SCALAR",
- "name": "String",
- "ofType": null,
- },
- },
- {
- "args": [],
- "deprecationReason": null,
- "description": "Error message if provisioning failed",
- "isDeprecated": false,
- "name": "error",
- "type": {
- "kind": "SCALAR",
- "name": "String",
- "ofType": null,
- },
- },
],
"inputFields": null,
"interfaces": [],
diff --git a/packages/bucket-provisioner/README.md b/packages/bucket-provisioner/README.md
index 21cc9fcc01..4173e21e17 100644
--- a/packages/bucket-provisioner/README.md
+++ b/packages/bucket-provisioner/README.md
@@ -196,11 +196,6 @@ belongs to one database at one scope, and there is no global bucket namespace to
prefix into. Each readable component has its own budget, so a long scope cannot
crowd out the database label, and the digest covers the untruncated identity.
-#### `mintPhysicalBucketName(prefix, databaseId, bucketKey)`
-
-The prefixed policy — `{prefix}-{bucketKey}-{digest}` — for a deployment that
-names its own bucket namespace.
-
### Policy Builders
Standalone functions for generating S3 policy documents.
diff --git a/packages/bucket-provisioner/__tests__/naming.test.ts b/packages/bucket-provisioner/__tests__/naming.test.ts
index 6e1696ee8b..b9099e157f 100644
--- a/packages/bucket-provisioner/__tests__/naming.test.ts
+++ b/packages/bucket-provisioner/__tests__/naming.test.ts
@@ -1,60 +1,8 @@
-import { mintPhysicalBucketName, physicalBucketName } from '../src/naming';
+import { physicalBucketName } from '../src/naming';
-const PREFIX = 'test-bucket';
const DATABASE_ID = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9';
const BUCKET_NAME = /^[a-z0-9-]{3,63}$/;
-describe('mintPhysicalBucketName', () => {
- it('returns the same name for repeated calls with the same identity', () => {
- const first = mintPhysicalBucketName(PREFIX, DATABASE_ID, 'default');
- const second = mintPhysicalBucketName(PREFIX, DATABASE_ID, 'default');
-
- expect(second).toBe(first);
- });
-
- it('separates the same bucket key across databases', () => {
- expect(
- mintPhysicalBucketName(PREFIX, DATABASE_ID, 'default'),
- ).not.toBe(
- mintPhysicalBucketName(PREFIX, '11111111-2222-3333-4444-555555555555', 'default'),
- );
- });
-
- it('separates keys that differ only past the readable budget', () => {
- const shared = 'a'.repeat(40);
-
- expect(
- mintPhysicalBucketName(PREFIX, DATABASE_ID, `${shared}-one`),
- ).not.toBe(
- mintPhysicalBucketName(PREFIX, DATABASE_ID, `${shared}-two`),
- );
- });
-
- it('always returns a bounded S3 bucket name without edge hyphens', () => {
- const name = mintPhysicalBucketName(
- 'Some_Very.Long CDN Prefix That Nobody Would Choose',
- DATABASE_ID,
- 'Marketing_Site/Assets — 2024'.repeat(5),
- );
-
- expect(name).toMatch(BUCKET_NAME);
- expect(name).not.toMatch(/^-|-$/);
- });
-
- it('keeps a key that sanitizes to empty legal', () => {
- const name = mintPhysicalBucketName(PREFIX, DATABASE_ID, '!!!');
-
- expect(name).toMatch(BUCKET_NAME);
- expect(name).not.toMatch(/^-|-$/);
- });
-
- it('falls back to the digest alone when both components sanitize away', () => {
- const name = mintPhysicalBucketName('!!!', DATABASE_ID, '???');
-
- expect(name).toMatch(/^[a-f0-9]{12}$/);
- });
-});
-
describe('physicalBucketName', () => {
const identity = {
scope: 'database',
diff --git a/packages/bucket-provisioner/src/index.ts b/packages/bucket-provisioner/src/index.ts
index 36ebbcd1cb..2b91125959 100644
--- a/packages/bucket-provisioner/src/index.ts
+++ b/packages/bucket-provisioner/src/index.ts
@@ -33,7 +33,7 @@ export { BucketProvisioner } from './provisioner';
// Physical naming policy
export type { PhysicalBucketIdentity } from './naming';
-export { mintPhysicalBucketName, physicalBucketName } from './naming';
+export { physicalBucketName } from './naming';
// S3 client factory
export { createS3Client } from './client';
diff --git a/packages/bucket-provisioner/src/naming.ts b/packages/bucket-provisioner/src/naming.ts
index 6924df5d15..2e06b39c53 100644
--- a/packages/bucket-provisioner/src/naming.ts
+++ b/packages/bucket-provisioner/src/naming.ts
@@ -6,10 +6,6 @@ const MAX_BUCKET_NAME_LENGTH = 63;
const MIN_BUCKET_NAME_LENGTH = 3;
/** Hex characters of the identity digest kept as the uniqueness tail. */
const IDENTITY_DIGEST_LENGTH = 12;
-/** Readable budget: how much of the name the prefix and key may each occupy. */
-const PREFIX_BUDGET = 20;
-const BUCKET_KEY_BUDGET = 63 - IDENTITY_DIGEST_LENGTH - PREFIX_BUDGET - 3;
-
/**
* Readable budgets for a name minted from a bucket's own identity.
*
@@ -100,10 +96,10 @@ function assembleBucketName(
*
* This is the policy for a platform-provisioned bucket, and it takes no prefix
* because there is no global bucket namespace to prefix into: a bucket belongs
- * to one database at one scope, and that is what its name should say. Callers
- * derive names from here rather than composing a prefix of their own, so the
- * policy can change in one place — and an already-provisioned bucket never
- * consults it at all, since the row's recorded `physical_name` is authoritative.
+ * to one database at one scope, and that is what its name should say. The
+ * reconciler derives names from here rather than composing a prefix of its own,
+ * and consumers of a bucket row never consult this policy: the recorded
+ * `physical_name` is authoritative.
*
* The scope is part of the digested identity untruncated, so two scopes of one
* database that declare the same bucket key stay distinct even when their
@@ -121,24 +117,3 @@ export function physicalBucketName(identity: PhysicalBucketIdentity): string {
`key "${bucketKey}" at scope "${scope}"`,
);
}
-
-/**
- * The prefixed physical-bucket naming policy:
- * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`).
- *
- * For a deployment that names its own bucket namespace — a single-app
- * installation, or the presigned-upload (lazy) path meeting a bucket an app
- * configured. A platform-provisioned bucket uses {@link physicalBucketName}
- * instead, whose components come from the bucket row rather than from
- * deployment config.
- */
-export function mintPhysicalBucketName(prefix: string, databaseId: string, bucketKey: string): string {
- return assembleBucketName(
- [
- readableComponent(prefix, PREFIX_BUDGET),
- readableComponent(bucketKey, BUCKET_KEY_BUDGET),
- ],
- `${prefix}/${databaseId}/${bucketKey}`,
- `key "${bucketKey}"`,
- );
-}
diff --git a/packages/errors/src/registry.ts b/packages/errors/src/registry.ts
index 7c5cf70fc1..64de8fd5a1 100644
--- a/packages/errors/src/registry.ts
+++ b/packages/errors/src/registry.ts
@@ -14,6 +14,13 @@ export { type DefinedError,defineError } from './define';
* code is present via generation; these just refine a subset.
*/
export const registry = {
+ STORAGE_BUCKET_NOT_RECONCILED: defineError({
+ code: 'STORAGE_BUCKET_NOT_RECONCILED',
+ class: 'public',
+ http: 409,
+ message: 'The storage bucket has not yet been reconciled.'
+ }),
+
// ===========================================================================
// Auth / account (public) — copy sourced from dashboard auth-errors.ts
// ===========================================================================
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7266219384..25dd9ae11e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2229,6 +2229,9 @@ importers:
'@constructive-io/graphql-query':
specifier: workspace:^
version: link:../query/dist
+ '@constructive-io/s3-utils':
+ specifier: workspace:^
+ version: link:../../uploads/s3-utils/dist
'@types/express':
specifier: ^5.0.6
version: 5.0.6