import { db } from '../db/client.js'; import { ensureSchema } from '../db/schema.js'; import { buildBirdPhotoObjectKey, getImageExtensionFromContentType, getS3ImageStorageConfig } from '../storage/imageStorageConfig.js'; import { deleteS3Object, putS3Object } from '../storage/s3Client.js'; type BirdPhotoMigrationRow = { id: string; workspace_id: number; name: string; photo_data_url: string; }; const parseDataImage = (dataUrl: string) => { const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl); if (!match) { return null; } return { contentType: match[1], content: Buffer.from(match[2], 'base64'), }; }; const getArgValue = (name: string) => { const prefix = `${name}=`; const match = process.argv.find((arg) => arg.startsWith(prefix)); return match ? match.slice(prefix.length) : null; }; const dryRun = !process.argv.includes('--apply'); const keepDataUrl = process.argv.includes('--keep-data-url'); const limitArg = getArgValue('--limit'); const limit = limitArg ? Number(limitArg) : null; if (limit !== null && (!Number.isInteger(limit) || limit <= 0)) { console.error('Invalid --limit value. Use a positive integer.'); process.exit(2); } const run = async () => { await ensureSchema(); const s3Config = getS3ImageStorageConfig(); if (!s3Config) { throw new Error('S3 image storage is not fully configured. Set IMAGE_STORAGE_PROVIDER=s3 and the S3_* environment variables.'); } const result = await db.query( `SELECT id, workspace_id, name, photo_data_url FROM birds WHERE photo_object_key IS NULL AND photo_data_url LIKE 'data:image/%' ORDER BY created_at ASC ${limit ? 'LIMIT $1' : ''}`, limit ? [limit] : undefined, ); if (dryRun) { console.log(`Dry run: ${result.rows.length} bird photo(s) would be migrated to bucket ${s3Config.bucket}.`); console.log('Run with --apply to upload objects and update rows.'); return; } let migrated = 0; let skipped = 0; let failed = 0; for (const bird of result.rows) { const parsedImage = parseDataImage(bird.photo_data_url); if (!parsedImage) { skipped += 1; console.warn(`Skipping bird ${bird.id} (${bird.name}): invalid data URL.`); continue; } const objectKey = buildBirdPhotoObjectKey({ workspaceId: bird.workspace_id, birdId: bird.id, extension: getImageExtensionFromContentType(parsedImage.contentType), }); try { await putS3Object({ config: s3Config, objectKey, content: parsedImage.content, contentType: parsedImage.contentType, }); const updateResult = await db.query( `UPDATE birds SET photo_object_key = $2, photo_content_type = $3, photo_updated_at = CURRENT_TIMESTAMP, photo_data_url = CASE WHEN $4::boolean THEN photo_data_url ELSE NULL END WHERE id = $1 AND photo_object_key IS NULL AND photo_data_url LIKE 'data:image/%'`, [bird.id, objectKey, parsedImage.contentType, keepDataUrl], ); if (updateResult.rowCount !== 1) { await deleteS3Object({ config: s3Config, objectKey }); skipped += 1; console.warn(`Skipped bird ${bird.id} (${bird.name}): row changed before update.`); continue; } migrated += 1; console.log(`Migrated bird ${bird.id} (${bird.name}) -> ${objectKey}`); } catch (error) { failed += 1; console.error(`Failed to migrate bird ${bird.id} (${bird.name}):`, error); } } console.log(`Migration complete: migrated=${migrated}, skipped=${skipped}, failed=${failed}`); if (failed > 0) { process.exitCode = 1; } }; run() .catch((error) => { console.error('Bird photo migration failed:', error); process.exitCode = 1; }) .finally(async () => { await db.close(); });