// One-time migration: copy locally-stored documents into DigitalOcean Spaces. // Idempotent — re-running skips objects already present in the bucket. // // npx tsx scripts/migrate-storage-to-spaces.ts # migrate // npx tsx scripts/migrate-storage-to-spaces.ts --dry-run # report only, no writes // // After the app is switched to Spaces (lib/storage.ts), this exists only to lift any files // that were written to the old local STORAGE_PATH on the server before the cutover. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.resolve(__dirname, '../.env') }); import { S3Client, PutObjectCommand, HeadObjectCommand, } from '@aws-sdk/client-s3'; import { getDb, getPool, documents } from '@lawdesk/db'; const DRY_RUN = process.argv.includes('--dry-run'); const BUCKET = process.env.SPACES_BUCKET!; const STORAGE_PATH = process.env.STORAGE_PATH ?? './storage'; const s3 = new S3Client({ endpoint: process.env.SPACES_ENDPOINT, region: process.env.SPACES_REGION, credentials: { accessKeyId: process.env.SPACES_KEY!, secretAccessKey: process.env.SPACES_SECRET!, }, forcePathStyle: false, }); async function existsInBucket(key: string): Promise { try { await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key })); return true; } catch { return false; } } async function main() { console.log(`Migrating local documents → Spaces bucket "${BUCKET}"${DRY_RUN ? ' (dry run)' : ''}`); console.log(`Local root: ${path.resolve(STORAGE_PATH)}\n`); const db = getDb(); const rows = await db .select({ storageKey: documents.storageKey, name: documents.name, mimeType: documents.mimeType, sizeBytes: documents.sizeBytes, }) .from(documents); if (rows.length === 0) { console.log('No document rows in the database — nothing to migrate.'); await getPool().end(); return; } let uploaded = 0; let skipped = 0; let missing = 0; for (const row of rows) { const localPath = path.resolve(STORAGE_PATH, row.storageKey); if (await existsInBucket(row.storageKey)) { skipped++; console.log(` = already in bucket: ${row.storageKey}`); continue; } if (!fs.existsSync(localPath)) { missing++; console.warn(` ! local file missing (nothing to upload): ${row.storageKey} [${row.name}]`); continue; } if (DRY_RUN) { console.log(` → would upload: ${row.storageKey} (${row.sizeBytes} bytes)`); uploaded++; continue; } const body = fs.readFileSync(localPath); await s3.send( new PutObjectCommand({ Bucket: BUCKET, Key: row.storageKey, Body: body, ContentType: row.mimeType, ACL: 'private', }), ); uploaded++; console.log(` ✓ uploaded: ${row.storageKey} (${body.length} bytes)`); } console.log( `\nDone. ${uploaded} ${DRY_RUN ? 'to upload' : 'uploaded'}, ${skipped} already present, ${missing} missing locally (of ${rows.length} document rows).`, ); if (missing > 0) { console.log( 'Missing files have DB rows but no bytes on disk or in the bucket — they were already lost before migration (local storage was never durable).', ); } await getPool().end(); } main().catch((err) => { console.error('Migration failed:', err); process.exit(1); });