graphile-presigned-url-plugin 1.11.3 → 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/default-bucket.d.ts +42 -0
- package/default-bucket.js +51 -0
- package/esm/default-bucket.d.ts +42 -0
- package/esm/default-bucket.js +48 -0
- package/esm/file-ref-registry.d.ts +61 -0
- package/esm/file-ref-registry.js +115 -0
- package/esm/index.d.ts +8 -1
- package/esm/index.js +6 -1
- package/esm/managed-upload.d.ts +132 -0
- package/esm/managed-upload.js +262 -0
- package/esm/physical-bucket.d.ts +60 -0
- package/esm/physical-bucket.js +111 -0
- package/esm/plugin.js +53 -84
- package/esm/s3-signer.d.ts +13 -0
- package/esm/s3-signer.js +23 -1
- package/esm/types.d.ts +34 -2
- package/file-ref-registry.d.ts +61 -0
- package/file-ref-registry.js +121 -0
- package/index.d.ts +8 -1
- package/index.js +20 -1
- package/managed-upload.d.ts +132 -0
- package/managed-upload.js +268 -0
- package/package.json +3 -3
- package/physical-bucket.d.ts +60 -0
- package/physical-bucket.js +117 -0
- package/plugin.js +57 -88
- package/s3-signer.d.ts +13 -0
- package/s3-signer.js +23 -0
- package/types.d.ts +34 -2
package/plugin.js
CHANGED
|
@@ -23,6 +23,9 @@ exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
|
|
|
23
23
|
require("graphile-build");
|
|
24
24
|
const logger_1 = require("@pgpmjs/logger");
|
|
25
25
|
const grafast_1 = require("grafast");
|
|
26
|
+
const default_bucket_1 = require("./default-bucket");
|
|
27
|
+
const managed_upload_1 = require("./managed-upload");
|
|
28
|
+
const physical_bucket_1 = require("./physical-bucket");
|
|
26
29
|
const request_pg_client_1 = require("./request-pg-client");
|
|
27
30
|
const s3_signer_1 = require("./s3-signer");
|
|
28
31
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
@@ -71,87 +74,20 @@ async function resolveDatabaseId(pgClient) {
|
|
|
71
74
|
});
|
|
72
75
|
return result.rows[0]?.id ?? null;
|
|
73
76
|
}
|
|
74
|
-
function resolveS3(options) {
|
|
75
|
-
if (typeof options.s3 === 'function') {
|
|
76
|
-
const resolved = options.s3();
|
|
77
|
-
options.s3 = resolved;
|
|
78
|
-
return resolved;
|
|
79
|
-
}
|
|
80
|
-
return options.s3;
|
|
81
|
-
}
|
|
82
77
|
/**
|
|
83
|
-
*
|
|
78
|
+
* Resolve the bucket an upload mutation writes into.
|
|
84
79
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
80
|
+
* A named `bucketKey` is the caller's override and is read directly, as before.
|
|
81
|
+
* An omitted one asks the database for the tenant's reserved default tag for the
|
|
82
|
+
* requested access, so a missing or ambiguous default raises in SQL rather than
|
|
83
|
+
* falling back to a server-global bucket name here.
|
|
88
84
|
*/
|
|
89
|
-
function
|
|
90
|
-
if (
|
|
91
|
-
return
|
|
85
|
+
async function resolveUploadBucket(pgClient, storageConfig, databaseId, bucketKey, ownerId, isPublic) {
|
|
86
|
+
if (bucketKey) {
|
|
87
|
+
return (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, bucketKey, ownerId || undefined);
|
|
92
88
|
}
|
|
93
|
-
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Build the S3 config for a *known* physical bucket. `physicalName` is
|
|
98
|
-
* required — callers must resolve the coordinate (stored row value, or a
|
|
99
|
-
* freshly provisioned name) before getting here. No name is ever recomputed.
|
|
100
|
-
*/
|
|
101
|
-
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
102
|
-
const globalS3 = resolveS3(options);
|
|
103
|
-
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
104
|
-
? storageConfig.publicUrlPrefix
|
|
105
|
-
: globalS3.publicUrlPrefix;
|
|
106
|
-
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
107
|
-
return globalS3;
|
|
108
|
-
}
|
|
109
|
-
return {
|
|
110
|
-
...globalS3,
|
|
111
|
-
bucket: physicalName,
|
|
112
|
-
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* First provision of a logical bucket: mint a name, create the physical S3
|
|
117
|
-
* bucket, and record the exact name on the source row. Returns the recorded
|
|
118
|
-
* physical name.
|
|
119
|
-
*
|
|
120
|
-
* Only called when the row has no `physical_name` yet. Afterwards the stored
|
|
121
|
-
* value is the durable coordinate: route resolution and every later read use
|
|
122
|
-
* it verbatim; nothing is recomputed.
|
|
123
|
-
*
|
|
124
|
-
* The record write runs in the system lane (privileged role, so it bypasses the
|
|
125
|
-
* RLS that stops request roles from UPDATE-ing bucket rows) — it is server
|
|
126
|
-
* bookkeeping, not request data. It still carries the tenant `database_id`
|
|
127
|
-
* claim, because the buckets table's catalog-sync trigger calls
|
|
128
|
-
* `jwt_private.current_database_id()` and would otherwise raise
|
|
129
|
-
* DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
|
|
130
|
-
* write's transaction without switching off the privileged role.
|
|
131
|
-
* `bucket` (the cached config) is mutated in place so subsequent reads observe
|
|
132
|
-
* the recorded name without a DB round-trip.
|
|
133
|
-
*/
|
|
134
|
-
async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
|
|
135
|
-
const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
|
|
136
|
-
if (options.ensureBucketProvisioned && !(0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName)) {
|
|
137
|
-
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
138
|
-
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
139
|
-
(0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
|
|
140
|
-
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
141
|
-
}
|
|
142
|
-
// Record the physical coordinate on the source row. The `physical_name IS NULL`
|
|
143
|
-
// guard keeps this idempotent and race-safe across concurrent first uploads.
|
|
144
|
-
// The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
|
|
145
|
-
// write runs under the resolved database claim (privileged role preserved).
|
|
146
|
-
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
|
|
147
|
-
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
148
|
-
SET physical_name = $1
|
|
149
|
-
WHERE id = $2 AND physical_name IS NULL`,
|
|
150
|
-
values: [s3BucketName, bucket.id],
|
|
151
|
-
}));
|
|
152
|
-
bucket.physical_name = s3BucketName;
|
|
153
|
-
log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
|
|
154
|
-
return s3BucketName;
|
|
89
|
+
const coordinate = await (0, default_bucket_1.resolveDefaultBucket)(pgClient, databaseId, storageConfig.scope, ownerId, isPublic, null);
|
|
90
|
+
return (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, coordinate.resolvedKey, ownerId || undefined);
|
|
155
91
|
}
|
|
156
92
|
// --- Plugin factory ---
|
|
157
93
|
function createPresignedUrlPlugin(options) {
|
|
@@ -170,6 +106,14 @@ function createPresignedUrlPlugin(options) {
|
|
|
170
106
|
if (!isRootMutation)
|
|
171
107
|
return fields;
|
|
172
108
|
const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
|
|
109
|
+
// The projection document is jsonb-shaped. PostGraphile registers a JSON
|
|
110
|
+
// scalar whenever the schema has a jsonb column, which any storage-equipped
|
|
111
|
+
// database does; if it is absent the payload simply omits the field rather
|
|
112
|
+
// than failing schema build over a field nothing can have asked for yet.
|
|
113
|
+
const jsonType = build.getTypeByName('JSON') ?? null;
|
|
114
|
+
if (!jsonType) {
|
|
115
|
+
log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
|
|
116
|
+
}
|
|
173
117
|
const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
|
|
174
118
|
if (bucketCodecs.length === 0)
|
|
175
119
|
return fields;
|
|
@@ -203,7 +147,8 @@ function createPresignedUrlPlugin(options) {
|
|
|
203
147
|
const InputType = new GraphQLInputObjectType({
|
|
204
148
|
name: `Upload${filesTypeName}Input`,
|
|
205
149
|
fields: {
|
|
206
|
-
bucketKey: { type:
|
|
150
|
+
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
151
|
+
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.' },
|
|
207
152
|
...(hasOwnerId
|
|
208
153
|
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
209
154
|
: {}),
|
|
@@ -223,6 +168,16 @@ function createPresignedUrlPlugin(options) {
|
|
|
223
168
|
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
|
|
224
169
|
expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
|
|
225
170
|
previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
|
|
171
|
+
...(jsonType
|
|
172
|
+
? {
|
|
173
|
+
file: {
|
|
174
|
+
type: jsonType,
|
|
175
|
+
description: 'The projection document for the created file: {id, key, bucket_id, mime, size, filename, url?}. ' +
|
|
176
|
+
'Store this verbatim in an image/upload column — its `id` is what keeps the object from being ' +
|
|
177
|
+
'garbage collected while the column still references it.',
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
: {}),
|
|
226
181
|
},
|
|
227
182
|
});
|
|
228
183
|
const capturedFilesCodec = filesCodec;
|
|
@@ -236,6 +191,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
236
191
|
plan(_$mutation, fieldArgs) {
|
|
237
192
|
const $input = fieldArgs.getRaw('input');
|
|
238
193
|
const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
|
|
194
|
+
const $isPublic = (0, grafast_1.access)($input, 'isPublic');
|
|
239
195
|
const $contentHash = (0, grafast_1.access)($input, 'contentHash');
|
|
240
196
|
const $contentType = (0, grafast_1.access)($input, 'contentType');
|
|
241
197
|
const $size = (0, grafast_1.access)($input, 'size');
|
|
@@ -246,6 +202,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
246
202
|
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
247
203
|
const $combined = (0, grafast_1.object)({
|
|
248
204
|
bucketKey: $bucketKey,
|
|
205
|
+
isPublic: $isPublic,
|
|
249
206
|
ownerId: $ownerId,
|
|
250
207
|
contentHash: $contentHash,
|
|
251
208
|
contentType: $contentType,
|
|
@@ -268,16 +225,16 @@ function createPresignedUrlPlugin(options) {
|
|
|
268
225
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
269
226
|
if (!storageConfig)
|
|
270
227
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
271
|
-
// Bucket
|
|
272
|
-
const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (
|
|
228
|
+
// Bucket resolution + read under the request role (RLS-gated visibility).
|
|
229
|
+
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));
|
|
273
230
|
if (!bucket)
|
|
274
231
|
throw new Error('BUCKET_NOT_FOUND');
|
|
275
232
|
// First provision mints + records the coordinate; afterwards the
|
|
276
233
|
// stored physical_name is authoritative and nothing is recomputed.
|
|
277
234
|
const physicalName = bucket.physical_name === null
|
|
278
|
-
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
235
|
+
? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
279
236
|
: bucket.physical_name;
|
|
280
|
-
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
237
|
+
const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
|
|
281
238
|
// File row INSERT under the request role (RLS enforced).
|
|
282
239
|
return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
283
240
|
contentHash: vals.contentHash,
|
|
@@ -309,12 +266,14 @@ function createPresignedUrlPlugin(options) {
|
|
|
309
266
|
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
|
|
310
267
|
expiresAt: { type: GraphQLString },
|
|
311
268
|
previousVersionId: { type: GraphQLString },
|
|
269
|
+
...(jsonType ? { file: { type: jsonType, description: 'The projection document for the created file.' } } : {}),
|
|
312
270
|
},
|
|
313
271
|
});
|
|
314
272
|
const BulkInputType = new GraphQLInputObjectType({
|
|
315
273
|
name: `Upload${filesTypeName}BulkInput`,
|
|
316
274
|
fields: {
|
|
317
|
-
bucketKey: { type:
|
|
275
|
+
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
276
|
+
isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
|
|
318
277
|
...(hasOwnerId
|
|
319
278
|
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
320
279
|
: {}),
|
|
@@ -338,12 +297,14 @@ function createPresignedUrlPlugin(options) {
|
|
|
338
297
|
plan(_$mutation, fieldArgs) {
|
|
339
298
|
const $input = fieldArgs.getRaw('input');
|
|
340
299
|
const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
|
|
300
|
+
const $isPublic = (0, grafast_1.access)($input, 'isPublic');
|
|
341
301
|
const $ownerId = hasOwnerId ? (0, grafast_1.access)($input, 'ownerId') : (0, grafast_1.lambda)(null, () => null);
|
|
342
302
|
const $files = (0, grafast_1.access)($input, 'files');
|
|
343
303
|
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
344
304
|
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
345
305
|
const $combined = (0, grafast_1.object)({
|
|
346
306
|
bucketKey: $bucketKey,
|
|
307
|
+
isPublic: $isPublic,
|
|
347
308
|
ownerId: $ownerId,
|
|
348
309
|
files: $files,
|
|
349
310
|
withPgClient: $withPgClient,
|
|
@@ -362,8 +323,8 @@ function createPresignedUrlPlugin(options) {
|
|
|
362
323
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
363
324
|
if (!storageConfig)
|
|
364
325
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
365
|
-
// Bucket
|
|
366
|
-
const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (
|
|
326
|
+
// Bucket resolution + read under the request role (RLS-gated visibility).
|
|
327
|
+
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));
|
|
367
328
|
if (!bucket)
|
|
368
329
|
throw new Error('BUCKET_NOT_FOUND');
|
|
369
330
|
// Enforce bulk upload limits
|
|
@@ -378,9 +339,9 @@ function createPresignedUrlPlugin(options) {
|
|
|
378
339
|
// First provision mints + records the coordinate; afterwards the
|
|
379
340
|
// stored physical_name is authoritative and nothing is recomputed.
|
|
380
341
|
const physicalName = bucket.physical_name === null
|
|
381
|
-
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
342
|
+
? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
382
343
|
: bucket.physical_name;
|
|
383
|
-
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
344
|
+
const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
|
|
384
345
|
// File row INSERTs under the request role (RLS enforced).
|
|
385
346
|
return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, async (txClient) => {
|
|
386
347
|
const results = [];
|
|
@@ -509,7 +470,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
509
470
|
log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
|
|
510
471
|
return;
|
|
511
472
|
}
|
|
512
|
-
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
473
|
+
const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
|
|
513
474
|
await (0, s3_signer_1.deleteS3Object)(s3ForDb, fileRow.key);
|
|
514
475
|
log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
|
|
515
476
|
});
|
|
@@ -568,6 +529,11 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
568
529
|
if (bucket.max_file_size && size > bucket.max_file_size) {
|
|
569
530
|
throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
|
|
570
531
|
}
|
|
532
|
+
// The projection document the caller stores in an image/upload column. Built
|
|
533
|
+
// from the same values the files row carries, so the column and the row cannot
|
|
534
|
+
// disagree, and it names the files row by id — which is what stops GC from
|
|
535
|
+
// collecting an object a document still points at.
|
|
536
|
+
const projectFile = (fileId, key) => (0, managed_upload_1.buildFileProjection)({ id: fileId, key, bucketId: bucket.id, mime: contentType, size, filename }, bucket, s3ForDb);
|
|
571
537
|
// Determine S3 key
|
|
572
538
|
let s3Key;
|
|
573
539
|
let isCustomKey = false;
|
|
@@ -608,6 +574,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
608
574
|
deduplicated: true,
|
|
609
575
|
expiresAt: null,
|
|
610
576
|
previousVersionId: null,
|
|
577
|
+
file: projectFile(existing.id, s3Key),
|
|
611
578
|
};
|
|
612
579
|
}
|
|
613
580
|
previousVersionId = existing.id;
|
|
@@ -633,6 +600,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
633
600
|
deduplicated: true,
|
|
634
601
|
expiresAt: null,
|
|
635
602
|
previousVersionId: null,
|
|
603
|
+
file: projectFile(existingFile.id, s3Key),
|
|
636
604
|
};
|
|
637
605
|
}
|
|
638
606
|
}
|
|
@@ -673,6 +641,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
673
641
|
deduplicated: false,
|
|
674
642
|
expiresAt,
|
|
675
643
|
previousVersionId,
|
|
644
|
+
file: projectFile(fileId, s3Key),
|
|
676
645
|
};
|
|
677
646
|
}
|
|
678
647
|
exports.PresignedUrlPlugin = createPresignedUrlPlugin;
|
package/s3-signer.d.ts
CHANGED
|
@@ -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/s3-signer.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.generatePresignedPutUrl = generatePresignedPutUrl;
|
|
4
4
|
exports.generatePresignedGetUrl = generatePresignedGetUrl;
|
|
5
5
|
exports.deleteS3Object = deleteS3Object;
|
|
6
|
+
exports.copyS3Object = copyS3Object;
|
|
6
7
|
exports.headObject = headObject;
|
|
7
8
|
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
8
9
|
const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
|
|
@@ -74,6 +75,28 @@ async function deleteS3Object(s3Config, key) {
|
|
|
74
75
|
}));
|
|
75
76
|
log.debug(`Deleted S3 object: bucket=${s3Config.bucket}, key=${key}`);
|
|
76
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Copy an object within the same physical bucket, preserving its content type.
|
|
80
|
+
*
|
|
81
|
+
* Used to promote a staged upload to its content-addressed key: the bytes are
|
|
82
|
+
* hashed as they stream in, so the final key is only known once the stream ends.
|
|
83
|
+
* Server-side copy keeps that promotion off the application's wire.
|
|
84
|
+
*
|
|
85
|
+
* @param s3Config - S3 client and bucket configuration
|
|
86
|
+
* @param sourceKey - The staged key the bytes were written to
|
|
87
|
+
* @param destinationKey - The final key (the content hash)
|
|
88
|
+
* @param contentType - MIME type to record on the destination object
|
|
89
|
+
*/
|
|
90
|
+
async function copyS3Object(s3Config, sourceKey, destinationKey, contentType) {
|
|
91
|
+
await s3Config.client.send(new client_s3_1.CopyObjectCommand({
|
|
92
|
+
Bucket: s3Config.bucket,
|
|
93
|
+
Key: destinationKey,
|
|
94
|
+
CopySource: `${s3Config.bucket}/${sourceKey}`,
|
|
95
|
+
ContentType: contentType,
|
|
96
|
+
MetadataDirective: 'REPLACE',
|
|
97
|
+
}));
|
|
98
|
+
log.debug(`Copied S3 object: bucket=${s3Config.bucket}, ${sourceKey} → ${destinationKey}`);
|
|
99
|
+
}
|
|
77
100
|
/**
|
|
78
101
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
79
102
|
*
|
package/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
|
-
/**
|
|
74
|
-
|
|
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.
|