Skip to content

Commit 29bf045

Browse files
feat(functions): add storage-confirm-upload function
Handles deferred upload confirmation via HeadObject check. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8f4d30e commit 29bf045

2 files changed

Lines changed: 180 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "storage-confirm-upload",
3+
"version": "1.0.0",
4+
"type": "node-graphql",
5+
"port": 8085,
6+
"taskIdentifier": "storage:confirm_upload",
7+
"description": "Confirms file upload by checking S3 HeadObject and transitioning status to uploaded",
8+
"dependencies": {
9+
"@aws-sdk/client-s3": "^3.1060.0",
10+
"@pgpmjs/env": "^2.15.3",
11+
"@pgpmjs/logger": "^2.4.3",
12+
"pg": "^8.16.0"
13+
}
14+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { HeadObjectCommand, S3Client } from '@aws-sdk/client-s3';
2+
import type { FunctionHandler } from '@constructive-io/fn-runtime';
3+
import { createLogger } from '@pgpmjs/logger';
4+
import { Client as PgClient } from 'pg';
5+
6+
type StorageConfirmUploadPayload = {
7+
file_id: string;
8+
key: string;
9+
bucket_id: string;
10+
mime_type: string;
11+
schema?: string;
12+
table?: string;
13+
};
14+
15+
const logger = createLogger('storage-confirm-upload');
16+
17+
const createPgClient = (): PgClient => {
18+
return new PgClient({
19+
host: process.env.PGHOST || 'localhost',
20+
port: Number(process.env.PGPORT) || 5432,
21+
user: process.env.PGUSER || 'postgres',
22+
password: process.env.PGPASSWORD || 'password',
23+
database: process.env.PGDATABASE || 'constructive',
24+
});
25+
};
26+
27+
const createS3Client = (): S3Client => {
28+
const endpoint = process.env.S3_ENDPOINT || process.env.CDN_ENDPOINT;
29+
return new S3Client({
30+
region: process.env.CDN_REGION || process.env.AWS_REGION || 'us-east-1',
31+
endpoint: endpoint,
32+
forcePathStyle: true,
33+
credentials: {
34+
accessKeyId: process.env.CDN_ACCESS_KEY || process.env.AWS_ACCESS_KEY_ID || '',
35+
secretAccessKey: process.env.CDN_SECRET_KEY || process.env.AWS_SECRET_ACCESS_KEY || '',
36+
},
37+
});
38+
};
39+
40+
const resolveS3BucketName = async (
41+
bucketId: string,
42+
storageSchema: string,
43+
databaseId: string
44+
): Promise<string> => {
45+
const pg = createPgClient();
46+
try {
47+
await pg.connect();
48+
// Get bucket type to construct S3 bucket name
49+
// Format: test-bucket-{type}-{database_id}
50+
const result = await pg.query(
51+
`SELECT b.type
52+
FROM "${storageSchema}".app_buckets b
53+
WHERE b.id = $1`,
54+
[bucketId]
55+
);
56+
if (result.rows.length > 0 && result.rows[0].type) {
57+
const bucketType = result.rows[0].type;
58+
return `test-bucket-${bucketType}-${databaseId}`;
59+
}
60+
// Fallback to bucket_id if type not found
61+
return bucketId;
62+
} finally {
63+
await pg.end();
64+
}
65+
};
66+
67+
const checkFileExistsInS3 = async (
68+
s3: S3Client,
69+
bucket: string,
70+
key: string
71+
): Promise<boolean> => {
72+
try {
73+
await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
74+
return true;
75+
} catch (err: unknown) {
76+
const error = err as { name?: string };
77+
if (error.name === 'NotFound' || error.name === 'NoSuchKey') {
78+
return false;
79+
}
80+
throw err;
81+
}
82+
};
83+
84+
const confirmFileUploaded = async (
85+
fileId: string,
86+
schema: string,
87+
table: string
88+
): Promise<void> => {
89+
const pg = createPgClient();
90+
try {
91+
await pg.connect();
92+
const privateSchema = schema.replace(/-public$/, '-private');
93+
const fnName = `${table}_confirm_uploaded`;
94+
95+
await pg.query(`SELECT "${privateSchema}"."${fnName}"($1)`, [fileId]);
96+
97+
logger.info('[storage-confirm-upload] File status confirmed', { fileId, schema, table });
98+
} finally {
99+
await pg.end();
100+
}
101+
};
102+
103+
const handler: FunctionHandler<StorageConfirmUploadPayload> = async (
104+
params,
105+
context
106+
) => {
107+
const { job, log } = context;
108+
const { file_id, key, bucket_id, mime_type, schema, table } = params;
109+
110+
if (!file_id || !key || !bucket_id) {
111+
throw new Error('Missing required fields: file_id, key, or bucket_id');
112+
}
113+
114+
log.info('[storage-confirm-upload] Processing', {
115+
file_id,
116+
key,
117+
bucket_id,
118+
schema,
119+
table,
120+
});
121+
122+
const s3 = createS3Client();
123+
124+
const storageSchema = schema
125+
? schema.replace(/-app-public$/, '-storage-public')
126+
: 'storage_public';
127+
128+
let bucketName = bucket_id;
129+
if (schema && job.databaseId) {
130+
try {
131+
bucketName = await resolveS3BucketName(bucket_id, storageSchema, job.databaseId);
132+
log.info('[storage-confirm-upload] Resolved bucket name', { bucket_id, bucketName });
133+
} catch (err) {
134+
log.warn('[storage-confirm-upload] Could not resolve bucket, using bucket_id', {
135+
bucket_id,
136+
error: (err as Error).message,
137+
});
138+
}
139+
}
140+
141+
const exists = await checkFileExistsInS3(s3, bucketName, key);
142+
143+
if (!exists) {
144+
log.info('[storage-confirm-upload] File not found in S3, will retry', {
145+
bucket: bucketName,
146+
key,
147+
});
148+
throw new Error(`File not found in S3: ${bucketName}/${key}`);
149+
}
150+
151+
log.info('[storage-confirm-upload] File exists in S3', { bucket: bucketName, key });
152+
153+
const targetSchema = schema || storageSchema;
154+
const targetTable = table || 'app_files';
155+
156+
await confirmFileUploaded(file_id, targetSchema, targetTable);
157+
158+
return {
159+
success: true,
160+
file_id,
161+
bucket: bucketName,
162+
key,
163+
};
164+
};
165+
166+
export default handler;

0 commit comments

Comments
 (0)