Skip to content

Commit 5c5c0b5

Browse files
authored
Merge pull request #1019 from constructive-io/devin/1776716000-fix-download-url-grafast-plan
fix: use Grafast plan() instead of resolve() for downloadUrl field
2 parents ad2d49e + b06932e commit 5c5c0b5

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)