Updating migration script
This commit is contained in:
@@ -127,6 +127,14 @@ Set these when Wasabi image storage is ready:
|
|||||||
|
|
||||||
Use a dedicated private bucket and access key for FlockPal images. Grant only the S3 permissions the app needs for that bucket. When `S3_PUBLIC_BASE_URL` is blank, FlockPal stores private object keys and returns short-lived signed URLs for bird photos.
|
Use a dedicated private bucket and access key for FlockPal images. Grant only the S3 permissions the app needs for that bucket. When `S3_PUBLIC_BASE_URL` is blank, FlockPal stores private object keys and returns short-lived signed URLs for bird photos.
|
||||||
|
|
||||||
|
Migrate existing Postgres-stored bird photos after deploying S3 image storage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml exec backend npm run migrate:bird-photos-to-s3 -- --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a dry run first by omitting `--apply`. Use `--limit=10` to migrate a small batch, and `--keep-data-url` if you want to leave the original inline image in Postgres during an initial verification pass.
|
||||||
|
|
||||||
Bucket settings recommendation:
|
Bucket settings recommendation:
|
||||||
|
|
||||||
- Enable bucket versioning if you want rollback protection from accidental overwrites or deletes. Add a lifecycle policy once upload volume is known because every object version contributes to stored data.
|
- Enable bucket versioning if you want rollback protection from accidental overwrites or deletes. Add a lifecycle policy once upload volume is known because every object version contributes to stored data.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
"worker:dev": "tsx watch src/worker.ts",
|
"worker:dev": "tsx watch src/worker.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "tsx --test src/**/*.test.ts",
|
"test": "tsx --test src/**/*.test.ts",
|
||||||
|
"migrate:bird-photos-to-s3": "node dist/scripts/migrateBirdPhotosToS3.js",
|
||||||
|
"migrate:bird-photos-to-s3:dev": "tsx src/scripts/migrateBirdPhotosToS3.ts",
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"worker": "node dist/worker.js"
|
"worker": "node dist/worker.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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<BirdPhotoMigrationRow>(
|
||||||
|
`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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user