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/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/index.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * ```
29
29
  */
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.readObjectPrefix = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.copyS3Object = exports.s3FailureError = exports.describeS3Failure = exports.withRequestPgClient = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.resolveS3ForDatabase = exports.resolveS3 = exports.provisionAndRecordPhysicalBucket = exports.mintPhysicalBucketName = exports.resolveManagedUploadTarget = exports.finalizeStagedUpload = exports.buildFileProjection = exports.assertUploadAllowedByBucket = exports.getFileRefFieldBinding = exports.FileRefFieldNotRegisteredError = exports.clearFileRefFieldCache = exports.createDownloadUrlPlugin = exports.resolveDefaultBucket = exports.validateCustomKey = exports.confirmUploadedBytes = exports.CONFIRM_PREFIX_BYTES = void 0;
31
+ exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.loadAllStorageModules = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.readObjectPrefix = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.copyS3Object = exports.s3FailureError = exports.describeS3Failure = exports.withRequestPgClient = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.StorageBucketNotReconciledError = exports.resolveS3ForDatabase = exports.resolveS3 = exports.assertBucketReconciled = exports.resolveManagedUploadTarget = exports.finalizeStagedUpload = exports.buildFileProjection = exports.assertUploadAllowedByBucket = exports.getFileRefFieldBinding = exports.FileRefFieldNotRegisteredError = exports.clearFileRefFieldCache = exports.createDownloadUrlPlugin = exports.resolveDefaultBucket = exports.validateCustomKey = exports.confirmUploadedBytes = exports.CONFIRM_PREFIX_BYTES = void 0;
32
32
  var confirm_upload_1 = require("./confirm-upload");
33
33
  Object.defineProperty(exports, "CONFIRM_PREFIX_BYTES", { enumerable: true, get: function () { return confirm_upload_1.CONFIRM_PREFIX_BYTES; } });
34
34
  Object.defineProperty(exports, "confirmUploadedBytes", { enumerable: true, get: function () { return confirm_upload_1.confirmUploadedBytes; } });
@@ -48,10 +48,10 @@ Object.defineProperty(exports, "buildFileProjection", { enumerable: true, get: f
48
48
  Object.defineProperty(exports, "finalizeStagedUpload", { enumerable: true, get: function () { return managed_upload_1.finalizeStagedUpload; } });
49
49
  Object.defineProperty(exports, "resolveManagedUploadTarget", { enumerable: true, get: function () { return managed_upload_1.resolveManagedUploadTarget; } });
50
50
  var physical_bucket_1 = require("./physical-bucket");
51
- Object.defineProperty(exports, "mintPhysicalBucketName", { enumerable: true, get: function () { return physical_bucket_1.mintPhysicalBucketName; } });
52
- Object.defineProperty(exports, "provisionAndRecordPhysicalBucket", { enumerable: true, get: function () { return physical_bucket_1.provisionAndRecordPhysicalBucket; } });
51
+ Object.defineProperty(exports, "assertBucketReconciled", { enumerable: true, get: function () { return physical_bucket_1.assertBucketReconciled; } });
53
52
  Object.defineProperty(exports, "resolveS3", { enumerable: true, get: function () { return physical_bucket_1.resolveS3; } });
54
53
  Object.defineProperty(exports, "resolveS3ForDatabase", { enumerable: true, get: function () { return physical_bucket_1.resolveS3ForDatabase; } });
54
+ Object.defineProperty(exports, "StorageBucketNotReconciledError", { enumerable: true, get: function () { return physical_bucket_1.StorageBucketNotReconciledError; } });
55
55
  var plugin_1 = require("./plugin");
56
56
  Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
57
57
  Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
@@ -73,8 +73,6 @@ var storage_module_cache_1 = require("./storage-module-cache");
73
73
  Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
74
74
  Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
75
75
  Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
76
- Object.defineProperty(exports, "isS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.isS3BucketProvisioned; } });
77
76
  Object.defineProperty(exports, "loadAllStorageModules", { enumerable: true, get: function () { return storage_module_cache_1.loadAllStorageModules; } });
78
- Object.defineProperty(exports, "markS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.markS3BucketProvisioned; } });
79
77
  Object.defineProperty(exports, "resolveStorageConfigFromCodec", { enumerable: true, get: function () { return storage_module_cache_1.resolveStorageConfigFromCodec; } });
80
78
  Object.defineProperty(exports, "resolveStorageModuleByFileId", { enumerable: true, get: function () { return storage_module_cache_1.resolveStorageModuleByFileId; } });
package/managed-upload.js CHANGED
@@ -30,6 +30,7 @@ const file_ref_registry_1 = require("./file-ref-registry");
30
30
  const physical_bucket_1 = require("./physical-bucket");
31
31
  const request_pg_client_1 = require("./request-pg-client");
32
32
  const s3_signer_1 = require("./s3-signer");
33
+ const storage_file_recorder_1 = require("./storage-file-recorder");
33
34
  const storage_module_cache_1 = require("./storage-module-cache");
34
35
  const log = new logger_1.Logger('graphile-presigned-url:managed-upload');
35
36
  /**
@@ -139,9 +140,7 @@ async function resolveManagedUploadTarget(args) {
139
140
  'the multipart upload lane only writes content-addressed keys. Use the presigned upload ' +
140
141
  'mutation with an explicit key.');
141
142
  }
142
- const physicalName = bucket.physical_name === null
143
- ? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
144
- : bucket.physical_name;
143
+ const physicalName = (0, physical_bucket_1.assertBucketReconciled)(bucket, databaseId);
145
144
  return {
146
145
  databaseId,
147
146
  storageConfig,
@@ -258,22 +257,14 @@ async function finalizeStagedUpload(args) {
258
257
  let fileId;
259
258
  try {
260
259
  fileId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
261
- const result = await pgClient.query({
262
- text: `INSERT INTO ${storageConfig.filesQualifiedName}
263
- (bucket_id, key, content_hash, mime_type, size, filename, is_public)
264
- VALUES ($1, $2, $3, $4, $5, $6, $7)
265
- RETURNING id`,
266
- values: [
267
- bucket.id,
268
- finalKey,
269
- staged.contentHash,
270
- staged.contentType,
271
- staged.size,
272
- staged.filename ?? null,
273
- bucket.is_public,
274
- ],
260
+ return (0, storage_file_recorder_1.recordManagedFile)(pgClient, storageConfig, {
261
+ bucketId: bucket.id,
262
+ key: finalKey,
263
+ contentHash: staged.contentHash,
264
+ mimeType: staged.contentType,
265
+ size: staged.size,
266
+ filename: staged.filename,
275
267
  });
276
- return result.rows[0].id;
277
268
  });
278
269
  }
279
270
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-presigned-url-plugin",
3
- "version": "1.16.1",
3
+ "version": "1.18.0",
4
4
  "description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -62,5 +62,5 @@
62
62
  "@types/node": "^22.19.11",
63
63
  "makage": "^0.3.0"
64
64
  },
65
- "gitHead": "7e6cc68f5659d12ec852835a7d8fcca6b05a8f50"
65
+ "gitHead": "77d58bb93796be91d4e00156bb6847392584d3bc"
66
66
  }
@@ -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,25 +1,32 @@
1
1
  "use strict";
2
2
  /**
3
- * Physical bucket coordinates: minting a name once, recording it, and building
4
- * an S3 config against a *known* name.
3
+ * Physical bucket coordinates: reading the reconciler's recorded name and
4
+ * building an S3 config against that known name.
5
5
  *
6
6
  * A logical bucket belongs to a tenant; a physical bucket is an S3 name. The
7
- * mapping is recorded on the bucket row the first time it is provisioned, and
8
- * from then on the recorded value is the only coordinate anything reads — no
9
- * name is ever recomputed from a prefix convention, and there is no
10
- * environment-level bucket standing in for a tenant's.
7
+ * mapping is recorded on the bucket row by the storage reconciler, and that
8
+ * value is the only coordinate anything reads — no name is ever recomputed.
11
9
  */
12
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.StorageBucketNotReconciledError = void 0;
13
12
  exports.resolveS3 = resolveS3;
14
- exports.mintPhysicalBucketName = mintPhysicalBucketName;
15
13
  exports.resolveS3ForDatabase = resolveS3ForDatabase;
16
- exports.provisionAndRecordPhysicalBucket = provisionAndRecordPhysicalBucket;
17
- const logger_1 = require("@pgpmjs/logger");
18
- const graphile_storage_registry_1 = require("graphile-storage-registry");
19
- const request_pg_client_1 = require("./request-pg-client");
20
- const s3_failure_1 = require("./s3-failure");
21
- const storage_module_cache_1 = require("./storage-module-cache");
22
- const log = new logger_1.Logger('graphile-presigned-url:physical-bucket');
14
+ exports.assertBucketReconciled = assertBucketReconciled;
15
+ class StorageBucketNotReconciledError extends Error {
16
+ code = 'STORAGE_BUCKET_NOT_RECONCILED';
17
+ retryable = true;
18
+ extensions = {
19
+ code: 'STORAGE_BUCKET_NOT_RECONCILED',
20
+ retryable: true,
21
+ };
22
+ constructor(bucket, databaseId) {
23
+ super(`STORAGE_BUCKET_NOT_RECONCILED: bucket "${bucket.key}" (id=${bucket.id}) ` +
24
+ `for database ${databaseId} has not yet been reconciled; the reconciler has ` +
25
+ 'not yet recorded a physical name');
26
+ this.name = 'StorageBucketNotReconciledError';
27
+ }
28
+ }
29
+ exports.StorageBucketNotReconciledError = StorageBucketNotReconciledError;
23
30
  /**
24
31
  * Resolve the plugin's S3 connection (credentials, endpoint, region), memoizing
25
32
  * a lazy getter on first use.
@@ -36,31 +43,10 @@ function resolveS3(options) {
36
43
  }
37
44
  return options.s3;
38
45
  }
39
- /**
40
- * Mint the physical S3 bucket name for a logical bucket's first provision.
41
- *
42
- * This is a naming *policy*, consulted exactly once per bucket — before the
43
- * physical bucket exists. Once provisioned, the recorded `physical_name` on the
44
- * row is authoritative and this function must not be consulted again.
45
- *
46
- * There is no fallback to the configured `s3.bucket`: a deployment-wide bucket
47
- * name is not a tenant's storage, and silently minting one is how objects ended
48
- * up in a bucket no database owned. A deployment that wants per-tenant buckets
49
- * must supply the policy.
50
- */
51
- function mintPhysicalBucketName(options, databaseId, bucketKey) {
52
- if (!options.resolveBucketName) {
53
- throw new Error('STORAGE_BUCKET_NAME_POLICY_MISSING: no resolveBucketName was configured, so there is ' +
54
- `no name to provision for bucket "${bucketKey}" of database ${databaseId}. ` +
55
- 'Physical bucket naming is a deployment policy; the configured s3.bucket is a ' +
56
- 'connection default and is never a tenant bucket.');
57
- }
58
- return options.resolveBucketName(databaseId, bucketKey);
59
- }
60
46
  /**
61
47
  * Build the S3 config for a *known* physical bucket. `physicalName` is
62
- * required — callers must resolve the coordinate (stored row value, or a
63
- * freshly provisioned name) before getting here. No name is ever recomputed.
48
+ * required — callers must resolve the coordinate from the stored row value
49
+ * before getting here. No name is ever recomputed.
64
50
  */
65
51
  function resolveS3ForDatabase(options, storageConfig, physicalName) {
66
52
  const globalS3 = resolveS3(options);
@@ -77,46 +63,11 @@ function resolveS3ForDatabase(options, storageConfig, physicalName) {
77
63
  };
78
64
  }
79
65
  /**
80
- * First provision of a logical bucket: mint a name, create the physical S3
81
- * bucket, and record the exact name on the source row. Returns the recorded
82
- * physical name.
83
- *
84
- * Only called when the row has no `physical_name` yet. Afterwards the stored
85
- * value is the durable coordinate: route resolution and every later read use
86
- * it verbatim; nothing is recomputed.
87
- *
88
- * The record write runs in the system lane (privileged role, so it bypasses the
89
- * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
90
- * bookkeeping, not request data. It still carries the tenant `database_id`
91
- * claim, because the buckets table's catalog-sync trigger calls
92
- * `jwt_private.current_database_id()` and would otherwise raise
93
- * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
94
- * write's transaction without switching off the privileged role.
95
- * `bucket` (the cached config) is mutated in place so subsequent reads observe
96
- * the recorded name without a DB round-trip.
66
+ * Return the reconciler's recorded physical name, or fail with a typed,
67
+ * retryable error while reconciliation is still pending.
97
68
  */
98
- async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
99
- const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
100
- if (options.ensureBucketProvisioned && !(0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName)) {
101
- log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
102
- try {
103
- await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
104
- }
105
- catch (err) {
106
- // The first upload to a bucket is where an unreachable object store is
107
- // discovered, and the transport's own message is routinely empty: name the
108
- // endpoint it could not reach so the response says what is misconfigured.
109
- throw (0, s3_failure_1.s3FailureError)('BUCKET_PROVISION_FAILED', { endpoint: resolveS3(options).endpoint, bucket: s3BucketName, databaseId }, err);
110
- }
111
- (0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
112
- log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
113
- }
114
- // Record the physical coordinate on the source row. The `physical_name IS NULL`
115
- // guard keeps this idempotent and race-safe across concurrent first uploads.
116
- // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
117
- // write runs under the resolved database claim (privileged role preserved).
118
- await (0, request_pg_client_1.withRequestPgClient)(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => (0, graphile_storage_registry_1.recordPhysicalName)((query) => client.query(query), storageConfig.bucketsQualifiedName, bucket.id, s3BucketName));
119
- bucket.physical_name = s3BucketName;
120
- log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
121
- return s3BucketName;
69
+ function assertBucketReconciled(bucket, databaseId) {
70
+ if (bucket.physical_name !== null)
71
+ return bucket.physical_name;
72
+ throw new StorageBucketNotReconciledError(bucket, databaseId);
122
73
  }
package/plugin.js CHANGED
@@ -34,6 +34,7 @@ const managed_upload_1 = require("./managed-upload");
34
34
  const physical_bucket_1 = require("./physical-bucket");
35
35
  const request_pg_client_1 = require("./request-pg-client");
36
36
  const s3_signer_1 = require("./s3-signer");
37
+ const storage_file_recorder_1 = require("./storage-file-recorder");
37
38
  const storage_module_cache_1 = require("./storage-module-cache");
38
39
  const log = new logger_1.Logger('graphile-presigned-url:plugin');
39
40
  // --- Protocol-level constants (not configurable) ---
@@ -202,11 +203,9 @@ function createPresignedUrlPlugin(options) {
202
203
  const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
203
204
  if (!bucket)
204
205
  throw new Error('BUCKET_NOT_FOUND');
205
- // First provision mints + records the coordinate; afterwards the
206
- // stored physical_name is authoritative and nothing is recomputed.
207
- const physicalName = bucket.physical_name === null
208
- ? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
209
- : bucket.physical_name;
206
+ // The reconciler records the coordinate; consumers never
207
+ // recompute it from the logical bucket row.
208
+ const physicalName = (0, physical_bucket_1.assertBucketReconciled)(bucket, databaseId);
210
209
  const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
211
210
  // File row INSERT under the request role (RLS enforced).
212
211
  return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
@@ -309,11 +308,9 @@ function createPresignedUrlPlugin(options) {
309
308
  if (totalSize > storageConfig.maxBulkTotalSize) {
310
309
  throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
311
310
  }
312
- // First provision mints + records the coordinate; afterwards the
313
- // stored physical_name is authoritative and nothing is recomputed.
314
- const physicalName = bucket.physical_name === null
315
- ? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
316
- : bucket.physical_name;
311
+ // The reconciler records the coordinate; consumers never
312
+ // recompute it from the logical bucket row.
313
+ const physicalName = (0, physical_bucket_1.assertBucketReconciled)(bucket, databaseId);
317
314
  const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
318
315
  // File row INSERTs under the request role (RLS enforced).
319
316
  return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, async (txClient) => {
@@ -616,32 +613,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
616
613
  }
617
614
  // Auto-derive ltree path from custom key directory (only when has_path_shares)
618
615
  const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
619
- // Create file record. An entity-keyed plane (the module records an entity
620
- // table) carries owner_id on its rows; app- and database-scope planes do not.
621
- const hasOwnerColumn = storageConfig.entityTableId !== null;
622
- const columns = ['bucket_id', 'key', 'content_hash', 'mime_type', 'size', 'filename', 'is_public'];
623
- const values = [bucket.id, s3Key, contentHash, contentType, size, filename || null, bucket.is_public];
624
- if (hasOwnerColumn) {
625
- columns.push('owner_id');
626
- values.push(bucket.owner_id);
627
- }
628
- if (previousVersionId) {
629
- columns.push('previous_version_id');
630
- values.push(previousVersionId);
631
- }
632
- if (derivedPath) {
633
- columns.push('path');
634
- values.push(derivedPath);
635
- }
636
- const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
637
- const fileResult = await txClient.query({
638
- text: `INSERT INTO ${storageConfig.filesQualifiedName}
639
- (${columns.join(', ')})
640
- VALUES (${placeholders})
641
- RETURNING id`,
642
- values,
616
+ const fileId = await (0, storage_file_recorder_1.recordManagedFile)(txClient, storageConfig, {
617
+ bucketId: bucket.id,
618
+ key: s3Key,
619
+ contentHash,
620
+ mimeType: contentType,
621
+ size,
622
+ filename: filename || null,
623
+ previousVersionId,
624
+ path: derivedPath,
643
625
  });
644
- const fileId = fileResult.rows[0].id;
645
626
  // Generate presigned PUT URL
646
627
  const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
647
628
  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,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.recordManagedFile = recordManagedFile;
4
+ /**
5
+ * Record a managed file through the storage plane's generated recorder.
6
+ *
7
+ * The recorder owns the files-table insert so bucket inheritance and claims
8
+ * attribution run at the database boundary. Optional arguments are only named
9
+ * when the generated function supports them and the caller has a value.
10
+ */
11
+ async function recordManagedFile(pgClient, storageConfig, input) {
12
+ if (!storageConfig.recorderQualifiedName) {
13
+ throw new Error(`STORAGE_RECORDER_MISSING: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
14
+ `must expose ${storageConfig.filesTableName}_record_file in its private schema`);
15
+ }
16
+ if (input.previousVersionId != null && !storageConfig.hasVersioning) {
17
+ throw new Error(`STORAGE_VERSIONING_UNSUPPORTED: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
18
+ 'does not support previous_version_id');
19
+ }
20
+ if (input.path != null && !storageConfig.hasPathShares) {
21
+ throw new Error(`STORAGE_PATH_SHARES_UNSUPPORTED: storage module ${storageConfig.id} (${storageConfig.filesTableName}) ` +
22
+ 'does not support path');
23
+ }
24
+ const args = [
25
+ 'bucket_id := $1::uuid',
26
+ 'key := $2::text',
27
+ 'content_hash := $3::text',
28
+ 'mime_type := $4::text',
29
+ 'size := $5::bigint',
30
+ 'filename := $6::text',
31
+ 'upload := $7::jsonb',
32
+ ];
33
+ const values = [
34
+ input.bucketId,
35
+ input.key,
36
+ input.contentHash,
37
+ input.mimeType,
38
+ input.size,
39
+ input.filename ?? null,
40
+ null,
41
+ ];
42
+ if (storageConfig.hasVersioning && input.previousVersionId != null) {
43
+ args.push(`previous_version_id := $${values.length + 1}::uuid`);
44
+ values.push(input.previousVersionId);
45
+ }
46
+ if (storageConfig.hasPathShares && input.path != null) {
47
+ args.push(`path := $${values.length + 1}::text`);
48
+ values.push(input.path);
49
+ }
50
+ const result = await pgClient.query({
51
+ text: `SELECT id FROM ${storageConfig.recorderQualifiedName}(${args.join(', ')})`,
52
+ values,
53
+ });
54
+ return result.rows[0].id;
55
+ }
@@ -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.
@@ -5,8 +5,6 @@ exports.loadAllStorageModules = loadAllStorageModules;
5
5
  exports.resolveStorageConfigFromCodec = resolveStorageConfigFromCodec;
6
6
  exports.storedPhysicalName = storedPhysicalName;
7
7
  exports.getBucketConfig = getBucketConfig;
8
- exports.isS3BucketProvisioned = isS3BucketProvisioned;
9
- exports.markS3BucketProvisioned = markS3BucketProvisioned;
10
8
  exports.clearStorageModuleCache = clearStorageModuleCache;
11
9
  exports.clearBucketCache = clearBucketCache;
12
10
  const logger_1 = require("@pgpmjs/logger");
@@ -52,6 +50,7 @@ const ALL_STORAGE_MODULES_QUERY = `
52
50
  bt.name AS buckets_table,
53
51
  fs.schema_name AS files_schema,
54
52
  ft.name AS files_table,
53
+ ps.schema_name AS private_schema,
55
54
  sm.endpoint,
56
55
  sm.public_url_prefix,
57
56
  sm.provider,
@@ -64,6 +63,7 @@ const ALL_STORAGE_MODULES_QUERY = `
64
63
  sm.max_bulk_files,
65
64
  sm.max_bulk_total_size,
66
65
  sm.has_path_shares,
66
+ sm.has_versioning,
67
67
  sm.has_confirm_upload,
68
68
  es.schema_name AS entity_schema,
69
69
  et.name AS entity_table
@@ -72,6 +72,7 @@ const ALL_STORAGE_MODULES_QUERY = `
72
72
  JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
73
73
  JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
74
74
  JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
75
+ LEFT JOIN metaschema_public.schema ps ON ps.id = sm.private_schema_id
75
76
  LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
76
77
  LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
77
78
  WHERE sm.database_id = $1
@@ -85,6 +86,9 @@ function buildConfig(row) {
85
86
  id: row.id,
86
87
  bucketsQualifiedName: quotes_1.QuoteUtils.quoteQualifiedIdentifier(row.buckets_schema, row.buckets_table),
87
88
  filesQualifiedName: quotes_1.QuoteUtils.quoteQualifiedIdentifier(row.files_schema, row.files_table),
89
+ recorderQualifiedName: row.private_schema
90
+ ? quotes_1.QuoteUtils.quoteQualifiedIdentifier(row.private_schema, `${row.files_table}_record_file`)
91
+ : null,
88
92
  schemaName: row.buckets_schema,
89
93
  bucketsTableName: row.buckets_table,
90
94
  filesTableName: row.files_table,
@@ -103,6 +107,7 @@ function buildConfig(row) {
103
107
  maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
104
108
  cacheTtlSeconds,
105
109
  hasPathShares: row.has_path_shares ?? false,
110
+ hasVersioning: row.has_versioning ?? false,
106
111
  hasConfirmUpload: row.has_confirm_upload ?? false,
107
112
  maxBulkFiles: row.max_bulk_files ?? DEFAULT_MAX_BULK_FILES,
108
113
  maxBulkTotalSize: row.max_bulk_total_size ?? DEFAULT_MAX_BULK_TOTAL_SIZE,
@@ -264,34 +269,6 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, o
264
269
  log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.scope})`);
265
270
  return config;
266
271
  }
267
- // --- S3 bucket existence cache ---
268
- /**
269
- * In-memory set of S3 bucket names that are known to exist.
270
- *
271
- * Used by the lazy provisioning logic in the presigned URL plugin:
272
- * before generating a presigned PUT URL, the plugin checks this set.
273
- * If the bucket name is absent, it calls `ensureBucketProvisioned`
274
- * to create the S3 bucket, then adds the name here. Subsequent
275
- * requests for the same bucket skip the provisioning entirely.
276
- *
277
- * No TTL needed — S3 buckets are never deleted during normal operation.
278
- * The set resets on server restart, which is fine because the
279
- * provisioner's createBucket is idempotent (handles "already exists").
280
- */
281
- const provisionedBuckets = new Set();
282
- /**
283
- * Check whether an S3 bucket has already been provisioned (cached).
284
- */
285
- function isS3BucketProvisioned(s3BucketName) {
286
- return provisionedBuckets.has(s3BucketName);
287
- }
288
- /**
289
- * Mark an S3 bucket as provisioned in the in-memory cache.
290
- */
291
- function markS3BucketProvisioned(s3BucketName) {
292
- provisionedBuckets.add(s3BucketName);
293
- log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
294
- }
295
272
  /**
296
273
  * Clear the storage module cache AND bucket cache.
297
274
  * Useful for testing or schema changes.
@@ -299,7 +276,6 @@ function markS3BucketProvisioned(s3BucketName) {
299
276
  function clearStorageModuleCache() {
300
277
  storageModuleCache.clear();
301
278
  bucketCache.clear();
302
- provisionedBuckets.clear();
303
279
  }
304
280
  /**
305
281
  * Clear cached bucket entries for a specific database.