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/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. Delete middlewarewraps `delete*` mutations on `@storageFiles`-tagged tables
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
- * 5. downloadUrlhandled by download-url-field.ts (separate plugin).
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 requestUploadUrl and requestBulkUploadUrls fields on @storageBuckets types.
116
+ * Add file upload mutations (uploadAppFile, uploadDataRoomFile, etc.) on root Mutation.
124
117
  */
125
118
  GraphQLObjectType_fields(fields, build, context) {
126
- const { scope: { pgCodec, isPgClassType, isRootMutation }, } = context;
127
- // --- Path 1: Add per-bucket mutation entry points + file creation mutations on root Mutation ---
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 tags = pgCodec.extensions?.tags;
381
- if (!tags?.storageBuckets) {
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
- log.debug(`Adding upload fields to bucket type: ${pgCodec.name} (has @storageBuckets tag)`);
385
- const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLList, GraphQLInputObjectType, }, } = build;
386
- // --- Shared output types ---
387
- const UploadUrlPayloadType = new GraphQLObjectType({
388
- name: `${build.inflection.upperCamelCase(pgCodec.name)}RequestUploadUrlPayload`,
389
- fields: {
390
- uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' },
391
- fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID' },
392
- key: { type: new GraphQLNonNull(GraphQLString), description: 'The S3 object key' },
393
- deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated' },
394
- expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
395
- previousVersionId: { type: GraphQLString, description: 'ID of the previous version' },
396
- },
397
- });
398
- const BulkUploadFilePayloadType = new GraphQLObjectType({
399
- name: `${build.inflection.upperCamelCase(pgCodec.name)}BulkUploadFilePayload`,
400
- fields: {
401
- uploadUrl: { type: GraphQLString },
402
- fileId: { type: new GraphQLNonNull(GraphQLString) },
403
- key: { type: new GraphQLNonNull(GraphQLString) },
404
- deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
405
- expiresAt: { type: GraphQLString },
406
- previousVersionId: { type: GraphQLString },
407
- index: { type: new GraphQLNonNull(GraphQLInt), description: 'Index in the input array' },
408
- },
409
- });
410
- const BulkUploadUrlsPayloadType = new GraphQLObjectType({
411
- name: `${build.inflection.upperCamelCase(pgCodec.name)}RequestBulkUploadUrlsPayload`,
412
- fields: {
413
- files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkUploadFilePayloadType))) },
414
- },
415
- });
416
- const BulkUploadFileInputType = new GraphQLInputObjectType({
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
- plan($parent, fieldArgs) {
440
- const $bucketId = $parent.get('id');
441
- const $bucketKey = $parent.get('key');
442
- const $bucketType = $parent.get('type');
443
- const $bucketIsPublic = $parent.get('is_public');
444
- const $bucketAllowCustomKeys = $parent.get('allow_custom_keys');
445
- const $bucketAllowedMimeTypes = $parent.get('allowed_mime_types');
446
- const $bucketMaxFileSize = $parent.get('max_file_size');
447
- const $bucketOwnerId = capturedCodec.attributes.owner_id ? $parent.get('owner_id') : (0, grafast_1.lambda)(null, () => null);
448
- const $contentHash = fieldArgs.getRaw('contentHash');
449
- const $contentType = fieldArgs.getRaw('contentType');
450
- const $size = fieldArgs.getRaw('size');
451
- const $filename = fieldArgs.getRaw('filename');
452
- const $customKey = fieldArgs.getRaw('key');
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
- bucketType: $bucketType,
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)(capturedCodec, allConfigs);
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
- id: vals.bucketId,
484
- key: vals.bucketKey,
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
- requestBulkUploadUrls: context.fieldWithHooks({ fieldName: 'requestBulkUploadUrls' }, {
507
- description: 'Request presigned URLs for uploading multiple files to this bucket.',
508
- type: BulkUploadUrlsPayloadType,
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
- files: {
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($parent, fieldArgs) {
516
- const $bucketId = $parent.get('id');
517
- const $bucketKey = $parent.get('key');
518
- const $bucketType = $parent.get('type');
519
- const $bucketIsPublic = $parent.get('is_public');
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
- bucketType: $bucketType,
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)(capturedCodec, allConfigs);
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
- if (files.length > storageConfig.maxBulkFiles) {
555
- throw new Error(`BULK_LIMIT_EXCEEDED: max ${storageConfig.maxBulkFiles} files per batch`);
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 (let i = 0; i < files.length; i++) {
575
- const result = await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, files[i]);
576
- results.push({ ...result, index: i });
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
- }, `PresignedUrlPlugin adding upload fields to ${pgCodec.name}`);
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.