graphile-presigned-url-plugin 1.16.1 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/index.d.ts CHANGED
@@ -34,11 +34,11 @@ export { createDownloadUrlPlugin } from './download-url-field';
34
34
  export type { FileRefFieldBinding } from './file-ref-registry';
35
35
  export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
36
36
  export { assertUploadAllowedByBucket, buildFileProjection, type FileProjection, finalizeStagedUpload, type ManagedUploadTarget, resolveManagedUploadTarget, } from './managed-upload';
37
- export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
37
+ export { assertBucketReconciled, resolveS3, resolveS3ForDatabase, StorageBucketNotReconciledError, } from './physical-bucket';
38
38
  export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
39
39
  export { PresignedUrlPreset } from './preset';
40
40
  export { type WithPgClient, withRequestPgClient } from './request-pg-client';
41
41
  export { describeS3Failure, s3FailureError } from './s3-failure';
42
42
  export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
43
- export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
44
- export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
43
+ export { clearBucketCache, clearStorageModuleCache, getBucketConfig, loadAllStorageModules, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
44
+ export type { BucketConfig, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
package/esm/index.js CHANGED
@@ -32,10 +32,10 @@ export { resolveDefaultBucket } from './default-bucket';
32
32
  export { createDownloadUrlPlugin } from './download-url-field';
33
33
  export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
34
34
  export { assertUploadAllowedByBucket, buildFileProjection, finalizeStagedUpload, resolveManagedUploadTarget, } from './managed-upload';
35
- export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
35
+ export { assertBucketReconciled, resolveS3, resolveS3ForDatabase, StorageBucketNotReconciledError, } from './physical-bucket';
36
36
  export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
37
37
  export { PresignedUrlPreset } from './preset';
38
38
  export { withRequestPgClient } from './request-pg-client';
39
39
  export { describeS3Failure, s3FailureError } from './s3-failure';
40
40
  export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
41
- export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
41
+ export { clearBucketCache, clearStorageModuleCache, getBucketConfig, loadAllStorageModules, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
@@ -21,9 +21,10 @@ import { Logger } from '@pgpmjs/logger';
21
21
  import { resolveDefaultBucket } from './default-bucket';
22
22
  import { isLiveFileRow, statusSelectFragment } from './file-lifecycle';
23
23
  import { getFileRefFieldBinding } from './file-ref-registry';
24
- import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
24
+ import { assertBucketReconciled, resolveS3ForDatabase } from './physical-bucket';
25
25
  import { withRequestPgClient } from './request-pg-client';
26
26
  import { copyS3Object, deleteS3Object } from './s3-signer';
27
+ import { recordManagedFile } from './storage-file-recorder';
27
28
  import { getBucketConfig, loadAllStorageModules } from './storage-module-cache';
28
29
  const log = new Logger('graphile-presigned-url:managed-upload');
29
30
  /**
@@ -133,9 +134,7 @@ export async function resolveManagedUploadTarget(args) {
133
134
  'the multipart upload lane only writes content-addressed keys. Use the presigned upload ' +
134
135
  'mutation with an explicit key.');
135
136
  }
136
- const physicalName = bucket.physical_name === null
137
- ? await provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
138
- : bucket.physical_name;
137
+ const physicalName = assertBucketReconciled(bucket, databaseId);
139
138
  return {
140
139
  databaseId,
141
140
  storageConfig,
@@ -252,22 +251,14 @@ export async function finalizeStagedUpload(args) {
252
251
  let fileId;
253
252
  try {
254
253
  fileId = await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
255
- const result = await pgClient.query({
256
- text: `INSERT INTO ${storageConfig.filesQualifiedName}
257
- (bucket_id, key, content_hash, mime_type, size, filename, is_public)
258
- VALUES ($1, $2, $3, $4, $5, $6, $7)
259
- RETURNING id`,
260
- values: [
261
- bucket.id,
262
- finalKey,
263
- staged.contentHash,
264
- staged.contentType,
265
- staged.size,
266
- staged.filename ?? null,
267
- bucket.is_public,
268
- ],
254
+ return recordManagedFile(pgClient, storageConfig, {
255
+ bucketId: bucket.id,
256
+ key: finalKey,
257
+ contentHash: staged.contentHash,
258
+ mimeType: staged.contentType,
259
+ size: staged.size,
260
+ filename: staged.filename,
269
261
  });
270
- return result.rows[0].id;
271
262
  });
272
263
  }
273
264
  catch (err) {
@@ -1,15 +1,21 @@
1
1
  /**
2
- * Physical bucket coordinates: minting a name once, recording it, and building
3
- * an S3 config against a *known* name.
2
+ * Physical bucket coordinates: reading the reconciler's recorded name and
3
+ * building an S3 config against that known name.
4
4
  *
5
5
  * A logical bucket belongs to a tenant; a physical bucket is an S3 name. The
6
- * mapping is recorded on the bucket row the first time it is provisioned, and
7
- * from then on the recorded value is the only coordinate anything reads — no
8
- * name is ever recomputed from a prefix convention, and there is no
9
- * environment-level bucket standing in for a tenant's.
6
+ * mapping is recorded on the bucket row by the storage reconciler, and that
7
+ * value is the only coordinate anything reads — no name is ever recomputed.
10
8
  */
11
- import { type WithPgClient } from './request-pg-client';
12
9
  import type { BucketConfig, PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
10
+ export declare class StorageBucketNotReconciledError extends Error {
11
+ readonly code = "STORAGE_BUCKET_NOT_RECONCILED";
12
+ readonly retryable = true;
13
+ readonly extensions: {
14
+ code: string;
15
+ retryable: boolean;
16
+ };
17
+ constructor(bucket: BucketConfig, databaseId: string);
18
+ }
13
19
  /**
14
20
  * Resolve the plugin's S3 connection (credentials, endpoint, region), memoizing
15
21
  * a lazy getter on first use.
@@ -19,42 +25,14 @@ import type { BucketConfig, PresignedUrlPluginOptions, S3Config, StorageModuleCo
19
25
  * resolves its physical bucket from the tenant's bucket row.
20
26
  */
21
27
  export declare function resolveS3(options: PresignedUrlPluginOptions): S3Config;
22
- /**
23
- * Mint the physical S3 bucket name for a logical bucket's first provision.
24
- *
25
- * This is a naming *policy*, consulted exactly once per bucket — before the
26
- * physical bucket exists. Once provisioned, the recorded `physical_name` on the
27
- * row is authoritative and this function must not be consulted again.
28
- *
29
- * There is no fallback to the configured `s3.bucket`: a deployment-wide bucket
30
- * name is not a tenant's storage, and silently minting one is how objects ended
31
- * up in a bucket no database owned. A deployment that wants per-tenant buckets
32
- * must supply the policy.
33
- */
34
- export declare function mintPhysicalBucketName(options: PresignedUrlPluginOptions, databaseId: string, bucketKey: string): string;
35
28
  /**
36
29
  * Build the S3 config for a *known* physical bucket. `physicalName` is
37
- * required — callers must resolve the coordinate (stored row value, or a
38
- * freshly provisioned name) before getting here. No name is ever recomputed.
30
+ * required — callers must resolve the coordinate from the stored row value
31
+ * before getting here. No name is ever recomputed.
39
32
  */
40
33
  export declare function resolveS3ForDatabase(options: PresignedUrlPluginOptions, storageConfig: StorageModuleConfig, physicalName: string): S3Config;
41
34
  /**
42
- * First provision of a logical bucket: mint a name, create the physical S3
43
- * bucket, and record the exact name on the source row. Returns the recorded
44
- * physical name.
45
- *
46
- * Only called when the row has no `physical_name` yet. Afterwards the stored
47
- * value is the durable coordinate: route resolution and every later read use
48
- * it verbatim; nothing is recomputed.
49
- *
50
- * The record write runs in the system lane (privileged role, so it bypasses the
51
- * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
52
- * bookkeeping, not request data. It still carries the tenant `database_id`
53
- * claim, because the buckets table's catalog-sync trigger calls
54
- * `jwt_private.current_database_id()` and would otherwise raise
55
- * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
56
- * write's transaction without switching off the privileged role.
57
- * `bucket` (the cached config) is mutated in place so subsequent reads observe
58
- * the recorded name without a DB round-trip.
35
+ * Return the reconciler's recorded physical name, or fail with a typed,
36
+ * retryable error while reconciliation is still pending.
59
37
  */
60
- export declare function provisionAndRecordPhysicalBucket(options: PresignedUrlPluginOptions, withPgClient: WithPgClient, storageConfig: StorageModuleConfig, databaseId: string, bucket: BucketConfig, allowedOrigins: string[] | null): Promise<string>;
38
+ export declare function assertBucketReconciled(bucket: BucketConfig, databaseId: string): string;
@@ -1,19 +1,25 @@
1
1
  /**
2
- * Physical bucket coordinates: minting a name once, recording it, and building
3
- * an S3 config against a *known* name.
2
+ * Physical bucket coordinates: reading the reconciler's recorded name and
3
+ * building an S3 config against that known name.
4
4
  *
5
5
  * A logical bucket belongs to a tenant; a physical bucket is an S3 name. The
6
- * mapping is recorded on the bucket row the first time it is provisioned, and
7
- * from then on the recorded value is the only coordinate anything reads — no
8
- * name is ever recomputed from a prefix convention, and there is no
9
- * environment-level bucket standing in for a tenant's.
6
+ * mapping is recorded on the bucket row by the storage reconciler, and that
7
+ * value is the only coordinate anything reads — no name is ever recomputed.
10
8
  */
11
- import { Logger } from '@pgpmjs/logger';
12
- import { recordPhysicalName } from 'graphile-storage-registry';
13
- import { withRequestPgClient } from './request-pg-client';
14
- import { s3FailureError } from './s3-failure';
15
- import { isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
16
- const log = new Logger('graphile-presigned-url:physical-bucket');
9
+ export class StorageBucketNotReconciledError extends Error {
10
+ code = 'STORAGE_BUCKET_NOT_RECONCILED';
11
+ retryable = true;
12
+ extensions = {
13
+ code: 'STORAGE_BUCKET_NOT_RECONCILED',
14
+ retryable: true,
15
+ };
16
+ constructor(bucket, databaseId) {
17
+ super(`STORAGE_BUCKET_NOT_RECONCILED: bucket "${bucket.key}" (id=${bucket.id}) ` +
18
+ `for database ${databaseId} has not yet been reconciled; the reconciler has ` +
19
+ 'not yet recorded a physical name');
20
+ this.name = 'StorageBucketNotReconciledError';
21
+ }
22
+ }
17
23
  /**
18
24
  * Resolve the plugin's S3 connection (credentials, endpoint, region), memoizing
19
25
  * a lazy getter on first use.
@@ -30,31 +36,10 @@ export function resolveS3(options) {
30
36
  }
31
37
  return options.s3;
32
38
  }
33
- /**
34
- * Mint the physical S3 bucket name for a logical bucket's first provision.
35
- *
36
- * This is a naming *policy*, consulted exactly once per bucket — before the
37
- * physical bucket exists. Once provisioned, the recorded `physical_name` on the
38
- * row is authoritative and this function must not be consulted again.
39
- *
40
- * There is no fallback to the configured `s3.bucket`: a deployment-wide bucket
41
- * name is not a tenant's storage, and silently minting one is how objects ended
42
- * up in a bucket no database owned. A deployment that wants per-tenant buckets
43
- * must supply the policy.
44
- */
45
- export function mintPhysicalBucketName(options, databaseId, bucketKey) {
46
- if (!options.resolveBucketName) {
47
- throw new Error('STORAGE_BUCKET_NAME_POLICY_MISSING: no resolveBucketName was configured, so there is ' +
48
- `no name to provision for bucket "${bucketKey}" of database ${databaseId}. ` +
49
- 'Physical bucket naming is a deployment policy; the configured s3.bucket is a ' +
50
- 'connection default and is never a tenant bucket.');
51
- }
52
- return options.resolveBucketName(databaseId, bucketKey);
53
- }
54
39
  /**
55
40
  * Build the S3 config for a *known* physical bucket. `physicalName` is
56
- * required — callers must resolve the coordinate (stored row value, or a
57
- * freshly provisioned name) before getting here. No name is ever recomputed.
41
+ * required — callers must resolve the coordinate from the stored row value
42
+ * before getting here. No name is ever recomputed.
58
43
  */
59
44
  export function resolveS3ForDatabase(options, storageConfig, physicalName) {
60
45
  const globalS3 = resolveS3(options);
@@ -71,46 +56,11 @@ export function resolveS3ForDatabase(options, storageConfig, physicalName) {
71
56
  };
72
57
  }
73
58
  /**
74
- * First provision of a logical bucket: mint a name, create the physical S3
75
- * bucket, and record the exact name on the source row. Returns the recorded
76
- * physical name.
77
- *
78
- * Only called when the row has no `physical_name` yet. Afterwards the stored
79
- * value is the durable coordinate: route resolution and every later read use
80
- * it verbatim; nothing is recomputed.
81
- *
82
- * The record write runs in the system lane (privileged role, so it bypasses the
83
- * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
84
- * bookkeeping, not request data. It still carries the tenant `database_id`
85
- * claim, because the buckets table's catalog-sync trigger calls
86
- * `jwt_private.current_database_id()` and would otherwise raise
87
- * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
88
- * write's transaction without switching off the privileged role.
89
- * `bucket` (the cached config) is mutated in place so subsequent reads observe
90
- * the recorded name without a DB round-trip.
59
+ * Return the reconciler's recorded physical name, or fail with a typed,
60
+ * retryable error while reconciliation is still pending.
91
61
  */
92
- export async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
93
- const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
94
- if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) {
95
- log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
96
- try {
97
- await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
98
- }
99
- catch (err) {
100
- // The first upload to a bucket is where an unreachable object store is
101
- // discovered, and the transport's own message is routinely empty: name the
102
- // endpoint it could not reach so the response says what is misconfigured.
103
- throw s3FailureError('BUCKET_PROVISION_FAILED', { endpoint: resolveS3(options).endpoint, bucket: s3BucketName, databaseId }, err);
104
- }
105
- markS3BucketProvisioned(s3BucketName);
106
- log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
107
- }
108
- // Record the physical coordinate on the source row. The `physical_name IS NULL`
109
- // guard keeps this idempotent and race-safe across concurrent first uploads.
110
- // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
111
- // write runs under the resolved database claim (privileged role preserved).
112
- await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => recordPhysicalName((query) => client.query(query), storageConfig.bucketsQualifiedName, bucket.id, s3BucketName));
113
- bucket.physical_name = s3BucketName;
114
- log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
115
- return s3BucketName;
62
+ export function assertBucketReconciled(bucket, databaseId) {
63
+ if (bucket.physical_name !== null)
64
+ return bucket.physical_name;
65
+ throw new StorageBucketNotReconciledError(bucket, databaseId);
116
66
  }
package/esm/plugin.js CHANGED
@@ -27,9 +27,10 @@ import { validateCustomKey } from './custom-key';
27
27
  import { resolveDefaultBucket } from './default-bucket';
28
28
  import { isLiveFileRow, statusSelectFragment } from './file-lifecycle';
29
29
  import { buildFileProjection } from './managed-upload';
30
- import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
30
+ import { assertBucketReconciled, resolveS3ForDatabase } from './physical-bucket';
31
31
  import { withRequestPgClient } from './request-pg-client';
32
32
  import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
33
+ import { recordManagedFile } from './storage-file-recorder';
33
34
  import { getBucketConfig, loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
34
35
  const log = new Logger('graphile-presigned-url:plugin');
35
36
  // --- Protocol-level constants (not configurable) ---
@@ -198,11 +199,9 @@ export function createPresignedUrlPlugin(options) {
198
199
  const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
199
200
  if (!bucket)
200
201
  throw new Error('BUCKET_NOT_FOUND');
201
- // First provision mints + records the coordinate; afterwards the
202
- // stored physical_name is authoritative and nothing is recomputed.
203
- const physicalName = bucket.physical_name === null
204
- ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
205
- : bucket.physical_name;
202
+ // The reconciler records the coordinate; consumers never
203
+ // recompute it from the logical bucket row.
204
+ const physicalName = assertBucketReconciled(bucket, databaseId);
206
205
  const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
207
206
  // File row INSERT under the request role (RLS enforced).
208
207
  return withRequestPgClient(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
@@ -305,11 +304,9 @@ export function createPresignedUrlPlugin(options) {
305
304
  if (totalSize > storageConfig.maxBulkTotalSize) {
306
305
  throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
307
306
  }
308
- // First provision mints + records the coordinate; afterwards the
309
- // stored physical_name is authoritative and nothing is recomputed.
310
- const physicalName = bucket.physical_name === null
311
- ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
312
- : bucket.physical_name;
307
+ // The reconciler records the coordinate; consumers never
308
+ // recompute it from the logical bucket row.
309
+ const physicalName = assertBucketReconciled(bucket, databaseId);
313
310
  const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
314
311
  // File row INSERTs under the request role (RLS enforced).
315
312
  return withRequestPgClient(vals.withPgClient, vals.pgSettings, async (txClient) => {
@@ -612,32 +609,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
612
609
  }
613
610
  // Auto-derive ltree path from custom key directory (only when has_path_shares)
614
611
  const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
615
- // Create file record. An entity-keyed plane (the module records an entity
616
- // table) carries owner_id on its rows; app- and database-scope planes do not.
617
- const hasOwnerColumn = storageConfig.entityTableId !== null;
618
- const columns = ['bucket_id', 'key', 'content_hash', 'mime_type', 'size', 'filename', 'is_public'];
619
- const values = [bucket.id, s3Key, contentHash, contentType, size, filename || null, bucket.is_public];
620
- if (hasOwnerColumn) {
621
- columns.push('owner_id');
622
- values.push(bucket.owner_id);
623
- }
624
- if (previousVersionId) {
625
- columns.push('previous_version_id');
626
- values.push(previousVersionId);
627
- }
628
- if (derivedPath) {
629
- columns.push('path');
630
- values.push(derivedPath);
631
- }
632
- const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
633
- const fileResult = await txClient.query({
634
- text: `INSERT INTO ${storageConfig.filesQualifiedName}
635
- (${columns.join(', ')})
636
- VALUES (${placeholders})
637
- RETURNING id`,
638
- values,
612
+ const fileId = await recordManagedFile(txClient, storageConfig, {
613
+ bucketId: bucket.id,
614
+ key: s3Key,
615
+ contentHash,
616
+ mimeType: contentType,
617
+ size,
618
+ filename: filename || null,
619
+ previousVersionId,
620
+ path: derivedPath,
639
621
  });
640
- const fileId = fileResult.rows[0].id;
641
622
  // Generate presigned PUT URL
642
623
  const uploadUrl = await generatePresignedPutUrl(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
643
624
  const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
@@ -0,0 +1,27 @@
1
+ import type { StorageModuleConfig } from './types';
2
+ interface RecordManagedFileInput {
3
+ bucketId: string;
4
+ key: string;
5
+ contentHash: string;
6
+ mimeType: string;
7
+ size: number;
8
+ filename: string | null | undefined;
9
+ previousVersionId?: string | null;
10
+ path?: string | null;
11
+ }
12
+ /**
13
+ * Record a managed file through the storage plane's generated recorder.
14
+ *
15
+ * The recorder owns the files-table insert so bucket inheritance and claims
16
+ * attribution run at the database boundary. Optional arguments are only named
17
+ * when the generated function supports them and the caller has a value.
18
+ */
19
+ export declare function recordManagedFile(pgClient: {
20
+ query: (opts: {
21
+ text: string;
22
+ values: unknown[];
23
+ }) => Promise<{
24
+ rows: unknown[];
25
+ }>;
26
+ }, storageConfig: StorageModuleConfig, input: RecordManagedFileInput): Promise<string>;
27
+ export {};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Record a managed file through the storage plane's generated recorder.
3
+ *
4
+ * The recorder owns the files-table insert so bucket inheritance and claims
5
+ * attribution run at the database boundary. Optional arguments are only named
6
+ * when the generated function supports them and the caller has a value.
7
+ */
8
+ export async function recordManagedFile(pgClient, storageConfig, input) {
9
+ if (!storageConfig.recorderQualifiedName) {
10
+ throw new Error(`STORAGE_RECORDER_MISSING: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
11
+ `must expose ${storageConfig.filesTableName}_record_file in its private schema`);
12
+ }
13
+ if (input.previousVersionId != null && !storageConfig.hasVersioning) {
14
+ throw new Error(`STORAGE_VERSIONING_UNSUPPORTED: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
15
+ 'does not support previous_version_id');
16
+ }
17
+ if (input.path != null && !storageConfig.hasPathShares) {
18
+ throw new Error(`STORAGE_PATH_SHARES_UNSUPPORTED: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
19
+ 'does not support path');
20
+ }
21
+ const args = [
22
+ 'bucket_id := $1::uuid',
23
+ 'key := $2::text',
24
+ 'content_hash := $3::text',
25
+ 'mime_type := $4::text',
26
+ 'size := $5::bigint',
27
+ 'filename := $6::text',
28
+ 'upload := $7::jsonb',
29
+ ];
30
+ const values = [
31
+ input.bucketId,
32
+ input.key,
33
+ input.contentHash,
34
+ input.mimeType,
35
+ input.size,
36
+ input.filename ?? null,
37
+ null,
38
+ ];
39
+ if (storageConfig.hasVersioning && input.previousVersionId != null) {
40
+ args.push(`previous_version_id := $${values.length + 1}::uuid`);
41
+ values.push(input.previousVersionId);
42
+ }
43
+ if (storageConfig.hasPathShares && input.path != null) {
44
+ args.push(`path := $${values.length + 1}::text`);
45
+ values.push(input.path);
46
+ }
47
+ const result = await pgClient.query({
48
+ text: `SELECT id FROM ${storageConfig.recorderQualifiedName}(${args.join(', ')})`,
49
+ values,
50
+ });
51
+ return result.rows[0].id;
52
+ }
@@ -91,14 +91,6 @@ export declare function getBucketConfig(pgClient: {
91
91
  rows: unknown[];
92
92
  }>;
93
93
  }, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string, ownerId?: string): Promise<BucketConfig | null>;
94
- /**
95
- * Check whether an S3 bucket has already been provisioned (cached).
96
- */
97
- export declare function isS3BucketProvisioned(s3BucketName: string): boolean;
98
- /**
99
- * Mark an S3 bucket as provisioned in the in-memory cache.
100
- */
101
- export declare function markS3BucketProvisioned(s3BucketName: string): void;
102
94
  /**
103
95
  * Clear the storage module cache AND bucket cache.
104
96
  * Useful for testing or schema changes.
@@ -41,6 +41,7 @@ const ALL_STORAGE_MODULES_QUERY = `
41
41
  bt.name AS buckets_table,
42
42
  fs.schema_name AS files_schema,
43
43
  ft.name AS files_table,
44
+ ps.schema_name AS private_schema,
44
45
  sm.endpoint,
45
46
  sm.public_url_prefix,
46
47
  sm.provider,
@@ -53,6 +54,7 @@ const ALL_STORAGE_MODULES_QUERY = `
53
54
  sm.max_bulk_files,
54
55
  sm.max_bulk_total_size,
55
56
  sm.has_path_shares,
57
+ sm.has_versioning,
56
58
  sm.has_confirm_upload,
57
59
  es.schema_name AS entity_schema,
58
60
  et.name AS entity_table
@@ -61,6 +63,7 @@ const ALL_STORAGE_MODULES_QUERY = `
61
63
  JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
62
64
  JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
63
65
  JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
66
+ LEFT JOIN metaschema_public.schema ps ON ps.id = sm.private_schema_id
64
67
  LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
65
68
  LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
66
69
  WHERE sm.database_id = $1
@@ -74,6 +77,9 @@ function buildConfig(row) {
74
77
  id: row.id,
75
78
  bucketsQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.buckets_schema, row.buckets_table),
76
79
  filesQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.files_schema, row.files_table),
80
+ recorderQualifiedName: row.private_schema
81
+ ? QuoteUtils.quoteQualifiedIdentifier(row.private_schema, `${row.files_table}_record_file`)
82
+ : null,
77
83
  schemaName: row.buckets_schema,
78
84
  bucketsTableName: row.buckets_table,
79
85
  filesTableName: row.files_table,
@@ -92,6 +98,7 @@ function buildConfig(row) {
92
98
  maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
93
99
  cacheTtlSeconds,
94
100
  hasPathShares: row.has_path_shares ?? false,
101
+ hasVersioning: row.has_versioning ?? false,
95
102
  hasConfirmUpload: row.has_confirm_upload ?? false,
96
103
  maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
97
104
  maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
@@ -253,34 +260,6 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
253
260
  log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.scope})`);
254
261
  return config;
255
262
  }
256
- // --- S3 bucket existence cache ---
257
- /**
258
- * In-memory set of S3 bucket names that are known to exist.
259
- *
260
- * Used by the lazy provisioning logic in the presigned URL plugin:
261
- * before generating a presigned PUT URL, the plugin checks this set.
262
- * If the bucket name is absent, it calls `ensureBucketProvisioned`
263
- * to create the S3 bucket, then adds the name here. Subsequent
264
- * requests for the same bucket skip the provisioning entirely.
265
- *
266
- * No TTL needed — S3 buckets are never deleted during normal operation.
267
- * The set resets on server restart, which is fine because the
268
- * provisioner's createBucket is idempotent (handles "already exists").
269
- */
270
- const provisionedBuckets = new Set();
271
- /**
272
- * Check whether an S3 bucket has already been provisioned (cached).
273
- */
274
- export function isS3BucketProvisioned(s3BucketName) {
275
- return provisionedBuckets.has(s3BucketName);
276
- }
277
- /**
278
- * Mark an S3 bucket as provisioned in the in-memory cache.
279
- */
280
- export function markS3BucketProvisioned(s3BucketName) {
281
- provisionedBuckets.add(s3BucketName);
282
- log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
283
- }
284
263
  /**
285
264
  * Clear the storage module cache AND bucket cache.
286
265
  * Useful for testing or schema changes.
@@ -288,7 +267,6 @@ export function markS3BucketProvisioned(s3BucketName) {
288
267
  export function clearStorageModuleCache() {
289
268
  storageModuleCache.clear();
290
269
  bucketCache.clear();
291
- provisionedBuckets.clear();
292
270
  }
293
271
  /**
294
272
  * Clear cached bucket entries for a specific database.
package/esm/types.d.ts CHANGED
@@ -12,10 +12,9 @@ export interface BucketConfig {
12
12
  max_file_size: number | null;
13
13
  allow_custom_keys: boolean;
14
14
  /**
15
- * The physical S3/MinIO bucket name recorded when the physical bucket was
16
- * first provisioned. NULL until the first upload provisions it. Once set,
17
- * it is the source of truth for the physical bucket — reads never
18
- * reconstruct the name from a prefix convention.
15
+ * The physical S3/MinIO bucket name recorded by reconciliation. NULL until
16
+ * reconciliation completes. Once set, it is the source of truth for the
17
+ * physical bucket — reads never reconstruct the name.
19
18
  */
20
19
  physical_name: string | null;
21
20
  }
@@ -35,6 +34,8 @@ export interface StorageModuleConfig {
35
34
  bucketsTableName: string;
36
35
  /** Files table name */
37
36
  filesTableName: string;
37
+ /** Qualified generated managed-file recorder, or null when unavailable */
38
+ recorderQualifiedName: string | null;
38
39
  /** Scope name (e.g., 'app', 'org', 'team') */
39
40
  scope: string;
40
41
  /** Entity table ID for entity-scoped storage (NULL for app-level) */
@@ -67,6 +68,8 @@ export interface StorageModuleConfig {
67
68
  * without it every row is treated as live, because there is nothing to read.
68
69
  */
69
70
  hasConfirmUpload: boolean;
71
+ /** Whether the files table carries the versioning chain. */
72
+ hasVersioning: boolean;
70
73
  /** Max files per requestBulkUploadUrls batch (default: 100) */
71
74
  maxBulkFiles: number;
72
75
  /** Max total size per bulk upload batch in bytes (default: 1GB) */
@@ -174,51 +177,10 @@ export interface S3Config {
174
177
  * env-var reads and S3Client creation at module import time.
175
178
  */
176
179
  export type S3ConfigOrGetter = S3Config | (() => S3Config);
177
- /**
178
- * Function to derive the actual S3 bucket name for a given database and bucket key.
179
- *
180
- * When provided, the presigned URL plugin calls this on every request
181
- * to determine which S3 bucket to use — enabling per-(database, bucketKey)
182
- * isolation. If not provided, falls back to `s3Config.bucket` (global).
183
- *
184
- * @param databaseId - The metaschema database UUID
185
- * @param bucketKey - The logical bucket key (e.g., "public", "private")
186
- * @returns The S3 bucket name for this database + bucket key
187
- */
188
- export type BucketNameResolver = (databaseId: string, bucketKey: string) => string;
189
- /**
190
- * Callback to lazily provision an S3 bucket on first use.
191
- *
192
- * Called by the presigned URL plugin before generating a presigned PUT URL
193
- * when the bucket has not been seen before (tracked in an in-memory cache).
194
- * The implementation should create and fully configure the S3 bucket
195
- * (privacy policies, CORS, lifecycle rules, etc.) — or no-op if the
196
- * bucket already exists.
197
- *
198
- * @param bucketName - The S3 bucket name to provision
199
- * @param accessType - The logical bucket type ('public', 'private', 'temp')
200
- * @param databaseId - The metaschema database UUID
201
- * @param allowedOrigins - Per-database CORS origins (from storage_module), or null to use global fallback
202
- */
203
- export type EnsureBucketProvisioned = (bucketName: string, accessType: 'public' | 'private' | 'temp', databaseId: string, allowedOrigins: string[] | null) => Promise<void>;
204
180
  /**
205
181
  * Plugin options for the presigned URL plugin.
206
182
  */
207
183
  export interface PresignedUrlPluginOptions {
208
184
  /** S3 configuration (concrete or lazy getter) */
209
185
  s3: S3ConfigOrGetter;
210
- /**
211
- * Optional function to resolve S3 bucket name per-database.
212
- * When set, each database gets its own S3 bucket instead of sharing
213
- * the global `s3Config.bucket`. The S3 credentials (client) remain shared.
214
- */
215
- resolveBucketName?: BucketNameResolver;
216
- /**
217
- * Optional callback to lazily provision an S3 bucket on first upload.
218
- * When set, the plugin calls this before generating a presigned PUT URL
219
- * for any S3 bucket it hasn't seen yet (tracked in an in-memory cache).
220
- * This enables graceful bucket creation without requiring buckets to
221
- * exist at database provisioning time.
222
- */
223
- ensureBucketProvisioned?: EnsureBucketProvisioned;
224
186
  }