graphile-presigned-url-plugin 1.11.4 → 1.12.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/plugin.js CHANGED
@@ -19,9 +19,12 @@
19
19
  import 'graphile-build';
20
20
  import { Logger } from '@pgpmjs/logger';
21
21
  import { access, context as grafastContext, lambda, object } from 'grafast';
22
+ import { resolveDefaultBucket } from './default-bucket';
23
+ import { buildFileProjection } from './managed-upload';
24
+ import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
22
25
  import { withRequestPgClient } from './request-pg-client';
23
26
  import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
24
- import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
27
+ import { getBucketConfig, loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
25
28
  const log = new Logger('graphile-presigned-url:plugin');
26
29
  // --- Protocol-level constants (not configurable) ---
27
30
  const MAX_CONTENT_HASH_LENGTH = 128;
@@ -67,87 +70,20 @@ async function resolveDatabaseId(pgClient) {
67
70
  });
68
71
  return result.rows[0]?.id ?? null;
69
72
  }
70
- function resolveS3(options) {
71
- if (typeof options.s3 === 'function') {
72
- const resolved = options.s3();
73
- options.s3 = resolved;
74
- return resolved;
75
- }
76
- return options.s3;
77
- }
78
73
  /**
79
- * Mint the physical S3 bucket name for a logical bucket's first provision.
74
+ * Resolve the bucket an upload mutation writes into.
80
75
  *
81
- * This is a naming *policy*, consulted exactly once per bucket — before the
82
- * physical bucket exists. Once provisioned, the recorded `physical_name` on
83
- * the row is authoritative and this function must not be consulted again.
76
+ * A named `bucketKey` is the caller's override and is read directly, as before.
77
+ * An omitted one asks the database for the tenant's reserved default tag for the
78
+ * requested access, so a missing or ambiguous default raises in SQL rather than
79
+ * falling back to a server-global bucket name here.
84
80
  */
85
- function mintPhysicalBucketName(options, databaseId, bucketKey) {
86
- if (options.resolveBucketName) {
87
- return options.resolveBucketName(databaseId, bucketKey);
81
+ async function resolveUploadBucket(pgClient, storageConfig, databaseId, bucketKey, ownerId, isPublic) {
82
+ if (bucketKey) {
83
+ return getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, ownerId || undefined);
88
84
  }
89
- // Single-bucket deployment: the globally configured bucket is the physical bucket.
90
- return resolveS3(options).bucket;
91
- }
92
- /**
93
- * Build the S3 config for a *known* physical bucket. `physicalName` is
94
- * required — callers must resolve the coordinate (stored row value, or a
95
- * freshly provisioned name) before getting here. No name is ever recomputed.
96
- */
97
- function resolveS3ForDatabase(options, storageConfig, physicalName) {
98
- const globalS3 = resolveS3(options);
99
- const publicUrlPrefix = storageConfig.publicUrlPrefix != null
100
- ? storageConfig.publicUrlPrefix
101
- : globalS3.publicUrlPrefix;
102
- if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
103
- return globalS3;
104
- }
105
- return {
106
- ...globalS3,
107
- bucket: physicalName,
108
- ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
109
- };
110
- }
111
- /**
112
- * First provision of a logical bucket: mint a name, create the physical S3
113
- * bucket, and record the exact name on the source row. Returns the recorded
114
- * physical name.
115
- *
116
- * Only called when the row has no `physical_name` yet. Afterwards the stored
117
- * value is the durable coordinate: route resolution and every later read use
118
- * it verbatim; nothing is recomputed.
119
- *
120
- * The record write runs in the system lane (privileged role, so it bypasses the
121
- * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
122
- * bookkeeping, not request data. It still carries the tenant `database_id`
123
- * claim, because the buckets table's catalog-sync trigger calls
124
- * `jwt_private.current_database_id()` and would otherwise raise
125
- * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
126
- * write's transaction without switching off the privileged role.
127
- * `bucket` (the cached config) is mutated in place so subsequent reads observe
128
- * the recorded name without a DB round-trip.
129
- */
130
- async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
131
- const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
132
- if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) {
133
- log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
134
- await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
135
- markS3BucketProvisioned(s3BucketName);
136
- log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
137
- }
138
- // Record the physical coordinate on the source row. The `physical_name IS NULL`
139
- // guard keeps this idempotent and race-safe across concurrent first uploads.
140
- // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
141
- // write runs under the resolved database claim (privileged role preserved).
142
- await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
143
- text: `UPDATE ${storageConfig.bucketsQualifiedName}
144
- SET physical_name = $1
145
- WHERE id = $2 AND physical_name IS NULL`,
146
- values: [s3BucketName, bucket.id],
147
- }));
148
- bucket.physical_name = s3BucketName;
149
- log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
150
- return s3BucketName;
85
+ const coordinate = await resolveDefaultBucket(pgClient, databaseId, storageConfig.scope, ownerId, isPublic, null);
86
+ return getBucketConfig(pgClient, storageConfig, databaseId, coordinate.resolvedKey, ownerId || undefined);
151
87
  }
152
88
  // --- Plugin factory ---
153
89
  export function createPresignedUrlPlugin(options) {
@@ -166,6 +102,14 @@ export function createPresignedUrlPlugin(options) {
166
102
  if (!isRootMutation)
167
103
  return fields;
168
104
  const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
105
+ // The projection document is jsonb-shaped. PostGraphile registers a JSON
106
+ // scalar whenever the schema has a jsonb column, which any storage-equipped
107
+ // database does; if it is absent the payload simply omits the field rather
108
+ // than failing schema build over a field nothing can have asked for yet.
109
+ const jsonType = build.getTypeByName('JSON') ?? null;
110
+ if (!jsonType) {
111
+ log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
112
+ }
169
113
  const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
170
114
  if (bucketCodecs.length === 0)
171
115
  return fields;
@@ -199,7 +143,8 @@ export function createPresignedUrlPlugin(options) {
199
143
  const InputType = new GraphQLInputObjectType({
200
144
  name: `Upload${filesTypeName}Input`,
201
145
  fields: {
202
- bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
146
+ bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
147
+ isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted: the public one (true) or the private one (default false). Ignored when bucketKey is given.' },
203
148
  ...(hasOwnerId
204
149
  ? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
205
150
  : {}),
@@ -219,6 +164,16 @@ export function createPresignedUrlPlugin(options) {
219
164
  deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
220
165
  expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
221
166
  previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
167
+ ...(jsonType
168
+ ? {
169
+ file: {
170
+ type: jsonType,
171
+ description: 'The projection document for the created file: {id, key, bucket_id, mime, size, filename, url?}. ' +
172
+ 'Store this verbatim in an image/upload column — its `id` is what keeps the object from being ' +
173
+ 'garbage collected while the column still references it.',
174
+ },
175
+ }
176
+ : {}),
222
177
  },
223
178
  });
224
179
  const capturedFilesCodec = filesCodec;
@@ -232,6 +187,7 @@ export function createPresignedUrlPlugin(options) {
232
187
  plan(_$mutation, fieldArgs) {
233
188
  const $input = fieldArgs.getRaw('input');
234
189
  const $bucketKey = access($input, 'bucketKey');
190
+ const $isPublic = access($input, 'isPublic');
235
191
  const $contentHash = access($input, 'contentHash');
236
192
  const $contentType = access($input, 'contentType');
237
193
  const $size = access($input, 'size');
@@ -242,6 +198,7 @@ export function createPresignedUrlPlugin(options) {
242
198
  const $pgSettings = grafastContext().get('pgSettings');
243
199
  const $combined = object({
244
200
  bucketKey: $bucketKey,
201
+ isPublic: $isPublic,
245
202
  ownerId: $ownerId,
246
203
  contentHash: $contentHash,
247
204
  contentType: $contentType,
@@ -264,8 +221,8 @@ export function createPresignedUrlPlugin(options) {
264
221
  const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
265
222
  if (!storageConfig)
266
223
  throw new Error('STORAGE_MODULE_NOT_FOUND');
267
- // Bucket config read under the request role (RLS-gated visibility).
268
- const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
224
+ // Bucket resolution + read under the request role (RLS-gated visibility).
225
+ const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
269
226
  if (!bucket)
270
227
  throw new Error('BUCKET_NOT_FOUND');
271
228
  // First provision mints + records the coordinate; afterwards the
@@ -305,12 +262,14 @@ export function createPresignedUrlPlugin(options) {
305
262
  deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
306
263
  expiresAt: { type: GraphQLString },
307
264
  previousVersionId: { type: GraphQLString },
265
+ ...(jsonType ? { file: { type: jsonType, description: 'The projection document for the created file.' } } : {}),
308
266
  },
309
267
  });
310
268
  const BulkInputType = new GraphQLInputObjectType({
311
269
  name: `Upload${filesTypeName}BulkInput`,
312
270
  fields: {
313
- bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
271
+ bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
272
+ isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
314
273
  ...(hasOwnerId
315
274
  ? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
316
275
  : {}),
@@ -334,12 +293,14 @@ export function createPresignedUrlPlugin(options) {
334
293
  plan(_$mutation, fieldArgs) {
335
294
  const $input = fieldArgs.getRaw('input');
336
295
  const $bucketKey = access($input, 'bucketKey');
296
+ const $isPublic = access($input, 'isPublic');
337
297
  const $ownerId = hasOwnerId ? access($input, 'ownerId') : lambda(null, () => null);
338
298
  const $files = access($input, 'files');
339
299
  const $withPgClient = grafastContext().get('withPgClient');
340
300
  const $pgSettings = grafastContext().get('pgSettings');
341
301
  const $combined = object({
342
302
  bucketKey: $bucketKey,
303
+ isPublic: $isPublic,
343
304
  ownerId: $ownerId,
344
305
  files: $files,
345
306
  withPgClient: $withPgClient,
@@ -358,8 +319,8 @@ export function createPresignedUrlPlugin(options) {
358
319
  const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
359
320
  if (!storageConfig)
360
321
  throw new Error('STORAGE_MODULE_NOT_FOUND');
361
- // Bucket config read under the request role (RLS-gated visibility).
362
- const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
322
+ // Bucket resolution + read under the request role (RLS-gated visibility).
323
+ const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
363
324
  if (!bucket)
364
325
  throw new Error('BUCKET_NOT_FOUND');
365
326
  // Enforce bulk upload limits
@@ -564,6 +525,11 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
564
525
  if (bucket.max_file_size && size > bucket.max_file_size) {
565
526
  throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
566
527
  }
528
+ // The projection document the caller stores in an image/upload column. Built
529
+ // from the same values the files row carries, so the column and the row cannot
530
+ // disagree, and it names the files row by id — which is what stops GC from
531
+ // collecting an object a document still points at.
532
+ const projectFile = (fileId, key) => buildFileProjection({ id: fileId, key, bucketId: bucket.id, mime: contentType, size, filename }, bucket, s3ForDb);
567
533
  // Determine S3 key
568
534
  let s3Key;
569
535
  let isCustomKey = false;
@@ -604,6 +570,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
604
570
  deduplicated: true,
605
571
  expiresAt: null,
606
572
  previousVersionId: null,
573
+ file: projectFile(existing.id, s3Key),
607
574
  };
608
575
  }
609
576
  previousVersionId = existing.id;
@@ -629,6 +596,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
629
596
  deduplicated: true,
630
597
  expiresAt: null,
631
598
  previousVersionId: null,
599
+ file: projectFile(existingFile.id, s3Key),
632
600
  };
633
601
  }
634
602
  }
@@ -669,6 +637,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
669
637
  deduplicated: false,
670
638
  expiresAt,
671
639
  previousVersionId,
640
+ file: projectFile(fileId, s3Key),
672
641
  };
673
642
  }
674
643
  export const PresignedUrlPlugin = createPresignedUrlPlugin;
@@ -36,6 +36,19 @@ export declare function generatePresignedGetUrl(s3Config: S3Config, key: string,
36
36
  * @param key - S3 object key to delete
37
37
  */
38
38
  export declare function deleteS3Object(s3Config: S3Config, key: string): Promise<void>;
39
+ /**
40
+ * Copy an object within the same physical bucket, preserving its content type.
41
+ *
42
+ * Used to promote a staged upload to its content-addressed key: the bytes are
43
+ * hashed as they stream in, so the final key is only known once the stream ends.
44
+ * Server-side copy keeps that promotion off the application's wire.
45
+ *
46
+ * @param s3Config - S3 client and bucket configuration
47
+ * @param sourceKey - The staged key the bytes were written to
48
+ * @param destinationKey - The final key (the content hash)
49
+ * @param contentType - MIME type to record on the destination object
50
+ */
51
+ export declare function copyS3Object(s3Config: S3Config, sourceKey: string, destinationKey: string, contentType: string): Promise<void>;
39
52
  /**
40
53
  * Check if an object exists in S3 and optionally verify its content-type.
41
54
  *
package/esm/s3-signer.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
1
+ import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
2
2
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3
3
  import { Logger } from '@pgpmjs/logger';
4
4
  const log = new Logger('graphile-presigned-url:s3');
@@ -68,6 +68,28 @@ export async function deleteS3Object(s3Config, key) {
68
68
  }));
69
69
  log.debug(`Deleted S3 object: bucket=${s3Config.bucket}, key=${key}`);
70
70
  }
71
+ /**
72
+ * Copy an object within the same physical bucket, preserving its content type.
73
+ *
74
+ * Used to promote a staged upload to its content-addressed key: the bytes are
75
+ * hashed as they stream in, so the final key is only known once the stream ends.
76
+ * Server-side copy keeps that promotion off the application's wire.
77
+ *
78
+ * @param s3Config - S3 client and bucket configuration
79
+ * @param sourceKey - The staged key the bytes were written to
80
+ * @param destinationKey - The final key (the content hash)
81
+ * @param contentType - MIME type to record on the destination object
82
+ */
83
+ export async function copyS3Object(s3Config, sourceKey, destinationKey, contentType) {
84
+ await s3Config.client.send(new CopyObjectCommand({
85
+ Bucket: s3Config.bucket,
86
+ Key: destinationKey,
87
+ CopySource: `${s3Config.bucket}/${sourceKey}`,
88
+ ContentType: contentType,
89
+ MetadataDirective: 'REPLACE',
90
+ }));
91
+ log.debug(`Copied S3 object: bucket=${s3Config.bucket}, ${sourceKey} → ${destinationKey}`);
92
+ }
71
93
  /**
72
94
  * Check if an object exists in S3 and optionally verify its content-type.
73
95
  *
package/esm/types.d.ts CHANGED
@@ -70,8 +70,19 @@ export interface StorageModuleConfig {
70
70
  * Input for the requestUploadUrl mutation.
71
71
  */
72
72
  export interface RequestUploadUrlInput {
73
- /** Bucket key (e.g., "public", "private") */
74
- bucketKey: string;
73
+ /**
74
+ * Logical bucket key (e.g., "public", "private").
75
+ *
76
+ * Optional: when omitted the database resolves its own default bucket for the
77
+ * requested access (see `isPublic`), so a client never has to know a tenant's
78
+ * bucket naming to upload.
79
+ */
80
+ bucketKey?: string;
81
+ /**
82
+ * Which default bucket to resolve when `bucketKey` is omitted: the public one
83
+ * (true) or the private one (default false). Ignored when `bucketKey` is given.
84
+ */
85
+ isPublic?: boolean;
75
86
  /**
76
87
  * Owner entity ID for entity-scoped uploads.
77
88
  * Omit for app-level (database-wide) storage.
@@ -111,6 +122,27 @@ export interface RequestUploadUrlPayload {
111
122
  expiresAt: string | null;
112
123
  /** ID of the previous version (set when re-uploading to an existing custom key) */
113
124
  previousVersionId: string | null;
125
+ /**
126
+ * The projection document to store in an `image`/`upload` column.
127
+ *
128
+ * Its `id` is the files row, which is what makes the column a reference the
129
+ * server can count — storage GC will not collect an object while a registered
130
+ * document column still names its file.
131
+ */
132
+ file: FileProjection;
133
+ }
134
+ /**
135
+ * The document a managed `image`/`upload` column stores. See `./managed-upload`.
136
+ */
137
+ export interface FileProjection {
138
+ id: string;
139
+ key: string;
140
+ bucket_id: string;
141
+ mime: string;
142
+ size: number;
143
+ filename?: string;
144
+ /** @deprecated Read the files row's `downloadUrl` via `id` instead. */
145
+ url?: string;
114
146
  }
115
147
  /**
116
148
  * S3 configuration for the presigned URL plugin.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The `file_ref_field` registry: which storage module and bucket a managed
3
+ * document column writes into.
4
+ *
5
+ * An `image`/`upload` column is a projection of a files row, and the decision of
6
+ * *where* those bytes live is a property of the field declaration, not of the
7
+ * request. The registry records that intent per (table, column) — a storage
8
+ * module plus either a logical bucket key, a tag selector, or nothing at all
9
+ * (meaning the reserved default tag for the declared publicness).
10
+ *
11
+ * This module answers one question — "what does a write to this column bind
12
+ * to?" — and answers it loudly: an unregistered column raises rather than
13
+ * falling back to a server-global bucket, because a silent fallback is how the
14
+ * unmanaged lane produced objects no tenant owned.
15
+ */
16
+ /**
17
+ * A field's recorded storage intent.
18
+ *
19
+ * `bucketKey` and `bucketTags` are mutually exclusive by table constraint, and
20
+ * both may be absent — resolution then uses the reserved default tag for
21
+ * `isPublic`. Nothing here is a physical bucket name or id: the concrete bucket
22
+ * is resolved per written row, inside the tenant.
23
+ */
24
+ export interface FileRefFieldBinding {
25
+ id: string;
26
+ storageModuleId: string;
27
+ bucketKey: string | null;
28
+ bucketTags: string[] | null;
29
+ isPublic: boolean | null;
30
+ enforceFk: boolean;
31
+ }
32
+ export declare class FileRefFieldNotRegisteredError extends Error {
33
+ readonly databaseId: string;
34
+ readonly schemaName: string;
35
+ readonly tableName: string;
36
+ readonly columnName: string;
37
+ constructor(databaseId: string, schemaName: string, tableName: string, columnName: string);
38
+ }
39
+ /**
40
+ * Look up the storage binding for a document column, or throw.
41
+ *
42
+ * The read runs on whichever client the caller passes. The registry is schema
43
+ * metadata rather than tenant rows, so callers resolve it in the system lane —
44
+ * the RLS that matters is on the files table the upload eventually writes.
45
+ */
46
+ export declare function getFileRefFieldBinding(pgClient: {
47
+ query: (opts: {
48
+ text: string;
49
+ values?: unknown[];
50
+ }) => Promise<{
51
+ rows: unknown[];
52
+ }>;
53
+ }, databaseId: string, field: {
54
+ schemaName: string;
55
+ tableName: string;
56
+ columnName: string;
57
+ }): Promise<FileRefFieldBinding>;
58
+ /**
59
+ * Drop cached bindings. Used by tests and after a re-provision.
60
+ */
61
+ export declare function clearFileRefFieldCache(): void;
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ /**
3
+ * The `file_ref_field` registry: which storage module and bucket a managed
4
+ * document column writes into.
5
+ *
6
+ * An `image`/`upload` column is a projection of a files row, and the decision of
7
+ * *where* those bytes live is a property of the field declaration, not of the
8
+ * request. The registry records that intent per (table, column) — a storage
9
+ * module plus either a logical bucket key, a tag selector, or nothing at all
10
+ * (meaning the reserved default tag for the declared publicness).
11
+ *
12
+ * This module answers one question — "what does a write to this column bind
13
+ * to?" — and answers it loudly: an unregistered column raises rather than
14
+ * falling back to a server-global bucket, because a silent fallback is how the
15
+ * unmanaged lane produced objects no tenant owned.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.FileRefFieldNotRegisteredError = void 0;
19
+ exports.getFileRefFieldBinding = getFileRefFieldBinding;
20
+ exports.clearFileRefFieldCache = clearFileRefFieldCache;
21
+ const logger_1 = require("@pgpmjs/logger");
22
+ const lru_cache_1 = require("lru-cache");
23
+ const log = new logger_1.Logger('graphile-presigned-url:file-ref-registry');
24
+ const FIVE_MINUTES_MS = 1000 * 60 * 5;
25
+ const ONE_HOUR_MS = 1000 * 60 * 60;
26
+ /**
27
+ * Resolve the registry row for a document column.
28
+ *
29
+ * Joined through metaschema rather than keyed by name, because the registry
30
+ * records field *ids*: the physical (schema, table, column) triple is what the
31
+ * GraphQL layer knows, and metaschema is the only thing that maps one to the
32
+ * other.
33
+ */
34
+ const FILE_REF_FIELD_QUERY = `
35
+ SELECT
36
+ frf.id,
37
+ frf.storage_module_id,
38
+ frf.bucket_key,
39
+ frf.bucket_tags::text[] AS bucket_tags,
40
+ frf.is_public,
41
+ frf.enforce_fk
42
+ FROM metaschema_modules_public.file_ref_field frf
43
+ JOIN metaschema_public.field f ON f.id = frf.field_id
44
+ JOIN metaschema_public.table t ON t.id = frf.table_id
45
+ JOIN metaschema_public.schema s ON s.id = t.schema_id
46
+ WHERE frf.database_id = $1
47
+ AND s.schema_name = $2
48
+ AND t.name = $3
49
+ AND f.name = $4
50
+ LIMIT 1
51
+ `;
52
+ /**
53
+ * LRU cache of field bindings.
54
+ *
55
+ * A binding is schema, not data: it changes only when a database is
56
+ * re-provisioned, so it caches on the same terms as the storage module config
57
+ * next to it. Misses are never cached — an unregistered column is a hard error
58
+ * every time it is written, not a remembered "no".
59
+ */
60
+ const bindingCache = new lru_cache_1.LRUCache({
61
+ max: 500,
62
+ ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
63
+ updateAgeOnGet: true,
64
+ });
65
+ class FileRefFieldNotRegisteredError extends Error {
66
+ databaseId;
67
+ schemaName;
68
+ tableName;
69
+ columnName;
70
+ constructor(databaseId, schemaName, tableName, columnName) {
71
+ super(`FILE_REF_FIELD_NOT_REGISTERED: ${schemaName}.${tableName}.${columnName} ` +
72
+ `is not a registered file-reference field in database ${databaseId}. ` +
73
+ 'A managed upload needs the declared storage module and bucket intent; ' +
74
+ 'there is no server-global bucket to fall back to.');
75
+ this.databaseId = databaseId;
76
+ this.schemaName = schemaName;
77
+ this.tableName = tableName;
78
+ this.columnName = columnName;
79
+ this.name = 'FileRefFieldNotRegisteredError';
80
+ }
81
+ }
82
+ exports.FileRefFieldNotRegisteredError = FileRefFieldNotRegisteredError;
83
+ /**
84
+ * Look up the storage binding for a document column, or throw.
85
+ *
86
+ * The read runs on whichever client the caller passes. The registry is schema
87
+ * metadata rather than tenant rows, so callers resolve it in the system lane —
88
+ * the RLS that matters is on the files table the upload eventually writes.
89
+ */
90
+ async function getFileRefFieldBinding(pgClient, databaseId, field) {
91
+ const cacheKey = `file-ref:${databaseId}:${field.schemaName}.${field.tableName}.${field.columnName}`;
92
+ const cached = bindingCache.get(cacheKey);
93
+ if (cached)
94
+ return cached;
95
+ const result = await pgClient.query({
96
+ text: FILE_REF_FIELD_QUERY,
97
+ values: [databaseId, field.schemaName, field.tableName, field.columnName],
98
+ });
99
+ if (result.rows.length === 0) {
100
+ throw new FileRefFieldNotRegisteredError(databaseId, field.schemaName, field.tableName, field.columnName);
101
+ }
102
+ const row = result.rows[0];
103
+ const binding = {
104
+ id: row.id,
105
+ storageModuleId: row.storage_module_id,
106
+ bucketKey: row.bucket_key,
107
+ bucketTags: row.bucket_tags,
108
+ isPublic: row.is_public,
109
+ enforceFk: row.enforce_fk,
110
+ };
111
+ bindingCache.set(cacheKey, binding);
112
+ log.debug(`Bound ${field.schemaName}.${field.tableName}.${field.columnName} to storage module ` +
113
+ `${binding.storageModuleId} (bucket_key=${binding.bucketKey ?? '<default tag>'})`);
114
+ return binding;
115
+ }
116
+ /**
117
+ * Drop cached bindings. Used by tests and after a re-provision.
118
+ */
119
+ function clearFileRefFieldCache() {
120
+ bindingCache.clear();
121
+ }
package/index.d.ts CHANGED
@@ -26,9 +26,16 @@
26
26
  * };
27
27
  * ```
28
28
  */
29
+ export type { ResolvedBucketCoordinate } from './default-bucket';
30
+ export { resolveDefaultBucket } from './default-bucket';
29
31
  export { createDownloadUrlPlugin } from './download-url-field';
32
+ export type { FileRefFieldBinding } from './file-ref-registry';
33
+ export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
34
+ export { assertUploadAllowedByBucket, buildFileProjection, type FileProjection, finalizeStagedUpload, type ManagedUploadTarget, resolveManagedUploadTarget, } from './managed-upload';
35
+ export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
30
36
  export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
31
37
  export { PresignedUrlPreset } from './preset';
32
- export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
38
+ export { type WithPgClient, withRequestPgClient } from './request-pg-client';
39
+ export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
33
40
  export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
34
41
  export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
package/index.js CHANGED
@@ -28,15 +28,34 @@
28
28
  * ```
29
29
  */
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.createDownloadUrlPlugin = void 0;
31
+ exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.copyS3Object = 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 = void 0;
32
+ var default_bucket_1 = require("./default-bucket");
33
+ Object.defineProperty(exports, "resolveDefaultBucket", { enumerable: true, get: function () { return default_bucket_1.resolveDefaultBucket; } });
32
34
  var download_url_field_1 = require("./download-url-field");
33
35
  Object.defineProperty(exports, "createDownloadUrlPlugin", { enumerable: true, get: function () { return download_url_field_1.createDownloadUrlPlugin; } });
36
+ var file_ref_registry_1 = require("./file-ref-registry");
37
+ Object.defineProperty(exports, "clearFileRefFieldCache", { enumerable: true, get: function () { return file_ref_registry_1.clearFileRefFieldCache; } });
38
+ Object.defineProperty(exports, "FileRefFieldNotRegisteredError", { enumerable: true, get: function () { return file_ref_registry_1.FileRefFieldNotRegisteredError; } });
39
+ Object.defineProperty(exports, "getFileRefFieldBinding", { enumerable: true, get: function () { return file_ref_registry_1.getFileRefFieldBinding; } });
40
+ var managed_upload_1 = require("./managed-upload");
41
+ Object.defineProperty(exports, "assertUploadAllowedByBucket", { enumerable: true, get: function () { return managed_upload_1.assertUploadAllowedByBucket; } });
42
+ Object.defineProperty(exports, "buildFileProjection", { enumerable: true, get: function () { return managed_upload_1.buildFileProjection; } });
43
+ Object.defineProperty(exports, "finalizeStagedUpload", { enumerable: true, get: function () { return managed_upload_1.finalizeStagedUpload; } });
44
+ Object.defineProperty(exports, "resolveManagedUploadTarget", { enumerable: true, get: function () { return managed_upload_1.resolveManagedUploadTarget; } });
45
+ var physical_bucket_1 = require("./physical-bucket");
46
+ Object.defineProperty(exports, "mintPhysicalBucketName", { enumerable: true, get: function () { return physical_bucket_1.mintPhysicalBucketName; } });
47
+ Object.defineProperty(exports, "provisionAndRecordPhysicalBucket", { enumerable: true, get: function () { return physical_bucket_1.provisionAndRecordPhysicalBucket; } });
48
+ Object.defineProperty(exports, "resolveS3", { enumerable: true, get: function () { return physical_bucket_1.resolveS3; } });
49
+ Object.defineProperty(exports, "resolveS3ForDatabase", { enumerable: true, get: function () { return physical_bucket_1.resolveS3ForDatabase; } });
34
50
  var plugin_1 = require("./plugin");
35
51
  Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
36
52
  Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
37
53
  var preset_1 = require("./preset");
38
54
  Object.defineProperty(exports, "PresignedUrlPreset", { enumerable: true, get: function () { return preset_1.PresignedUrlPreset; } });
55
+ var request_pg_client_1 = require("./request-pg-client");
56
+ Object.defineProperty(exports, "withRequestPgClient", { enumerable: true, get: function () { return request_pg_client_1.withRequestPgClient; } });
39
57
  var s3_signer_1 = require("./s3-signer");
58
+ Object.defineProperty(exports, "copyS3Object", { enumerable: true, get: function () { return s3_signer_1.copyS3Object; } });
40
59
  Object.defineProperty(exports, "deleteS3Object", { enumerable: true, get: function () { return s3_signer_1.deleteS3Object; } });
41
60
  Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
42
61
  Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });