Skip to content

Commit b06932e

Browse files
committed
fix: use Grafast plan() instead of resolve() for downloadUrl field
PostGraphile V5's Grafast execution engine does not call traditional resolve() functions on PG table type fields. The downloadUrl computed field was returning null because its resolve() was never invoked. Replace with Grafast plan() using lambda(), object(), and grafastContext() — the same pattern used by requestUploadUrl and confirmUpload mutations in the same plugin. This fixes presigned GET URL generation for private files and public CDN URL generation for public files.
1 parent ad2d49e commit b06932e

1 file changed

Lines changed: 70 additions & 50 deletions

File tree

graphile/graphile-presigned-url-plugin/src/download-url-field.ts

Lines changed: 70 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,16 @@
1111
* COMMENT ON TABLE files IS E'@storageFiles\nStorage files table';
1212
*
1313
* This is explicit and reliable — no duck-typing on column names.
14+
*
15+
* IMPORTANT: Uses Grafast plan() instead of traditional resolve().
16+
* In PostGraphile V5, Grafast's planning system does not invoke traditional
17+
* resolve functions on PG table type fields — it plans them as column
18+
* lookups. Since downloadUrl is a computed field (not a real column),
19+
* the plan() function is required for Grafast to execute the S3 signing.
1420
*/
1521

1622
import type { GraphileConfig } from 'graphile-config';
23+
import { context as grafastContext, lambda, object } from 'grafast';
1724
import { Logger } from '@pgpmjs/logger';
1825

1926
import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
@@ -76,7 +83,7 @@ export function createDownloadUrlPlugin(
7683

7784
return {
7885
name: 'PresignedUrlDownloadPlugin',
79-
version: '0.1.0',
86+
version: '0.2.0',
8087
description: 'Adds downloadUrl computed field to File types tagged with @storageFiles',
8188

8289
schema: {
@@ -113,58 +120,71 @@ export function createDownloadUrlPlugin(
113120
'URL to download this file. For public files, returns the public URL. ' +
114121
'For private files, returns a time-limited presigned URL.',
115122
type: GraphQLString,
116-
async resolve(parent: any, _args: any, context: any) {
117-
const key = parent.key || parent.get?.('key');
118-
const isPublic = parent.is_public ?? parent.get?.('is_public');
119-
const filename = parent.filename || parent.get?.('filename');
120-
const status = parent.status || parent.get?.('status');
121-
122-
if (!key) return null;
123-
124-
// Only provide download URLs for ready/processed files
125-
if (status !== 'ready' && status !== 'processed') {
126-
return null;
127-
}
128-
129-
// Resolve per-database config (bucket, publicUrlPrefix, expiry)
130-
let s3ForDb = resolveS3(options); // fallback to global
131-
let downloadUrlExpirySeconds = 3600; // fallback default
132-
try {
133-
const withPgClient = context.pgSettings
134-
? context.withPgClient
135-
: null;
136-
if (withPgClient) {
137-
const resolved = await withPgClient(null, async (pgClient: any) => {
138-
const dbResult = await pgClient.query({
139-
text: `SELECT jwt_private.current_database_id() AS id`,
123+
plan($parent: any) {
124+
// Access file attributes from the parent PgSelectSingleStep
125+
const $key = $parent.get('key');
126+
const $isPublic = $parent.get('is_public');
127+
const $filename = $parent.get('filename');
128+
const $status = $parent.get('status');
129+
130+
// Access GraphQL context for per-database config resolution
131+
const $withPgClient = (grafastContext() as any).get('withPgClient');
132+
const $pgSettings = (grafastContext() as any).get('pgSettings');
133+
134+
const $combined = object({
135+
key: $key,
136+
isPublic: $isPublic,
137+
filename: $filename,
138+
status: $status,
139+
withPgClient: $withPgClient,
140+
pgSettings: $pgSettings,
141+
});
142+
143+
return lambda($combined, async ({ key, isPublic, filename, status, withPgClient, pgSettings }: any) => {
144+
if (!key) return null;
145+
146+
// Only provide download URLs for ready/processed files
147+
if (status !== 'ready' && status !== 'processed') {
148+
return null;
149+
}
150+
151+
// Resolve per-database config (bucket, publicUrlPrefix, expiry)
152+
let s3ForDb = resolveS3(options); // fallback to global
153+
let downloadUrlExpirySeconds = 3600; // fallback default
154+
try {
155+
if (withPgClient && pgSettings) {
156+
const resolved = await withPgClient(null, async (pgClient: any) => {
157+
const dbResult = await pgClient.query({
158+
text: `SELECT jwt_private.current_database_id() AS id`,
159+
});
160+
const databaseId = dbResult.rows[0]?.id;
161+
if (!databaseId) return null;
162+
const config = await getStorageModuleConfig(pgClient, databaseId);
163+
if (!config) return null;
164+
return { config, databaseId };
140165
});
141-
const databaseId = dbResult.rows[0]?.id;
142-
if (!databaseId) return null;
143-
const config = await getStorageModuleConfig(pgClient, databaseId);
144-
if (!config) return null;
145-
return { config, databaseId };
146-
});
147-
if (resolved) {
148-
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
149-
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
166+
if (resolved) {
167+
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
168+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
169+
}
150170
}
171+
} catch {
172+
// Fall back to global config if lookup fails
151173
}
152-
} catch {
153-
// Fall back to global config if lookup fails
154-
}
155-
156-
if (isPublic && s3ForDb.publicUrlPrefix) {
157-
// Public file: return direct CDN URL (per-database prefix)
158-
return `${s3ForDb.publicUrlPrefix}/${key}`;
159-
}
160-
161-
// Private file: generate presigned GET URL (per-database bucket)
162-
return generatePresignedGetUrl(
163-
s3ForDb,
164-
key,
165-
downloadUrlExpirySeconds,
166-
filename || undefined,
167-
);
174+
175+
if (isPublic && s3ForDb.publicUrlPrefix) {
176+
// Public file: return direct CDN URL (per-database prefix)
177+
return `${s3ForDb.publicUrlPrefix}/${key}`;
178+
}
179+
180+
// Private file: generate presigned GET URL (per-database bucket)
181+
return generatePresignedGetUrl(
182+
s3ForDb,
183+
key,
184+
downloadUrlExpirySeconds,
185+
filename || undefined,
186+
);
187+
});
168188
},
169189
},
170190
),

0 commit comments

Comments
 (0)