graphile-presigned-url-plugin 0.15.0 → 0.15.1
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.d.ts +5 -12
- package/esm/plugin.js +145 -402
- package/package.json +2 -2
- package/plugin.d.ts +5 -12
- package/plugin.js +145 -402
package/plugin.js
CHANGED
|
@@ -4,22 +4,15 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Hooks into PostGraphile's auto-generated CRUD mutations to add S3 operations:
|
|
6
6
|
*
|
|
7
|
-
* 1.
|
|
8
|
-
* with S3 object cleanup (sync + async GC fallback via AFTER DELETE trigger).
|
|
9
|
-
*
|
|
10
|
-
* 2. Upload fields — adds `requestUploadUrl` and `requestBulkUploadUrls` fields
|
|
11
|
-
* on `@storageBuckets`-tagged types, so clients upload via the typed bucket API.
|
|
12
|
-
*
|
|
13
|
-
* 3. Mutation entry points — adds per-bucket mutation fields on the root Mutation
|
|
14
|
-
* type (e.g., `appBucket(key: "public"): AppBucket`), so upload operations
|
|
15
|
-
* can be accessed as proper GraphQL mutations instead of queries.
|
|
16
|
-
*
|
|
17
|
-
* 4. File upload mutations — adds `upload<FileType>(input: {...})` mutations
|
|
7
|
+
* 1. File upload mutations — adds `upload<FileType>(input: {...})` mutations
|
|
18
8
|
* on root Mutation for each @storageFiles/@storageBuckets pair. These combine
|
|
19
9
|
* bucket resolution + file INSERT + presigned URL generation in one step.
|
|
20
10
|
* E.g., `uploadAppFile(input: { bucketKey: "public", contentHash: "...", ... })`
|
|
21
11
|
*
|
|
22
|
-
*
|
|
12
|
+
* 2. Delete middleware — wraps `delete*` mutations on `@storageFiles`-tagged tables
|
|
13
|
+
* with S3 object cleanup (sync + async GC fallback via AFTER DELETE trigger).
|
|
14
|
+
*
|
|
15
|
+
* 3. downloadUrl — handled by download-url-field.ts (separate plugin).
|
|
23
16
|
*
|
|
24
17
|
* Scope resolution uses the codec's schema/table name matched against
|
|
25
18
|
* cached storage module configs.
|
|
@@ -120,347 +113,90 @@ function createPresignedUrlPlugin(options) {
|
|
|
120
113
|
schema: {
|
|
121
114
|
hooks: {
|
|
122
115
|
/**
|
|
123
|
-
* Add
|
|
116
|
+
* Add file upload mutations (uploadAppFile, uploadDataRoomFile, etc.) on root Mutation.
|
|
124
117
|
*/
|
|
125
118
|
GraphQLObjectType_fields(fields, build, context) {
|
|
126
|
-
const { scope: {
|
|
127
|
-
|
|
128
|
-
if (isRootMutation) {
|
|
129
|
-
const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
|
|
130
|
-
const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
|
|
131
|
-
if (bucketCodecs.length === 0)
|
|
132
|
-
return fields;
|
|
133
|
-
const newFields = {};
|
|
134
|
-
// --- 1a: Per-bucket entry points (appBucket, dataRoomBucket, etc.) ---
|
|
135
|
-
for (const codec of bucketCodecs) {
|
|
136
|
-
const typeName = build.inflection.tableType(codec);
|
|
137
|
-
const bucketType = build.getTypeByName(typeName);
|
|
138
|
-
if (!bucketType) {
|
|
139
|
-
log.debug(`Skipping mutation entry point for ${codec.name}: type ${typeName} not found`);
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
const fieldName = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
|
143
|
-
const hasOwnerId = !!codec.attributes.owner_id;
|
|
144
|
-
const bucketResource = Object.values(build.input.pgRegistry.pgResources).find((r) => r.codec === codec && !r.isUnique && !r.isVirtual && !r.parameters);
|
|
145
|
-
if (!bucketResource) {
|
|
146
|
-
log.debug(`Skipping mutation entry point for ${codec.name}: no PgResource found`);
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
const ownerIdType = hasOwnerId
|
|
150
|
-
? build.getGraphQLTypeByPgCodec(codec.attributes.owner_id.codec, 'input')
|
|
151
|
-
: null;
|
|
152
|
-
log.debug(`Adding mutation entry point "${fieldName}" for bucket type ${typeName} (entity-scoped=${hasOwnerId})`);
|
|
153
|
-
newFields[fieldName] = context.fieldWithHooks({ fieldName }, {
|
|
154
|
-
description: `Look up a ${typeName} by key for mutation operations (upload, etc.).`,
|
|
155
|
-
type: bucketType,
|
|
156
|
-
args: {
|
|
157
|
-
key: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
|
|
158
|
-
...(hasOwnerId
|
|
159
|
-
? { ownerId: { type: new GraphQLNonNull(ownerIdType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
160
|
-
: {}),
|
|
161
|
-
},
|
|
162
|
-
plan(_$mutation, fieldArgs) {
|
|
163
|
-
const spec = {
|
|
164
|
-
key: fieldArgs.getRaw('key'),
|
|
165
|
-
};
|
|
166
|
-
if (hasOwnerId) {
|
|
167
|
-
spec.owner_id = fieldArgs.getRaw('ownerId');
|
|
168
|
-
}
|
|
169
|
-
return bucketResource.find(spec).single();
|
|
170
|
-
},
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
// --- 1b: File upload mutations (uploadAppFile, uploadDataRoomFile, etc.) ---
|
|
174
|
-
const fileCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageFiles);
|
|
175
|
-
for (const filesCodec of fileCodecs) {
|
|
176
|
-
const filesTypeName = build.inflection.tableType(filesCodec);
|
|
177
|
-
const filesSchemaName = filesCodec.extensions?.pg?.schemaName;
|
|
178
|
-
// Find the matching bucket codec by table name prefix.
|
|
179
|
-
// Schema-name matching is ambiguous when multiple storage modules share
|
|
180
|
-
// the same PG schema (e.g. app_files + data_room_files both in storage_public).
|
|
181
|
-
// Instead, derive the prefix from the raw SQL table name:
|
|
182
|
-
// "data_room_files" → prefix "data_room" → matches "data_room_buckets"
|
|
183
|
-
// "app_files" → prefix "app" → matches "app_buckets"
|
|
184
|
-
const filesRawName = filesCodec.extensions?.pg?.name;
|
|
185
|
-
const filesPrefix = filesRawName?.replace(/_files$/, '');
|
|
186
|
-
const matchingBucketCodec = bucketCodecs.find((bc) => {
|
|
187
|
-
const bucketRawName = bc.extensions?.pg?.name;
|
|
188
|
-
const bucketPrefix = bucketRawName?.replace(/_buckets$/, '');
|
|
189
|
-
return bucketPrefix === filesPrefix;
|
|
190
|
-
});
|
|
191
|
-
if (!matchingBucketCodec) {
|
|
192
|
-
log.debug(`Skipping upload mutation for ${filesCodec.name}: no matching bucket codec with prefix "${filesPrefix}"`);
|
|
193
|
-
continue;
|
|
194
|
-
}
|
|
195
|
-
const hasOwnerId = !!matchingBucketCodec.attributes.owner_id;
|
|
196
|
-
const mutationName = `upload${filesTypeName}`;
|
|
197
|
-
const ownerIdGqlType = hasOwnerId
|
|
198
|
-
? build.getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input')
|
|
199
|
-
: null;
|
|
200
|
-
const InputType = new GraphQLInputObjectType({
|
|
201
|
-
name: `Upload${filesTypeName}Input`,
|
|
202
|
-
fields: {
|
|
203
|
-
bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
|
|
204
|
-
...(hasOwnerId
|
|
205
|
-
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
206
|
-
: {}),
|
|
207
|
-
contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
|
|
208
|
-
contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
|
|
209
|
-
size: { type: new GraphQLNonNull(GraphQLInt), description: 'File size in bytes' },
|
|
210
|
-
filename: { type: GraphQLString, description: 'Original filename (optional)' },
|
|
211
|
-
key: { type: GraphQLString, description: 'Custom S3 key (only when bucket has allow_custom_keys=true)' },
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
const PayloadType = new GraphQLObjectType({
|
|
215
|
-
name: `Upload${filesTypeName}Payload`,
|
|
216
|
-
fields: {
|
|
217
|
-
uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' },
|
|
218
|
-
fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID (UUID)' },
|
|
219
|
-
key: { type: new GraphQLNonNull(GraphQLString), description: 'The S3 object key' },
|
|
220
|
-
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
|
|
221
|
-
expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
|
|
222
|
-
previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
|
|
223
|
-
},
|
|
224
|
-
});
|
|
225
|
-
const capturedFilesCodec = filesCodec;
|
|
226
|
-
log.debug(`Adding file upload mutation "${mutationName}" for ${filesTypeName} (entity-scoped=${hasOwnerId})`);
|
|
227
|
-
newFields[mutationName] = context.fieldWithHooks({ fieldName: mutationName }, {
|
|
228
|
-
description: `Upload a file: resolves the bucket by key, creates the file row, and returns a presigned PUT URL.`,
|
|
229
|
-
type: PayloadType,
|
|
230
|
-
args: {
|
|
231
|
-
input: { type: new GraphQLNonNull(InputType) },
|
|
232
|
-
},
|
|
233
|
-
plan(_$mutation, fieldArgs) {
|
|
234
|
-
const $input = fieldArgs.getRaw('input');
|
|
235
|
-
const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
|
|
236
|
-
const $contentHash = (0, grafast_1.access)($input, 'contentHash');
|
|
237
|
-
const $contentType = (0, grafast_1.access)($input, 'contentType');
|
|
238
|
-
const $size = (0, grafast_1.access)($input, 'size');
|
|
239
|
-
const $filename = (0, grafast_1.access)($input, 'filename');
|
|
240
|
-
const $customKey = (0, grafast_1.access)($input, 'key');
|
|
241
|
-
const $ownerId = hasOwnerId ? (0, grafast_1.access)($input, 'ownerId') : (0, grafast_1.lambda)(null, () => null);
|
|
242
|
-
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
243
|
-
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
244
|
-
const $combined = (0, grafast_1.object)({
|
|
245
|
-
bucketKey: $bucketKey,
|
|
246
|
-
ownerId: $ownerId,
|
|
247
|
-
contentHash: $contentHash,
|
|
248
|
-
contentType: $contentType,
|
|
249
|
-
size: $size,
|
|
250
|
-
filename: $filename,
|
|
251
|
-
customKey: $customKey,
|
|
252
|
-
withPgClient: $withPgClient,
|
|
253
|
-
pgSettings: $pgSettings,
|
|
254
|
-
});
|
|
255
|
-
return (0, grafast_1.lambda)($combined, async (vals) => {
|
|
256
|
-
return vals.withPgClient(vals.pgSettings, async (pgClient) => {
|
|
257
|
-
return pgClient.withTransaction(async (txClient) => {
|
|
258
|
-
const databaseId = await resolveDatabaseId(txClient);
|
|
259
|
-
if (!databaseId)
|
|
260
|
-
throw new Error('DATABASE_NOT_FOUND');
|
|
261
|
-
const allConfigs = await (0, storage_module_cache_1.loadAllStorageModules)(txClient, databaseId);
|
|
262
|
-
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
263
|
-
if (!storageConfig)
|
|
264
|
-
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
265
|
-
const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
|
|
266
|
-
if (!bucket)
|
|
267
|
-
throw new Error('BUCKET_NOT_FOUND');
|
|
268
|
-
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
269
|
-
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
270
|
-
return processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
271
|
-
contentHash: vals.contentHash,
|
|
272
|
-
contentType: vals.contentType,
|
|
273
|
-
size: vals.size,
|
|
274
|
-
filename: vals.filename,
|
|
275
|
-
key: vals.customKey,
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
},
|
|
281
|
-
});
|
|
282
|
-
// --- Bulk file upload mutation ---
|
|
283
|
-
const BulkFileInputType = new GraphQLInputObjectType({
|
|
284
|
-
name: `Upload${filesTypeName}BulkFileInput`,
|
|
285
|
-
fields: {
|
|
286
|
-
contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
|
|
287
|
-
contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
|
|
288
|
-
size: { type: new GraphQLNonNull(GraphQLInt), description: 'File size in bytes' },
|
|
289
|
-
filename: { type: GraphQLString, description: 'Original filename (optional)' },
|
|
290
|
-
key: { type: GraphQLString, description: 'Custom S3 key (only when bucket has allow_custom_keys=true)' },
|
|
291
|
-
},
|
|
292
|
-
});
|
|
293
|
-
const BulkFilePayloadType = new GraphQLObjectType({
|
|
294
|
-
name: `Upload${filesTypeName}BulkFilePayload`,
|
|
295
|
-
fields: {
|
|
296
|
-
uploadUrl: { type: GraphQLString },
|
|
297
|
-
fileId: { type: new GraphQLNonNull(GraphQLString) },
|
|
298
|
-
key: { type: new GraphQLNonNull(GraphQLString) },
|
|
299
|
-
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
|
|
300
|
-
expiresAt: { type: GraphQLString },
|
|
301
|
-
previousVersionId: { type: GraphQLString },
|
|
302
|
-
},
|
|
303
|
-
});
|
|
304
|
-
const BulkInputType = new GraphQLInputObjectType({
|
|
305
|
-
name: `Upload${filesTypeName}BulkInput`,
|
|
306
|
-
fields: {
|
|
307
|
-
bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
|
|
308
|
-
...(hasOwnerId
|
|
309
|
-
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
310
|
-
: {}),
|
|
311
|
-
files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFileInputType))), description: 'Array of files to upload' },
|
|
312
|
-
},
|
|
313
|
-
});
|
|
314
|
-
const BulkPayloadType = new GraphQLObjectType({
|
|
315
|
-
name: `Upload${filesTypeName}BulkPayload`,
|
|
316
|
-
fields: {
|
|
317
|
-
files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFilePayloadType))) },
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
const bulkMutationName = `upload${filesTypeName}s`;
|
|
321
|
-
log.debug(`Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}`);
|
|
322
|
-
newFields[bulkMutationName] = context.fieldWithHooks({ fieldName: bulkMutationName }, {
|
|
323
|
-
description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`,
|
|
324
|
-
type: BulkPayloadType,
|
|
325
|
-
args: {
|
|
326
|
-
input: { type: new GraphQLNonNull(BulkInputType) },
|
|
327
|
-
},
|
|
328
|
-
plan(_$mutation, fieldArgs) {
|
|
329
|
-
const $input = fieldArgs.getRaw('input');
|
|
330
|
-
const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
|
|
331
|
-
const $ownerId = hasOwnerId ? (0, grafast_1.access)($input, 'ownerId') : (0, grafast_1.lambda)(null, () => null);
|
|
332
|
-
const $files = (0, grafast_1.access)($input, 'files');
|
|
333
|
-
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
334
|
-
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
335
|
-
const $combined = (0, grafast_1.object)({
|
|
336
|
-
bucketKey: $bucketKey,
|
|
337
|
-
ownerId: $ownerId,
|
|
338
|
-
files: $files,
|
|
339
|
-
withPgClient: $withPgClient,
|
|
340
|
-
pgSettings: $pgSettings,
|
|
341
|
-
});
|
|
342
|
-
return (0, grafast_1.lambda)($combined, async (vals) => {
|
|
343
|
-
return vals.withPgClient(vals.pgSettings, async (pgClient) => {
|
|
344
|
-
return pgClient.withTransaction(async (txClient) => {
|
|
345
|
-
const databaseId = await resolveDatabaseId(txClient);
|
|
346
|
-
if (!databaseId)
|
|
347
|
-
throw new Error('DATABASE_NOT_FOUND');
|
|
348
|
-
const allConfigs = await (0, storage_module_cache_1.loadAllStorageModules)(txClient, databaseId);
|
|
349
|
-
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
350
|
-
if (!storageConfig)
|
|
351
|
-
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
352
|
-
const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
|
|
353
|
-
if (!bucket)
|
|
354
|
-
throw new Error('BUCKET_NOT_FOUND');
|
|
355
|
-
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
356
|
-
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
357
|
-
const results = [];
|
|
358
|
-
for (const file of vals.files) {
|
|
359
|
-
results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
360
|
-
contentHash: file.contentHash,
|
|
361
|
-
contentType: file.contentType,
|
|
362
|
-
size: file.size,
|
|
363
|
-
filename: file.filename,
|
|
364
|
-
key: file.key,
|
|
365
|
-
}));
|
|
366
|
-
}
|
|
367
|
-
return { files: results };
|
|
368
|
-
});
|
|
369
|
-
});
|
|
370
|
-
});
|
|
371
|
-
},
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
return build.extend(fields, newFields, 'PresignedUrlPlugin adding per-bucket mutation entry points and file upload mutations');
|
|
375
|
-
}
|
|
376
|
-
// --- Path 2: Add upload fields on @storageBuckets types ---
|
|
377
|
-
if (!isPgClassType || !pgCodec || !pgCodec.attributes) {
|
|
119
|
+
const { scope: { isRootMutation }, } = context;
|
|
120
|
+
if (!isRootMutation)
|
|
378
121
|
return fields;
|
|
379
|
-
}
|
|
380
|
-
const
|
|
381
|
-
if (
|
|
122
|
+
const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
|
|
123
|
+
const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
|
|
124
|
+
if (bucketCodecs.length === 0)
|
|
382
125
|
return fields;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
name
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
name: `${build.inflection.upperCamelCase(pgCodec.name)}BulkUploadFileInput`,
|
|
418
|
-
fields: {
|
|
419
|
-
contentHash: { type: new GraphQLNonNull(GraphQLString) },
|
|
420
|
-
contentType: { type: new GraphQLNonNull(GraphQLString) },
|
|
421
|
-
size: { type: new GraphQLNonNull(GraphQLInt) },
|
|
422
|
-
filename: { type: GraphQLString },
|
|
423
|
-
key: { type: GraphQLString },
|
|
424
|
-
},
|
|
425
|
-
});
|
|
426
|
-
// Capture codec for closure
|
|
427
|
-
const capturedCodec = pgCodec;
|
|
428
|
-
return build.extend(fields, {
|
|
429
|
-
requestUploadUrl: context.fieldWithHooks({ fieldName: 'requestUploadUrl' }, {
|
|
430
|
-
description: 'Request a presigned URL for uploading a file to this bucket.',
|
|
431
|
-
type: UploadUrlPayloadType,
|
|
432
|
-
args: {
|
|
126
|
+
const newFields = {};
|
|
127
|
+
// --- File upload mutations (uploadAppFile, uploadDataRoomFile, etc.) ---
|
|
128
|
+
const fileCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageFiles);
|
|
129
|
+
for (const filesCodec of fileCodecs) {
|
|
130
|
+
const filesTypeName = build.inflection.tableType(filesCodec);
|
|
131
|
+
// Find the matching bucket codec by table name prefix.
|
|
132
|
+
// Schema-name matching is ambiguous when multiple storage modules share
|
|
133
|
+
// the same PG schema (e.g. app_files + data_room_files both in storage_public).
|
|
134
|
+
// Instead, derive the prefix from the raw SQL table name:
|
|
135
|
+
// "data_room_files" → prefix "data_room" → matches "data_room_buckets"
|
|
136
|
+
// "app_files" → prefix "app" → matches "app_buckets"
|
|
137
|
+
const filesRawName = filesCodec.extensions?.pg?.name;
|
|
138
|
+
const filesPrefix = filesRawName?.replace(/_files$/, '');
|
|
139
|
+
const matchingBucketCodec = bucketCodecs.find((bc) => {
|
|
140
|
+
const bucketRawName = bc.extensions?.pg?.name;
|
|
141
|
+
const bucketPrefix = bucketRawName?.replace(/_buckets$/, '');
|
|
142
|
+
return bucketPrefix === filesPrefix;
|
|
143
|
+
});
|
|
144
|
+
if (!matchingBucketCodec) {
|
|
145
|
+
log.debug(`Skipping upload mutation for ${filesCodec.name}: no matching bucket codec with prefix "${filesPrefix}"`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const hasOwnerId = !!matchingBucketCodec.attributes.owner_id;
|
|
149
|
+
const mutationName = `upload${filesTypeName}`;
|
|
150
|
+
const ownerIdGqlType = hasOwnerId
|
|
151
|
+
? build.getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input')
|
|
152
|
+
: null;
|
|
153
|
+
const InputType = new GraphQLInputObjectType({
|
|
154
|
+
name: `Upload${filesTypeName}Input`,
|
|
155
|
+
fields: {
|
|
156
|
+
bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
|
|
157
|
+
...(hasOwnerId
|
|
158
|
+
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
159
|
+
: {}),
|
|
433
160
|
contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
|
|
434
161
|
contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
|
|
435
162
|
size: { type: new GraphQLNonNull(GraphQLInt), description: 'File size in bytes' },
|
|
436
163
|
filename: { type: GraphQLString, description: 'Original filename (optional)' },
|
|
437
164
|
key: { type: GraphQLString, description: 'Custom S3 key (only when bucket has allow_custom_keys=true)' },
|
|
438
165
|
},
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
166
|
+
});
|
|
167
|
+
const PayloadType = new GraphQLObjectType({
|
|
168
|
+
name: `Upload${filesTypeName}Payload`,
|
|
169
|
+
fields: {
|
|
170
|
+
uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' },
|
|
171
|
+
fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID (UUID)' },
|
|
172
|
+
key: { type: new GraphQLNonNull(GraphQLString), description: 'The S3 object key' },
|
|
173
|
+
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
|
|
174
|
+
expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
|
|
175
|
+
previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const capturedFilesCodec = filesCodec;
|
|
179
|
+
log.debug(`Adding file upload mutation "${mutationName}" for ${filesTypeName} (entity-scoped=${hasOwnerId})`);
|
|
180
|
+
newFields[mutationName] = context.fieldWithHooks({ fieldName: mutationName }, {
|
|
181
|
+
description: `Upload a file: resolves the bucket by key, creates the file row, and returns a presigned PUT URL.`,
|
|
182
|
+
type: PayloadType,
|
|
183
|
+
args: {
|
|
184
|
+
input: { type: new GraphQLNonNull(InputType) },
|
|
185
|
+
},
|
|
186
|
+
plan(_$mutation, fieldArgs) {
|
|
187
|
+
const $input = fieldArgs.getRaw('input');
|
|
188
|
+
const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
|
|
189
|
+
const $contentHash = (0, grafast_1.access)($input, 'contentHash');
|
|
190
|
+
const $contentType = (0, grafast_1.access)($input, 'contentType');
|
|
191
|
+
const $size = (0, grafast_1.access)($input, 'size');
|
|
192
|
+
const $filename = (0, grafast_1.access)($input, 'filename');
|
|
193
|
+
const $customKey = (0, grafast_1.access)($input, 'key');
|
|
194
|
+
const $ownerId = hasOwnerId ? (0, grafast_1.access)($input, 'ownerId') : (0, grafast_1.lambda)(null, () => null);
|
|
453
195
|
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
454
196
|
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
455
197
|
const $combined = (0, grafast_1.object)({
|
|
456
|
-
bucketId: $bucketId,
|
|
457
198
|
bucketKey: $bucketKey,
|
|
458
|
-
|
|
459
|
-
bucketIsPublic: $bucketIsPublic,
|
|
460
|
-
bucketAllowCustomKeys: $bucketAllowCustomKeys,
|
|
461
|
-
bucketAllowedMimeTypes: $bucketAllowedMimeTypes,
|
|
462
|
-
bucketMaxFileSize: $bucketMaxFileSize,
|
|
463
|
-
bucketOwnerId: $bucketOwnerId,
|
|
199
|
+
ownerId: $ownerId,
|
|
464
200
|
contentHash: $contentHash,
|
|
465
201
|
contentType: $contentType,
|
|
466
202
|
size: $size,
|
|
@@ -476,19 +212,12 @@ function createPresignedUrlPlugin(options) {
|
|
|
476
212
|
if (!databaseId)
|
|
477
213
|
throw new Error('DATABASE_NOT_FOUND');
|
|
478
214
|
const allConfigs = await (0, storage_module_cache_1.loadAllStorageModules)(txClient, databaseId);
|
|
479
|
-
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(
|
|
215
|
+
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
480
216
|
if (!storageConfig)
|
|
481
217
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
482
|
-
const bucket =
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
type: vals.bucketType,
|
|
486
|
-
is_public: vals.bucketIsPublic,
|
|
487
|
-
owner_id: vals.bucketOwnerId,
|
|
488
|
-
allowed_mime_types: vals.bucketAllowedMimeTypes,
|
|
489
|
-
max_file_size: vals.bucketMaxFileSize,
|
|
490
|
-
allow_custom_keys: vals.bucketAllowCustomKeys ?? false,
|
|
491
|
-
};
|
|
218
|
+
const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
|
|
219
|
+
if (!bucket)
|
|
220
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
492
221
|
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
493
222
|
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
494
223
|
return processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
@@ -502,86 +231,100 @@ function createPresignedUrlPlugin(options) {
|
|
|
502
231
|
});
|
|
503
232
|
});
|
|
504
233
|
},
|
|
505
|
-
})
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
234
|
+
});
|
|
235
|
+
// --- Bulk file upload mutation ---
|
|
236
|
+
const BulkFileInputType = new GraphQLInputObjectType({
|
|
237
|
+
name: `Upload${filesTypeName}BulkFileInput`,
|
|
238
|
+
fields: {
|
|
239
|
+
contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
|
|
240
|
+
contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
|
|
241
|
+
size: { type: new GraphQLNonNull(GraphQLInt), description: 'File size in bytes' },
|
|
242
|
+
filename: { type: GraphQLString, description: 'Original filename (optional)' },
|
|
243
|
+
key: { type: GraphQLString, description: 'Custom S3 key (only when bucket has allow_custom_keys=true)' },
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
const BulkFilePayloadType = new GraphQLObjectType({
|
|
247
|
+
name: `Upload${filesTypeName}BulkFilePayload`,
|
|
248
|
+
fields: {
|
|
249
|
+
uploadUrl: { type: GraphQLString },
|
|
250
|
+
fileId: { type: new GraphQLNonNull(GraphQLString) },
|
|
251
|
+
key: { type: new GraphQLNonNull(GraphQLString) },
|
|
252
|
+
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
|
|
253
|
+
expiresAt: { type: GraphQLString },
|
|
254
|
+
previousVersionId: { type: GraphQLString },
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
const BulkInputType = new GraphQLInputObjectType({
|
|
258
|
+
name: `Upload${filesTypeName}BulkInput`,
|
|
259
|
+
fields: {
|
|
260
|
+
bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
|
|
261
|
+
...(hasOwnerId
|
|
262
|
+
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
263
|
+
: {}),
|
|
264
|
+
files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFileInputType))), description: 'Array of files to upload' },
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
const BulkPayloadType = new GraphQLObjectType({
|
|
268
|
+
name: `Upload${filesTypeName}BulkPayload`,
|
|
269
|
+
fields: {
|
|
270
|
+
files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFilePayloadType))) },
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
const bulkMutationName = `upload${filesTypeName}s`;
|
|
274
|
+
log.debug(`Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}`);
|
|
275
|
+
newFields[bulkMutationName] = context.fieldWithHooks({ fieldName: bulkMutationName }, {
|
|
276
|
+
description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`,
|
|
277
|
+
type: BulkPayloadType,
|
|
509
278
|
args: {
|
|
510
|
-
|
|
511
|
-
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkUploadFileInputType))),
|
|
512
|
-
description: 'Array of files to upload',
|
|
513
|
-
},
|
|
279
|
+
input: { type: new GraphQLNonNull(BulkInputType) },
|
|
514
280
|
},
|
|
515
|
-
plan($
|
|
516
|
-
const $
|
|
517
|
-
const $bucketKey =
|
|
518
|
-
const $
|
|
519
|
-
const $
|
|
520
|
-
const $bucketAllowCustomKeys = $parent.get('allow_custom_keys');
|
|
521
|
-
const $bucketAllowedMimeTypes = $parent.get('allowed_mime_types');
|
|
522
|
-
const $bucketMaxFileSize = $parent.get('max_file_size');
|
|
523
|
-
const $bucketOwnerId = capturedCodec.attributes.owner_id ? $parent.get('owner_id') : (0, grafast_1.lambda)(null, () => null);
|
|
524
|
-
const $files = fieldArgs.getRaw('files');
|
|
281
|
+
plan(_$mutation, fieldArgs) {
|
|
282
|
+
const $input = fieldArgs.getRaw('input');
|
|
283
|
+
const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
|
|
284
|
+
const $ownerId = hasOwnerId ? (0, grafast_1.access)($input, 'ownerId') : (0, grafast_1.lambda)(null, () => null);
|
|
285
|
+
const $files = (0, grafast_1.access)($input, 'files');
|
|
525
286
|
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
526
287
|
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
527
288
|
const $combined = (0, grafast_1.object)({
|
|
528
|
-
bucketId: $bucketId,
|
|
529
289
|
bucketKey: $bucketKey,
|
|
530
|
-
|
|
531
|
-
bucketIsPublic: $bucketIsPublic,
|
|
532
|
-
bucketAllowCustomKeys: $bucketAllowCustomKeys,
|
|
533
|
-
bucketAllowedMimeTypes: $bucketAllowedMimeTypes,
|
|
534
|
-
bucketMaxFileSize: $bucketMaxFileSize,
|
|
535
|
-
bucketOwnerId: $bucketOwnerId,
|
|
290
|
+
ownerId: $ownerId,
|
|
536
291
|
files: $files,
|
|
537
292
|
withPgClient: $withPgClient,
|
|
538
293
|
pgSettings: $pgSettings,
|
|
539
294
|
});
|
|
540
295
|
return (0, grafast_1.lambda)($combined, async (vals) => {
|
|
541
|
-
const { files } = vals;
|
|
542
|
-
if (!Array.isArray(files) || files.length === 0) {
|
|
543
|
-
throw new Error('INVALID_FILES: must provide at least one file');
|
|
544
|
-
}
|
|
545
296
|
return vals.withPgClient(vals.pgSettings, async (pgClient) => {
|
|
546
297
|
return pgClient.withTransaction(async (txClient) => {
|
|
547
298
|
const databaseId = await resolveDatabaseId(txClient);
|
|
548
299
|
if (!databaseId)
|
|
549
300
|
throw new Error('DATABASE_NOT_FOUND');
|
|
550
301
|
const allConfigs = await (0, storage_module_cache_1.loadAllStorageModules)(txClient, databaseId);
|
|
551
|
-
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(
|
|
302
|
+
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
552
303
|
if (!storageConfig)
|
|
553
304
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
558
|
-
if (totalSize > storageConfig.maxBulkTotalSize) {
|
|
559
|
-
throw new Error(`BULK_SIZE_EXCEEDED: total size ${totalSize} exceeds max ${storageConfig.maxBulkTotalSize} bytes`);
|
|
560
|
-
}
|
|
561
|
-
const bucket = {
|
|
562
|
-
id: vals.bucketId,
|
|
563
|
-
key: vals.bucketKey,
|
|
564
|
-
type: vals.bucketType,
|
|
565
|
-
is_public: vals.bucketIsPublic,
|
|
566
|
-
owner_id: vals.bucketOwnerId,
|
|
567
|
-
allowed_mime_types: vals.bucketAllowedMimeTypes,
|
|
568
|
-
max_file_size: vals.bucketMaxFileSize,
|
|
569
|
-
allow_custom_keys: vals.bucketAllowCustomKeys ?? false,
|
|
570
|
-
};
|
|
305
|
+
const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
|
|
306
|
+
if (!bucket)
|
|
307
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
571
308
|
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
572
309
|
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
573
310
|
const results = [];
|
|
574
|
-
for (
|
|
575
|
-
|
|
576
|
-
|
|
311
|
+
for (const file of vals.files) {
|
|
312
|
+
results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
313
|
+
contentHash: file.contentHash,
|
|
314
|
+
contentType: file.contentType,
|
|
315
|
+
size: file.size,
|
|
316
|
+
filename: file.filename,
|
|
317
|
+
key: file.key,
|
|
318
|
+
}));
|
|
577
319
|
}
|
|
578
320
|
return { files: results };
|
|
579
321
|
});
|
|
580
322
|
});
|
|
581
323
|
});
|
|
582
324
|
},
|
|
583
|
-
})
|
|
584
|
-
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return build.extend(fields, newFields, 'PresignedUrlPlugin adding file upload mutations');
|
|
585
328
|
},
|
|
586
329
|
/**
|
|
587
330
|
* Wrap delete* mutations on @storageFiles-tagged tables with S3 cleanup.
|