graphile-presigned-url-plugin 0.12.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/plugin.d.ts +6 -1
- package/esm/plugin.js +202 -9
- package/package.json +4 -4
- package/plugin.d.ts +6 -1
- package/plugin.js +200 -7
package/esm/plugin.d.ts
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
* type (e.g., `appBucket(key: "public"): AppBucket`), so upload operations
|
|
14
14
|
* can be accessed as proper GraphQL mutations instead of queries.
|
|
15
15
|
*
|
|
16
|
-
* 4.
|
|
16
|
+
* 4. File upload mutations — adds `upload<FileType>(input: {...})` mutations
|
|
17
|
+
* on root Mutation for each @storageFiles/@storageBuckets pair. These combine
|
|
18
|
+
* bucket resolution + file INSERT + presigned URL generation in one step.
|
|
19
|
+
* E.g., `uploadAppFile(input: { bucketKey: "public", contentHash: "...", ... })`
|
|
20
|
+
*
|
|
21
|
+
* 5. downloadUrl — handled by download-url-field.ts (separate plugin).
|
|
17
22
|
*
|
|
18
23
|
* Scope resolution uses the codec's schema/table name matched against
|
|
19
24
|
* cached storage module configs.
|
package/esm/plugin.js
CHANGED
|
@@ -13,15 +13,20 @@
|
|
|
13
13
|
* type (e.g., `appBucket(key: "public"): AppBucket`), so upload operations
|
|
14
14
|
* can be accessed as proper GraphQL mutations instead of queries.
|
|
15
15
|
*
|
|
16
|
-
* 4.
|
|
16
|
+
* 4. File upload mutations — adds `upload<FileType>(input: {...})` mutations
|
|
17
|
+
* on root Mutation for each @storageFiles/@storageBuckets pair. These combine
|
|
18
|
+
* bucket resolution + file INSERT + presigned URL generation in one step.
|
|
19
|
+
* E.g., `uploadAppFile(input: { bucketKey: "public", contentHash: "...", ... })`
|
|
20
|
+
*
|
|
21
|
+
* 5. downloadUrl — handled by download-url-field.ts (separate plugin).
|
|
17
22
|
*
|
|
18
23
|
* Scope resolution uses the codec's schema/table name matched against
|
|
19
24
|
* cached storage module configs.
|
|
20
25
|
*/
|
|
21
|
-
import { context as grafastContext, lambda, object } from 'grafast';
|
|
26
|
+
import { access, context as grafastContext, lambda, object } from 'grafast';
|
|
22
27
|
import 'graphile-build';
|
|
23
28
|
import { Logger } from '@pgpmjs/logger';
|
|
24
|
-
import { loadAllStorageModules, resolveStorageConfigFromCodec, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
|
|
29
|
+
import { loadAllStorageModules, resolveStorageConfigFromCodec, getBucketConfig, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
|
|
25
30
|
import { generatePresignedPutUrl, deleteS3Object } from './s3-signer';
|
|
26
31
|
const log = new Logger('graphile-presigned-url:plugin');
|
|
27
32
|
// --- Protocol-level constants (not configurable) ---
|
|
@@ -115,13 +120,14 @@ export function createPresignedUrlPlugin(options) {
|
|
|
115
120
|
*/
|
|
116
121
|
GraphQLObjectType_fields(fields, build, context) {
|
|
117
122
|
const { scope: { pgCodec, isPgClassType, isRootMutation }, } = context;
|
|
118
|
-
// --- Path 1: Add per-bucket mutation entry points on root Mutation ---
|
|
123
|
+
// --- Path 1: Add per-bucket mutation entry points + file creation mutations on root Mutation ---
|
|
119
124
|
if (isRootMutation) {
|
|
120
|
-
const { graphql: { GraphQLString, GraphQLNonNull }, } = build;
|
|
125
|
+
const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
|
|
121
126
|
const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
|
|
122
127
|
if (bucketCodecs.length === 0)
|
|
123
128
|
return fields;
|
|
124
129
|
const newFields = {};
|
|
130
|
+
// --- 1a: Per-bucket entry points (appBucket, dataRoomBucket, etc.) ---
|
|
125
131
|
for (const codec of bucketCodecs) {
|
|
126
132
|
const typeName = build.inflection.tableType(codec);
|
|
127
133
|
const bucketType = build.getTypeByName(typeName);
|
|
@@ -131,14 +137,11 @@ export function createPresignedUrlPlugin(options) {
|
|
|
131
137
|
}
|
|
132
138
|
const fieldName = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
|
133
139
|
const hasOwnerId = !!codec.attributes.owner_id;
|
|
134
|
-
// Find the PgResource for this codec so we can return a proper PgSelectSingleStep
|
|
135
140
|
const bucketResource = Object.values(build.input.pgRegistry.pgResources).find((r) => r.codec === codec && !r.isUnique && !r.isVirtual && !r.parameters);
|
|
136
141
|
if (!bucketResource) {
|
|
137
142
|
log.debug(`Skipping mutation entry point for ${codec.name}: no PgResource found`);
|
|
138
143
|
continue;
|
|
139
144
|
}
|
|
140
|
-
// Resolve the GraphQL type for ownerId from the codec's attribute codec
|
|
141
|
-
// (e.g. UUID scalar instead of String) so Grafast's type matching works.
|
|
142
145
|
const ownerIdType = hasOwnerId
|
|
143
146
|
? build.getGraphQLTypeByPgCodec(codec.attributes.owner_id.codec, 'input')
|
|
144
147
|
: null;
|
|
@@ -163,7 +166,197 @@ export function createPresignedUrlPlugin(options) {
|
|
|
163
166
|
},
|
|
164
167
|
});
|
|
165
168
|
}
|
|
166
|
-
|
|
169
|
+
// --- 1b: File upload mutations (uploadAppFile, uploadDataRoomFile, etc.) ---
|
|
170
|
+
const fileCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageFiles);
|
|
171
|
+
for (const filesCodec of fileCodecs) {
|
|
172
|
+
const filesTypeName = build.inflection.tableType(filesCodec);
|
|
173
|
+
const filesSchemaName = filesCodec.extensions?.pg?.schemaName;
|
|
174
|
+
// Find the matching bucket codec by schema name
|
|
175
|
+
const matchingBucketCodec = bucketCodecs.find((bc) => bc.extensions?.pg?.schemaName === filesSchemaName);
|
|
176
|
+
if (!matchingBucketCodec) {
|
|
177
|
+
log.debug(`Skipping create mutation for ${filesCodec.name}: no matching bucket codec in schema ${filesSchemaName}`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const hasOwnerId = !!matchingBucketCodec.attributes.owner_id;
|
|
181
|
+
const mutationName = `upload${filesTypeName}`;
|
|
182
|
+
const ownerIdGqlType = hasOwnerId
|
|
183
|
+
? build.getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input')
|
|
184
|
+
: null;
|
|
185
|
+
const InputType = new GraphQLInputObjectType({
|
|
186
|
+
name: `Upload${filesTypeName}Input`,
|
|
187
|
+
fields: {
|
|
188
|
+
bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
|
|
189
|
+
...(hasOwnerId
|
|
190
|
+
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
191
|
+
: {}),
|
|
192
|
+
contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
|
|
193
|
+
contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
|
|
194
|
+
size: { type: new GraphQLNonNull(GraphQLInt), description: 'File size in bytes' },
|
|
195
|
+
filename: { type: GraphQLString, description: 'Original filename (optional)' },
|
|
196
|
+
key: { type: GraphQLString, description: 'Custom S3 key (only when bucket has allow_custom_keys=true)' },
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
const PayloadType = new GraphQLObjectType({
|
|
200
|
+
name: `Upload${filesTypeName}Payload`,
|
|
201
|
+
fields: {
|
|
202
|
+
uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' },
|
|
203
|
+
fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID (UUID)' },
|
|
204
|
+
key: { type: new GraphQLNonNull(GraphQLString), description: 'The S3 object key' },
|
|
205
|
+
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
|
|
206
|
+
expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
|
|
207
|
+
previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
const capturedFilesCodec = filesCodec;
|
|
211
|
+
log.debug(`Adding file upload mutation "${mutationName}" for ${filesTypeName} (entity-scoped=${hasOwnerId})`);
|
|
212
|
+
newFields[mutationName] = context.fieldWithHooks({ fieldName: mutationName }, {
|
|
213
|
+
description: `Upload a file: resolves the bucket by key, creates the file row, and returns a presigned PUT URL.`,
|
|
214
|
+
type: PayloadType,
|
|
215
|
+
args: {
|
|
216
|
+
input: { type: new GraphQLNonNull(InputType) },
|
|
217
|
+
},
|
|
218
|
+
plan(_$mutation, fieldArgs) {
|
|
219
|
+
const $input = fieldArgs.getRaw('input');
|
|
220
|
+
const $bucketKey = access($input, 'bucketKey');
|
|
221
|
+
const $contentHash = access($input, 'contentHash');
|
|
222
|
+
const $contentType = access($input, 'contentType');
|
|
223
|
+
const $size = access($input, 'size');
|
|
224
|
+
const $filename = access($input, 'filename');
|
|
225
|
+
const $customKey = access($input, 'key');
|
|
226
|
+
const $ownerId = hasOwnerId ? access($input, 'ownerId') : lambda(null, () => null);
|
|
227
|
+
const $withPgClient = grafastContext().get('withPgClient');
|
|
228
|
+
const $pgSettings = grafastContext().get('pgSettings');
|
|
229
|
+
const $combined = object({
|
|
230
|
+
bucketKey: $bucketKey,
|
|
231
|
+
ownerId: $ownerId,
|
|
232
|
+
contentHash: $contentHash,
|
|
233
|
+
contentType: $contentType,
|
|
234
|
+
size: $size,
|
|
235
|
+
filename: $filename,
|
|
236
|
+
customKey: $customKey,
|
|
237
|
+
withPgClient: $withPgClient,
|
|
238
|
+
pgSettings: $pgSettings,
|
|
239
|
+
});
|
|
240
|
+
return lambda($combined, async (vals) => {
|
|
241
|
+
return vals.withPgClient(vals.pgSettings, async (pgClient) => {
|
|
242
|
+
return pgClient.withTransaction(async (txClient) => {
|
|
243
|
+
const databaseId = await resolveDatabaseId(txClient);
|
|
244
|
+
if (!databaseId)
|
|
245
|
+
throw new Error('DATABASE_NOT_FOUND');
|
|
246
|
+
const allConfigs = await loadAllStorageModules(txClient, databaseId);
|
|
247
|
+
const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
|
|
248
|
+
if (!storageConfig)
|
|
249
|
+
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
250
|
+
const bucket = await getBucketConfig(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
|
|
251
|
+
if (!bucket)
|
|
252
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
253
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
254
|
+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
255
|
+
return processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
256
|
+
contentHash: vals.contentHash,
|
|
257
|
+
contentType: vals.contentType,
|
|
258
|
+
size: vals.size,
|
|
259
|
+
filename: vals.filename,
|
|
260
|
+
key: vals.customKey,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
// --- Bulk file upload mutation ---
|
|
268
|
+
const BulkFileInputType = new GraphQLInputObjectType({
|
|
269
|
+
name: `Upload${filesTypeName}BulkFileInput`,
|
|
270
|
+
fields: {
|
|
271
|
+
contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
|
|
272
|
+
contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
|
|
273
|
+
size: { type: new GraphQLNonNull(GraphQLInt), description: 'File size in bytes' },
|
|
274
|
+
filename: { type: GraphQLString, description: 'Original filename (optional)' },
|
|
275
|
+
key: { type: GraphQLString, description: 'Custom S3 key (only when bucket has allow_custom_keys=true)' },
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const BulkFilePayloadType = new GraphQLObjectType({
|
|
279
|
+
name: `Upload${filesTypeName}BulkFilePayload`,
|
|
280
|
+
fields: {
|
|
281
|
+
uploadUrl: { type: GraphQLString },
|
|
282
|
+
fileId: { type: new GraphQLNonNull(GraphQLString) },
|
|
283
|
+
key: { type: new GraphQLNonNull(GraphQLString) },
|
|
284
|
+
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
|
|
285
|
+
expiresAt: { type: GraphQLString },
|
|
286
|
+
previousVersionId: { type: GraphQLString },
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const BulkInputType = new GraphQLInputObjectType({
|
|
290
|
+
name: `Upload${filesTypeName}BulkInput`,
|
|
291
|
+
fields: {
|
|
292
|
+
bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
|
|
293
|
+
...(hasOwnerId
|
|
294
|
+
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
295
|
+
: {}),
|
|
296
|
+
files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFileInputType))), description: 'Array of files to upload' },
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
const BulkPayloadType = new GraphQLObjectType({
|
|
300
|
+
name: `Upload${filesTypeName}BulkPayload`,
|
|
301
|
+
fields: {
|
|
302
|
+
files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFilePayloadType))) },
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const bulkMutationName = `upload${filesTypeName}s`;
|
|
306
|
+
log.debug(`Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}`);
|
|
307
|
+
newFields[bulkMutationName] = context.fieldWithHooks({ fieldName: bulkMutationName }, {
|
|
308
|
+
description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`,
|
|
309
|
+
type: BulkPayloadType,
|
|
310
|
+
args: {
|
|
311
|
+
input: { type: new GraphQLNonNull(BulkInputType) },
|
|
312
|
+
},
|
|
313
|
+
plan(_$mutation, fieldArgs) {
|
|
314
|
+
const $input = fieldArgs.getRaw('input');
|
|
315
|
+
const $bucketKey = access($input, 'bucketKey');
|
|
316
|
+
const $ownerId = hasOwnerId ? access($input, 'ownerId') : lambda(null, () => null);
|
|
317
|
+
const $files = access($input, 'files');
|
|
318
|
+
const $withPgClient = grafastContext().get('withPgClient');
|
|
319
|
+
const $pgSettings = grafastContext().get('pgSettings');
|
|
320
|
+
const $combined = object({
|
|
321
|
+
bucketKey: $bucketKey,
|
|
322
|
+
ownerId: $ownerId,
|
|
323
|
+
files: $files,
|
|
324
|
+
withPgClient: $withPgClient,
|
|
325
|
+
pgSettings: $pgSettings,
|
|
326
|
+
});
|
|
327
|
+
return lambda($combined, async (vals) => {
|
|
328
|
+
return vals.withPgClient(vals.pgSettings, async (pgClient) => {
|
|
329
|
+
return pgClient.withTransaction(async (txClient) => {
|
|
330
|
+
const databaseId = await resolveDatabaseId(txClient);
|
|
331
|
+
if (!databaseId)
|
|
332
|
+
throw new Error('DATABASE_NOT_FOUND');
|
|
333
|
+
const allConfigs = await loadAllStorageModules(txClient, databaseId);
|
|
334
|
+
const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
|
|
335
|
+
if (!storageConfig)
|
|
336
|
+
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
337
|
+
const bucket = await getBucketConfig(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
|
|
338
|
+
if (!bucket)
|
|
339
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
340
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
341
|
+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
342
|
+
const results = [];
|
|
343
|
+
for (const file of vals.files) {
|
|
344
|
+
results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
345
|
+
contentHash: file.contentHash,
|
|
346
|
+
contentType: file.contentType,
|
|
347
|
+
size: file.size,
|
|
348
|
+
filename: file.filename,
|
|
349
|
+
key: file.key,
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
return { files: results };
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return build.extend(fields, newFields, 'PresignedUrlPlugin adding per-bucket mutation entry points and file upload mutations');
|
|
167
360
|
}
|
|
168
361
|
// --- Path 2: Add upload fields on @storageBuckets types ---
|
|
169
362
|
if (!isPgClassType || !pgCodec || !pgCodec.attributes) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-presigned-url-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@aws-sdk/client-s3": "^3.1009.0",
|
|
44
44
|
"@aws-sdk/s3-request-presigner": "^3.1009.0",
|
|
45
|
-
"@pgpmjs/logger": "^2.
|
|
45
|
+
"@pgpmjs/logger": "^2.8.0",
|
|
46
46
|
"@pgsql/quotes": "^17.1.0",
|
|
47
47
|
"lru-cache": "^11.2.7"
|
|
48
48
|
},
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"postgraphile": "5.0.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@constructive-io/s3-utils": "^2.
|
|
59
|
+
"@constructive-io/s3-utils": "^2.14.0",
|
|
60
60
|
"@types/node": "^22.19.11",
|
|
61
61
|
"makage": "^0.1.10"
|
|
62
62
|
},
|
|
63
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "c1ecb2b54ea2bd12e2edeecc4ec721ccf93a67bf"
|
|
64
64
|
}
|
package/plugin.d.ts
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
* type (e.g., `appBucket(key: "public"): AppBucket`), so upload operations
|
|
14
14
|
* can be accessed as proper GraphQL mutations instead of queries.
|
|
15
15
|
*
|
|
16
|
-
* 4.
|
|
16
|
+
* 4. File upload mutations — adds `upload<FileType>(input: {...})` mutations
|
|
17
|
+
* on root Mutation for each @storageFiles/@storageBuckets pair. These combine
|
|
18
|
+
* bucket resolution + file INSERT + presigned URL generation in one step.
|
|
19
|
+
* E.g., `uploadAppFile(input: { bucketKey: "public", contentHash: "...", ... })`
|
|
20
|
+
*
|
|
21
|
+
* 5. downloadUrl — handled by download-url-field.ts (separate plugin).
|
|
17
22
|
*
|
|
18
23
|
* Scope resolution uses the codec's schema/table name matched against
|
|
19
24
|
* cached storage module configs.
|
package/plugin.js
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
* type (e.g., `appBucket(key: "public"): AppBucket`), so upload operations
|
|
15
15
|
* can be accessed as proper GraphQL mutations instead of queries.
|
|
16
16
|
*
|
|
17
|
-
* 4.
|
|
17
|
+
* 4. File upload mutations — adds `upload<FileType>(input: {...})` mutations
|
|
18
|
+
* on root Mutation for each @storageFiles/@storageBuckets pair. These combine
|
|
19
|
+
* bucket resolution + file INSERT + presigned URL generation in one step.
|
|
20
|
+
* E.g., `uploadAppFile(input: { bucketKey: "public", contentHash: "...", ... })`
|
|
21
|
+
*
|
|
22
|
+
* 5. downloadUrl — handled by download-url-field.ts (separate plugin).
|
|
18
23
|
*
|
|
19
24
|
* Scope resolution uses the codec's schema/table name matched against
|
|
20
25
|
* cached storage module configs.
|
|
@@ -119,13 +124,14 @@ function createPresignedUrlPlugin(options) {
|
|
|
119
124
|
*/
|
|
120
125
|
GraphQLObjectType_fields(fields, build, context) {
|
|
121
126
|
const { scope: { pgCodec, isPgClassType, isRootMutation }, } = context;
|
|
122
|
-
// --- Path 1: Add per-bucket mutation entry points on root Mutation ---
|
|
127
|
+
// --- Path 1: Add per-bucket mutation entry points + file creation mutations on root Mutation ---
|
|
123
128
|
if (isRootMutation) {
|
|
124
|
-
const { graphql: { GraphQLString, GraphQLNonNull }, } = build;
|
|
129
|
+
const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
|
|
125
130
|
const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
|
|
126
131
|
if (bucketCodecs.length === 0)
|
|
127
132
|
return fields;
|
|
128
133
|
const newFields = {};
|
|
134
|
+
// --- 1a: Per-bucket entry points (appBucket, dataRoomBucket, etc.) ---
|
|
129
135
|
for (const codec of bucketCodecs) {
|
|
130
136
|
const typeName = build.inflection.tableType(codec);
|
|
131
137
|
const bucketType = build.getTypeByName(typeName);
|
|
@@ -135,14 +141,11 @@ function createPresignedUrlPlugin(options) {
|
|
|
135
141
|
}
|
|
136
142
|
const fieldName = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
|
137
143
|
const hasOwnerId = !!codec.attributes.owner_id;
|
|
138
|
-
// Find the PgResource for this codec so we can return a proper PgSelectSingleStep
|
|
139
144
|
const bucketResource = Object.values(build.input.pgRegistry.pgResources).find((r) => r.codec === codec && !r.isUnique && !r.isVirtual && !r.parameters);
|
|
140
145
|
if (!bucketResource) {
|
|
141
146
|
log.debug(`Skipping mutation entry point for ${codec.name}: no PgResource found`);
|
|
142
147
|
continue;
|
|
143
148
|
}
|
|
144
|
-
// Resolve the GraphQL type for ownerId from the codec's attribute codec
|
|
145
|
-
// (e.g. UUID scalar instead of String) so Grafast's type matching works.
|
|
146
149
|
const ownerIdType = hasOwnerId
|
|
147
150
|
? build.getGraphQLTypeByPgCodec(codec.attributes.owner_id.codec, 'input')
|
|
148
151
|
: null;
|
|
@@ -167,7 +170,197 @@ function createPresignedUrlPlugin(options) {
|
|
|
167
170
|
},
|
|
168
171
|
});
|
|
169
172
|
}
|
|
170
|
-
|
|
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 schema name
|
|
179
|
+
const matchingBucketCodec = bucketCodecs.find((bc) => bc.extensions?.pg?.schemaName === filesSchemaName);
|
|
180
|
+
if (!matchingBucketCodec) {
|
|
181
|
+
log.debug(`Skipping create mutation for ${filesCodec.name}: no matching bucket codec in schema ${filesSchemaName}`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const hasOwnerId = !!matchingBucketCodec.attributes.owner_id;
|
|
185
|
+
const mutationName = `upload${filesTypeName}`;
|
|
186
|
+
const ownerIdGqlType = hasOwnerId
|
|
187
|
+
? build.getGraphQLTypeByPgCodec(matchingBucketCodec.attributes.owner_id.codec, 'input')
|
|
188
|
+
: null;
|
|
189
|
+
const InputType = new GraphQLInputObjectType({
|
|
190
|
+
name: `Upload${filesTypeName}Input`,
|
|
191
|
+
fields: {
|
|
192
|
+
bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
|
|
193
|
+
...(hasOwnerId
|
|
194
|
+
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
195
|
+
: {}),
|
|
196
|
+
contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
|
|
197
|
+
contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
|
|
198
|
+
size: { type: new GraphQLNonNull(GraphQLInt), description: 'File size in bytes' },
|
|
199
|
+
filename: { type: GraphQLString, description: 'Original filename (optional)' },
|
|
200
|
+
key: { type: GraphQLString, description: 'Custom S3 key (only when bucket has allow_custom_keys=true)' },
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
const PayloadType = new GraphQLObjectType({
|
|
204
|
+
name: `Upload${filesTypeName}Payload`,
|
|
205
|
+
fields: {
|
|
206
|
+
uploadUrl: { type: GraphQLString, description: 'Presigned PUT URL (null if deduplicated)' },
|
|
207
|
+
fileId: { type: new GraphQLNonNull(GraphQLString), description: 'The file ID (UUID)' },
|
|
208
|
+
key: { type: new GraphQLNonNull(GraphQLString), description: 'The S3 object key' },
|
|
209
|
+
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
|
|
210
|
+
expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
|
|
211
|
+
previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
const capturedFilesCodec = filesCodec;
|
|
215
|
+
log.debug(`Adding file upload mutation "${mutationName}" for ${filesTypeName} (entity-scoped=${hasOwnerId})`);
|
|
216
|
+
newFields[mutationName] = context.fieldWithHooks({ fieldName: mutationName }, {
|
|
217
|
+
description: `Upload a file: resolves the bucket by key, creates the file row, and returns a presigned PUT URL.`,
|
|
218
|
+
type: PayloadType,
|
|
219
|
+
args: {
|
|
220
|
+
input: { type: new GraphQLNonNull(InputType) },
|
|
221
|
+
},
|
|
222
|
+
plan(_$mutation, fieldArgs) {
|
|
223
|
+
const $input = fieldArgs.getRaw('input');
|
|
224
|
+
const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
|
|
225
|
+
const $contentHash = (0, grafast_1.access)($input, 'contentHash');
|
|
226
|
+
const $contentType = (0, grafast_1.access)($input, 'contentType');
|
|
227
|
+
const $size = (0, grafast_1.access)($input, 'size');
|
|
228
|
+
const $filename = (0, grafast_1.access)($input, 'filename');
|
|
229
|
+
const $customKey = (0, grafast_1.access)($input, 'key');
|
|
230
|
+
const $ownerId = hasOwnerId ? (0, grafast_1.access)($input, 'ownerId') : (0, grafast_1.lambda)(null, () => null);
|
|
231
|
+
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
232
|
+
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
233
|
+
const $combined = (0, grafast_1.object)({
|
|
234
|
+
bucketKey: $bucketKey,
|
|
235
|
+
ownerId: $ownerId,
|
|
236
|
+
contentHash: $contentHash,
|
|
237
|
+
contentType: $contentType,
|
|
238
|
+
size: $size,
|
|
239
|
+
filename: $filename,
|
|
240
|
+
customKey: $customKey,
|
|
241
|
+
withPgClient: $withPgClient,
|
|
242
|
+
pgSettings: $pgSettings,
|
|
243
|
+
});
|
|
244
|
+
return (0, grafast_1.lambda)($combined, async (vals) => {
|
|
245
|
+
return vals.withPgClient(vals.pgSettings, async (pgClient) => {
|
|
246
|
+
return pgClient.withTransaction(async (txClient) => {
|
|
247
|
+
const databaseId = await resolveDatabaseId(txClient);
|
|
248
|
+
if (!databaseId)
|
|
249
|
+
throw new Error('DATABASE_NOT_FOUND');
|
|
250
|
+
const allConfigs = await (0, storage_module_cache_1.loadAllStorageModules)(txClient, databaseId);
|
|
251
|
+
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
252
|
+
if (!storageConfig)
|
|
253
|
+
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
254
|
+
const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
|
|
255
|
+
if (!bucket)
|
|
256
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
257
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
258
|
+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
259
|
+
return processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
260
|
+
contentHash: vals.contentHash,
|
|
261
|
+
contentType: vals.contentType,
|
|
262
|
+
size: vals.size,
|
|
263
|
+
filename: vals.filename,
|
|
264
|
+
key: vals.customKey,
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
// --- Bulk file upload mutation ---
|
|
272
|
+
const BulkFileInputType = new GraphQLInputObjectType({
|
|
273
|
+
name: `Upload${filesTypeName}BulkFileInput`,
|
|
274
|
+
fields: {
|
|
275
|
+
contentHash: { type: new GraphQLNonNull(GraphQLString), description: 'SHA-256 content hash (hex-encoded, 64 chars)' },
|
|
276
|
+
contentType: { type: new GraphQLNonNull(GraphQLString), description: 'MIME type of the file' },
|
|
277
|
+
size: { type: new GraphQLNonNull(GraphQLInt), description: 'File size in bytes' },
|
|
278
|
+
filename: { type: GraphQLString, description: 'Original filename (optional)' },
|
|
279
|
+
key: { type: GraphQLString, description: 'Custom S3 key (only when bucket has allow_custom_keys=true)' },
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
const BulkFilePayloadType = new GraphQLObjectType({
|
|
283
|
+
name: `Upload${filesTypeName}BulkFilePayload`,
|
|
284
|
+
fields: {
|
|
285
|
+
uploadUrl: { type: GraphQLString },
|
|
286
|
+
fileId: { type: new GraphQLNonNull(GraphQLString) },
|
|
287
|
+
key: { type: new GraphQLNonNull(GraphQLString) },
|
|
288
|
+
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
|
|
289
|
+
expiresAt: { type: GraphQLString },
|
|
290
|
+
previousVersionId: { type: GraphQLString },
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
const BulkInputType = new GraphQLInputObjectType({
|
|
294
|
+
name: `Upload${filesTypeName}BulkInput`,
|
|
295
|
+
fields: {
|
|
296
|
+
bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
|
|
297
|
+
...(hasOwnerId
|
|
298
|
+
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
299
|
+
: {}),
|
|
300
|
+
files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFileInputType))), description: 'Array of files to upload' },
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
const BulkPayloadType = new GraphQLObjectType({
|
|
304
|
+
name: `Upload${filesTypeName}BulkPayload`,
|
|
305
|
+
fields: {
|
|
306
|
+
files: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(BulkFilePayloadType))) },
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const bulkMutationName = `upload${filesTypeName}s`;
|
|
310
|
+
log.debug(`Adding bulk file upload mutation "${bulkMutationName}" for ${filesTypeName}`);
|
|
311
|
+
newFields[bulkMutationName] = context.fieldWithHooks({ fieldName: bulkMutationName }, {
|
|
312
|
+
description: `Upload multiple files: resolves the bucket by key, creates file rows, and returns presigned PUT URLs for each.`,
|
|
313
|
+
type: BulkPayloadType,
|
|
314
|
+
args: {
|
|
315
|
+
input: { type: new GraphQLNonNull(BulkInputType) },
|
|
316
|
+
},
|
|
317
|
+
plan(_$mutation, fieldArgs) {
|
|
318
|
+
const $input = fieldArgs.getRaw('input');
|
|
319
|
+
const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
|
|
320
|
+
const $ownerId = hasOwnerId ? (0, grafast_1.access)($input, 'ownerId') : (0, grafast_1.lambda)(null, () => null);
|
|
321
|
+
const $files = (0, grafast_1.access)($input, 'files');
|
|
322
|
+
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
323
|
+
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
324
|
+
const $combined = (0, grafast_1.object)({
|
|
325
|
+
bucketKey: $bucketKey,
|
|
326
|
+
ownerId: $ownerId,
|
|
327
|
+
files: $files,
|
|
328
|
+
withPgClient: $withPgClient,
|
|
329
|
+
pgSettings: $pgSettings,
|
|
330
|
+
});
|
|
331
|
+
return (0, grafast_1.lambda)($combined, async (vals) => {
|
|
332
|
+
return vals.withPgClient(vals.pgSettings, async (pgClient) => {
|
|
333
|
+
return pgClient.withTransaction(async (txClient) => {
|
|
334
|
+
const databaseId = await resolveDatabaseId(txClient);
|
|
335
|
+
if (!databaseId)
|
|
336
|
+
throw new Error('DATABASE_NOT_FOUND');
|
|
337
|
+
const allConfigs = await (0, storage_module_cache_1.loadAllStorageModules)(txClient, databaseId);
|
|
338
|
+
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
339
|
+
if (!storageConfig)
|
|
340
|
+
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
341
|
+
const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined);
|
|
342
|
+
if (!bucket)
|
|
343
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
344
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
345
|
+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
346
|
+
const results = [];
|
|
347
|
+
for (const file of vals.files) {
|
|
348
|
+
results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
349
|
+
contentHash: file.contentHash,
|
|
350
|
+
contentType: file.contentType,
|
|
351
|
+
size: file.size,
|
|
352
|
+
filename: file.filename,
|
|
353
|
+
key: file.key,
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
return { files: results };
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return build.extend(fields, newFields, 'PresignedUrlPlugin adding per-bucket mutation entry points and file upload mutations');
|
|
171
364
|
}
|
|
172
365
|
// --- Path 2: Add upload fields on @storageBuckets types ---
|
|
173
366
|
if (!isPgClassType || !pgCodec || !pgCodec.attributes) {
|