feat(api): harden production data import

Preserve live Dokploy state while importing legacy Supabase data. Add schema parity, safe relationship reconciliation, storage quarantine, and production-shaped integration coverage.
This commit is contained in:
jjbrunton committed 2026-08-29 00:04:11 +01:00
1 parent 78900a24fb
commit aedba2a480
8 files changed
+795 -85

No files matched your search

+26
View File
@@ -10,6 +10,10 @@ export const categories = pgTable('categories', {
color: text('color'),
sortOrder: integer('sort_order').notNull().default(0),
isPublic: boolean('is_public').notNull().default(true),
contentStatus: text('content_status').notNull().default('unreviewed'),
contentReviewReason: text('content_review_reason'),
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -26,6 +30,10 @@ export const questionPacks = pgTable('question_packs', {
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2, mode: 'number' }),
sortOrder: integer('sort_order').notNull().default(0),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
contentStatus: text('content_status').notNull().default('unreviewed'),
contentReviewReason: text('content_review_reason'),
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
@@ -42,6 +50,10 @@ export const questions = pgTable('questions', {
config: jsonb('config').notNull().default({}),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
inverseOf: uuid('inverse_of'),
contentStatus: text('content_status').notNull().default('unreviewed'),
contentReviewReason: text('content_review_reason'),
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('questions_pack_id_idx').on(table.packId)]);
@@ -64,3 +76,17 @@ export const couplePacks = pgTable('couple_packs', {
enabled: boolean('enabled').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [primaryKey({ columns: [table.coupleId, table.packId] })]);
export const contentReviews = pgTable('content_reviews', {
id: uuid('id').primaryKey().defaultRandom(),
entityType: text('entity_type').notNull(),
entityId: uuid('entity_id').notNull(),
previousStatus: text('previous_status').notNull(),
newStatus: text('new_status').notNull(),
reason: text('reason').notNull(),
changedBy: uuid('changed_by').references(() => profiles.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('content_reviews_entity_idx').on(table.entityType, table.entityId, table.createdAt),
index('content_reviews_created_at_idx').on(table.createdAt),
]);
+108 -19
View File
@@ -1,14 +1,31 @@
import type { Pool, PoolClient } from 'pg';
import type { DatabaseMigrationOptions, TableResult } from './types.js';
interface TableSpec { name: string; key: string[]; replace?: boolean }
interface TableSpec {
name: string;
key: string[];
replace?: boolean;
/** Target-column to legacy source-column rename. */
columnMap?: Record<string, string>;
/** Target-column SQL expressions for legacy rows that need a derived value. */
sourceExpressions?: Record<string, string>;
/** Columns populated after all rows exist, for self-referential foreign keys. */
deferredColumns?: string[];
/** Keep a newer standalone-backend row when the same key exists in both systems. */
preserveNewer?: boolean;
/** Singleton runtime configuration is owned by the live target during an initial merge. */
preserveExistingOnInitial?: boolean;
}
export const TABLES: TableSpec[] = [
{ name: 'couples', key: ['id'] }, { name: 'profiles', key: ['id'] },
{ name: 'couples', key: ['id'], sourceExpressions: { invite_code: 'upper("couples"."invite_code")' } },
{ name: 'profiles', key: ['id'], preserveNewer: true },
{ name: 'admin_users', key: ['id'] }, { name: 'master_keys', key: ['id'] },
{ name: 'categories', key: ['id'] }, { name: 'question_packs', key: ['id'] },
{ name: 'topics', key: ['id'] }, { name: 'pack_topics', key: ['pack_id', 'topic_id'] },
{ name: 'questions', key: ['id'] }, { name: 'app_config', key: ['id'], replace: true },
{ name: 'ai_config', key: ['id'], replace: true },
{ name: 'questions', key: ['id'], deferredColumns: ['inverse_of'] },
{ name: 'content_reviews', key: ['id'] },
{ name: 'app_config', key: ['id'], replace: true, preserveExistingOnInitial: true },
{ name: 'ai_config', key: ['id'], replace: true, preserveExistingOnInitial: true },
{ name: 'dare_packs', key: ['id'] }, { name: 'dares', key: ['id'] },
{ name: 'sent_dares', key: ['id'] }, { name: 'dare_messages', key: ['id'] },
{ name: 'couple_packs', key: ['couple_id', 'pack_id'] }, { name: 'responses', key: ['id'] },
@@ -18,8 +35,21 @@ export const TABLES: TableSpec[] = [
{ name: 'notification_preferences', key: ['user_id'] }, { name: 'feedback', key: ['id'] },
{ name: 'subscriptions', key: ['id'] }, { name: 'revenuecat_webhook_events', key: ['id'] },
{ name: 'redemption_codes', key: ['id'] }, { name: 'code_redemptions', key: ['id'] },
{ name: 'feature_interests', key: ['user_id', 'feature'] }, { name: 'live_draw_sessions', key: ['couple_id'] },
{ name: 'catchup_reminder_tracking', key: ['user_id'] }, { name: 'audit_logs', key: ['id'] },
{ name: 'feature_interests', key: ['user_id', 'feature'], columnMap: { feature: 'feature_name' } },
{
name: 'live_draw_sessions', key: ['couple_id'],
sourceExpressions: {
updated_by: '(select p.id from profiles p where p.couple_id="live_draw_sessions"."couple_id" order by p.created_at,p.id limit 1)',
},
},
{ name: 'catchup_reminder_tracking', key: ['user_id'] },
{
name: 'audit_logs', key: ['id'],
sourceExpressions: {
actor_user_id: '"audit_logs"."admin_user_id"',
admin_user_id: '(select au.id from admin_users au where au.user_id="audit_logs"."admin_user_id" limit 1)',
},
},
];
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
@@ -27,18 +57,42 @@ async function columns(pool: Pool | PoolClient, table: string): Promise<string[]
const result = await pool.query<{ column_name: string }>(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 order by ordinal_position`, [table]);
return result.rows.map((row) => row.column_name);
}
async function jsonColumns(pool: Pool | PoolClient, table: string): Promise<Set<string>> {
const result = await pool.query<{ column_name: string }>(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 and data_type in ('json','jsonb')`, [table]);
return new Set(result.rows.map((row) => row.column_name));
}
async function count(pool: Pool | PoolClient, table: string): Promise<number> {
return Number((await pool.query<{ count: string }>(`select count(*) count from ${quote(table)}`)).rows[0]?.count ?? 0);
}
async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record<string, unknown>[]): Promise<number> {
if (spec.replace) await client.query(`delete from ${quote(spec.name)}`);
async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record<string, unknown>[], jsonNames: Set<string>, finalSync: boolean): Promise<number> {
if (spec.preserveExistingOnInitial && !finalSync && await count(client, spec.name)) return 0;
if (spec.replace && finalSync) await client.query(`delete from ${quote(spec.name)}`);
if (!rows.length) return 0;
const update = names.filter((name) => !spec.key.includes(name));
const deferred = new Set(spec.deferredColumns ?? []);
const immediateNames = names.filter((name) => !deferred.has(name));
const update = immediateNames.filter((name) => !spec.key.includes(name));
const conflict = spec.key.map(quote).join(',');
const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}` : 'do nothing';
const freshness = spec.preserveNewer && names.includes('updated_at')
? ` where ${quote(spec.name)}.${quote('updated_at')} is null or (excluded.${quote('updated_at')} is not null and excluded.${quote('updated_at')}>${quote(spec.name)}.${quote('updated_at')})`
: '';
const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}${freshness}` : 'do nothing';
for (const row of rows) {
await client.query(`insert into ${quote(spec.name)} (${names.map(quote).join(',')}) values (${names.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`, names.map((name) => row[name]));
await client.query(
`insert into ${quote(spec.name)} (${immediateNames.map(quote).join(',')}) values (${immediateNames.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`,
immediateNames.map((name) => jsonNames.has(name) && row[name] !== null ? JSON.stringify(row[name]) : row[name]),
);
}
if (deferred.size) {
const deferredNames = names.filter((name) => deferred.has(name));
for (const row of rows) {
const values = deferredNames.map((name) => jsonNames.has(name) && row[name] !== null ? JSON.stringify(row[name]) : row[name]);
const whereValues = spec.key.map((name) => row[name]);
await client.query(
`update ${quote(spec.name)} set ${deferredNames.map((name, index) => `${quote(name)}=$${index + 1}`).join(',')} where ${spec.key.map((name, index) => `${quote(name)}=$${deferredNames.length + index + 1}`).join(' and ')}`,
[...values, ...whereValues],
);
}
}
return rows.length;
}
@@ -59,16 +113,24 @@ async function pruneRows(client: PoolClient, spec: TableSpec, sourceRows: Record
export async function migrateDatabase(source: Pool, target: Pool, options: DatabaseMigrationOptions): Promise<TableResult[]> {
const results: TableResult[] = [];
for (const spec of TABLES) {
const [sourceColumns, targetColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name)]);
const [sourceColumns, targetColumns, targetJsonColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name), jsonColumns(target, spec.name)]);
if (!sourceColumns.length || !targetColumns.length) {
results.push({ table: spec.name, sourceRows: 0, targetRows: targetColumns.length ? await count(target, spec.name) : 0, importedRows: 0, prunedRows: 0, status: 'skipped', note: !sourceColumns.length ? 'source table absent' : 'target table absent' });
continue;
}
const common = targetColumns.filter((name) => sourceColumns.includes(name));
const sourceOnly = sourceColumns.filter((name) => !targetColumns.includes(name));
const sourceName = (targetName: string) => spec.columnMap?.[targetName] ?? targetName;
const common = targetColumns.filter((name) => sourceColumns.includes(sourceName(name)) || Boolean(spec.sourceExpressions?.[name]));
const consumedSourceColumns = new Set(common.map(sourceName).filter((name) => sourceColumns.includes(name)));
const sourceOnly = sourceColumns.filter((name) => !consumedSourceColumns.has(name));
if (!spec.key.every((key) => common.includes(key))) throw new Error(`${spec.name} is missing required migration key columns`);
const sourceRows = (await source.query<Record<string, unknown>>(`select ${common.map(quote).join(',')} from ${quote(spec.name)}`)).rows;
let importedRows = 0; let prunedRows = 0;
const selections = common.map((targetName) => {
const expression = spec.sourceExpressions?.[targetName] ?? quote(sourceName(targetName));
return sourceName(targetName) === targetName && !spec.sourceExpressions?.[targetName]
? expression
: `${expression} as ${quote(targetName)}`;
});
const sourceRows = (await source.query<Record<string, unknown>>(`select ${selections.join(',')} from ${quote(spec.name)}`)).rows;
let importedRows = 0; let prunedRows = 0; let reconciledProfileCouples = 0;
const skip = !options.finalSync && options.checkpoint.completedTables.includes(spec.name);
if (!options.dryRun && !skip) {
const client = await target.connect();
@@ -77,7 +139,19 @@ export async function migrateDatabase(source: Pool, target: Pool, options: Datab
// Historical imports must not enqueue user notifications, classification,
// Discord events, or other operational side effects.
await client.query("set local sauci.suppress_operations = 'on'");
importedRows = await upsertRows(client, spec, common, sourceRows);
importedRows = await upsertRows(client, spec, common, sourceRows, targetJsonColumns, options.finalSync);
if (spec.name === 'profiles' && !options.finalSync) {
for (const row of sourceRows) {
if (!row.couple_id) continue;
const reconciled = await client.query(
`update profiles p set couple_id=$2
where p.id=$1 and p.couple_id is distinct from $2
and (select count(*) from profiles member where member.couple_id=p.couple_id)<=1`,
[row.id, row.couple_id],
);
reconciledProfileCouples += reconciled.rowCount ?? 0;
}
}
if (options.finalSync && options.prune) prunedRows = await pruneRows(client, spec, sourceRows);
await client.query('commit');
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
@@ -85,8 +159,23 @@ export async function migrateDatabase(source: Pool, target: Pool, options: Datab
await options.saveCheckpoint?.(options.checkpoint);
}
const targetRows = await count(target, spec.name);
const notes = [skip ? 'resumed from checkpoint' : '', sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : ''].filter(Boolean);
results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: sourceRows.length === targetRows ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) });
const targetKeys = targetColumns.length
? (await target.query<Record<string, unknown>>(`select ${spec.key.map(quote).join(',')} from ${quote(spec.name)}`)).rows
: [];
const keyValue = (row: Record<string, unknown>) => JSON.stringify(spec.key.map((key) => row[key]));
const targetKeySet = new Set(targetKeys.map(keyValue));
const coveredSourceRows = sourceRows.filter((row) => targetKeySet.has(keyValue(row))).length;
const preservedSingleton = Boolean(spec.preserveExistingOnInitial && !options.finalSync && targetRows && !coveredSourceRows);
const parity = options.finalSync ? sourceRows.length === targetRows : preservedSingleton || coveredSourceRows === sourceRows.length;
const preservedTargetRows = Math.max(0, targetRows - coveredSourceRows);
const notes = [
skip ? 'resumed from checkpoint' : '',
preservedSingleton ? 'kept existing live target singleton during initial merge' : '',
reconciledProfileCouples ? `restored ${reconciledProfileCouples} established couple membership(s) over one-person target couples` : '',
!options.finalSync && preservedTargetRows ? `preserved ${preservedTargetRows} target-only row(s)` : '',
sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : '',
].filter(Boolean);
results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: parity ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) });
}
return results;
}
+83 -27
View File
@@ -16,7 +16,9 @@ interface StorageObject {
const BUCKET_KIND: Record<string, 'avatar' | 'response' | 'chat' | 'feedback'> = {
avatars: 'avatar', 'response-media': 'response', 'chat-media': 'chat', 'feedback-screenshots': 'feedback',
};
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// PostgreSQL's uuid type accepts legacy UUID-shaped identifiers whose version
// nibble is not RFC 4122 (the production fixture profile uses version 0).
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
function safeStorageKey(bucket: string, name: string): string {
@@ -50,22 +52,57 @@ export function createSupabaseDownloader(baseUrl: string, serviceRoleKey: string
};
}
async function mediaRelation(target: Pool, object: StorageObject, key: string) {
const first = object.name.split('/')[0];
const owner = object.owner_id && uuidPattern.test(object.owner_id) ? object.owner_id : (uuidPattern.test(first) && object.bucket_id !== 'chat-media' ? first : null);
if (!owner) throw new Error(`Cannot determine owner for ${key}`);
const profile = await target.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [owner]);
if (!profile.rows[0]) throw new Error(`Storage owner does not exist in target for ${key}`);
let matchId: string | null = null; let questionId: string | null = null;
if (object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first;
if (object.bucket_id === 'response-media') {
const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? '';
if (uuidPattern.test(candidate)) questionId = candidate;
const legacyPredicate = (column: string) => `(
${column}=$1 or ${column}=$2 or
(right(${column},length($1))=$1 and position('/'||$3||'/' in ${column})>0)
)`;
async function sourceReference(source: Pool, object: StorageObject) {
const values = [object.name, `${object.bucket_id}/${object.name}`, object.bucket_id];
if (object.bucket_id === 'avatars') {
return (await source.query<{ owner_id: string; match_id: null; question_id: null }>(
`select id owner_id,null::uuid match_id,null::uuid question_id from profiles where ${legacyPredicate('avatar_url')} limit 1`, values,
)).rows[0] ?? null;
}
return { ownerId: owner, coupleId: profile.rows[0].couple_id, matchId, questionId };
if (object.bucket_id === 'chat-media') {
return (await source.query<{ owner_id: string; match_id: string; question_id: null }>(
`select user_id owner_id,match_id,null::uuid question_id from messages where ${legacyPredicate('media_path')} limit 1`, values,
)).rows[0] ?? null;
}
if (object.bucket_id === 'feedback-screenshots') {
return (await source.query<{ owner_id: string; match_id: null; question_id: null }>(
`select user_id owner_id,null::uuid match_id,null::uuid question_id from feedback where ${legacyPredicate('screenshot_url')} limit 1`, values,
)).rows[0] ?? null;
}
return (await source.query<{ owner_id: string; match_id: null; question_id: string }>(
`select user_id owner_id,null::uuid match_id,question_id from responses
where response_data is not null and ${legacyPredicate("response_data->>'media_path'")} limit 1`, values,
)).rows[0] ?? null;
}
async function rewriteLegacyReference(target: Pool, object: StorageObject): Promise<void> {
async function mediaRelation(source: Pool, target: Pool, object: StorageObject) {
const first = object.name.split('/')[0];
const reference = await sourceReference(source, object);
const ownerCandidates = [object.owner_id, reference?.owner_id, object.bucket_id === 'chat-media' ? null : first]
.filter((value): value is string => Boolean(value && uuidPattern.test(value)));
let ownerId: string | null = null; let coupleId: string | null = null;
for (const candidate of ownerCandidates) {
const profile = await target.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [candidate]);
if (profile.rows[0]) { ownerId = candidate; coupleId = profile.rows[0].couple_id; break; }
}
let matchId = reference?.match_id && uuidPattern.test(reference.match_id) ? reference.match_id : null;
if (!matchId && object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first;
if (matchId && !(await target.query('select 1 from matches where id=$1', [matchId])).rowCount) matchId = null;
let questionId = reference?.question_id && uuidPattern.test(reference.question_id) ? reference.question_id : null;
if (object.bucket_id === 'response-media') {
const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? '';
if (!questionId && uuidPattern.test(candidate)) questionId = candidate;
}
if (questionId && !(await target.query('select 1 from questions where id=$1', [questionId])).rowCount) questionId = null;
return { ownerId, coupleId, matchId, questionId };
}
async function rewriteLegacyReference(source: Pool, target: Pool, object: StorageObject): Promise<void> {
const reference = `media:${object.id}`;
const legacyName = object.name;
const legacyKey = `${object.bucket_id}/${legacyName}`;
@@ -78,7 +115,11 @@ async function rewriteLegacyReference(target: Pool, object: StorageObject): Prom
} else if (object.bucket_id === 'chat-media') {
await target.query(`update messages set media_path=$1 where ${matchesLegacy('media_path')}`, [reference, legacyName, legacyKey]);
} else if (object.bucket_id === 'feedback-screenshots') {
await target.query(`update feedback set screenshot_url=$1 where ${matchesLegacy('screenshot_url')}`, [reference, legacyName, legacyKey]);
const sourceRows = await source.query<{ id: string }>(
`select id from feedback where ${legacyPredicate('screenshot_url')}`,
[legacyName, legacyKey, object.bucket_id],
);
if (sourceRows.rows.length) await target.query('update feedback set screenshot_media_id=$1 where id=any($2::uuid[])', [object.id, sourceRows.rows.map((row) => row.id)]);
} else if (object.bucket_id === 'response-media') {
await target.query(`
update responses
@@ -92,7 +133,7 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
const schema = options.storageSchema ?? 'storage';
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error('Invalid storage schema');
const objects = await source.query<StorageObject>(`select id,bucket_id,name,owner_id,metadata,created_at,updated_at from ${quote(schema)}.objects where bucket_id=any($1::text[]) order by bucket_id,name`, [Object.keys(BUCKET_KIND)]);
const result: StorageResult = { sourceFiles: objects.rowCount ?? objects.rows.length, targetFiles: 0, copiedFiles: 0, skippedFiles: 0, prunedFiles: 0, failedFiles: 0, sourceBytes: 0, copiedBytes: 0, missingOnDisk: [], failures: [] };
const result: StorageResult = { sourceFiles: objects.rowCount ?? objects.rows.length, targetFiles: 0, preservedTargetFiles: 0, copiedFiles: 0, quarantinedFiles: 0, skippedFiles: 0, prunedFiles: 0, failedFiles: 0, sourceBytes: 0, copiedBytes: 0, missingOnDisk: [], failures: [] };
const sourceKeys = new Set<string>();
for (const object of objects.rows) {
const key = safeStorageKey(object.bucket_id, object.name); const expectedSize = metadataSize(object.metadata); result.sourceBytes += expectedSize;
@@ -101,10 +142,11 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
const marker = options.checkpoint.storage[key];
if (marker && marker.updatedAt === updatedAt && (!expectedSize || marker.byteSize === expectedSize)) {
if (options.dryRun) { result.skippedFiles += 1; continue; }
const registered = await target.query('select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3', [object.id, key, marker.byteSize]);
const registered = await target.query(`select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3
union all select 1 from legacy_media_quarantine where id=$1 and original_storage_key=$2 and byte_size=$3`, [object.id, key, marker.byteSize]);
try {
if (registered.rowCount && (await stat(destination(options.mediaRoot, key))).size === marker.byteSize) {
await rewriteLegacyReference(target, object);
await rewriteLegacyReference(source, target, object);
result.skippedFiles += 1;
continue;
}
@@ -112,16 +154,25 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
}
if (options.dryRun) continue;
try {
const relation = await mediaRelation(source, target, object);
const bytes = await options.download(object.bucket_id, object.name);
if (expectedSize && bytes.byteLength !== expectedSize) throw new Error(`Size mismatch for ${key}: expected ${expectedSize}, received ${bytes.byteLength}`);
const relation = await mediaRelation(target, object, key);
const path = destination(options.mediaRoot, key); await mkdir(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.tmp`; await writeFile(temporary, bytes, { mode: 0o600 }); await rename(temporary, path);
const mime = metadataString(object.metadata, 'mimetype', 'contentType') ?? 'application/octet-stream';
await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`,
[object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]);
await rewriteLegacyReference(target, object);
if (relation.ownerId) {
await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`,
[object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]);
await target.query('delete from legacy_media_quarantine where id=$1', [object.id]);
await rewriteLegacyReference(source, target, object);
} else {
await target.query(`insert into legacy_media_quarantine(id,original_storage_key,bucket_id,object_name,source_owner_id,metadata,mime_type,byte_size,reason,source_created_at,source_updated_at)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) on conflict(id) do update set original_storage_key=excluded.original_storage_key,bucket_id=excluded.bucket_id,object_name=excluded.object_name,source_owner_id=excluded.source_owner_id,metadata=excluded.metadata,mime_type=excluded.mime_type,byte_size=excluded.byte_size,reason=excluded.reason,source_created_at=excluded.source_created_at,source_updated_at=excluded.source_updated_at`,
[object.id, key, object.bucket_id, object.name, object.owner_id, object.metadata, mime, bytes.byteLength, 'legacy object has no verifiable owner or live reference', object.created_at, object.updated_at]);
await target.query('delete from media_objects where id=$1', [object.id]);
result.quarantinedFiles += 1;
}
options.checkpoint.storage[key] = { updatedAt, byteSize: bytes.byteLength };
await options.saveCheckpoint?.(options.checkpoint); result.copiedFiles += 1; result.copiedBytes += bytes.byteLength;
} catch (error) {
@@ -131,16 +182,21 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
}
if (!options.dryRun) {
if (options.prune) {
const existing = await target.query<{ id: string; storage_key: string }>("select id,storage_key from media_objects where kind=any($1::text[])", [Object.values(BUCKET_KIND)]);
const existing = await target.query<{ id: string; storage_key: string; quarantined: boolean }>(`
select id,storage_key,false quarantined from media_objects where kind=any($1::text[])
union all select id,original_storage_key storage_key,true quarantined from legacy_media_quarantine
`, [Object.values(BUCKET_KIND)]);
for (const row of existing.rows) if (!sourceKeys.has(row.storage_key)) {
await rm(destination(options.mediaRoot, row.storage_key), { force: true });
await target.query('delete from media_objects where id=$1', [row.id]);
await target.query(row.quarantined ? 'delete from legacy_media_quarantine where id=$1' : 'delete from media_objects where id=$1', [row.id]);
delete options.checkpoint.storage[row.storage_key]; result.prunedFiles += 1;
}
await options.saveCheckpoint?.(options.checkpoint);
}
const registered = await target.query<{ storage_key: string; byte_size: number }>("select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[])", [Object.values(BUCKET_KIND)]);
result.targetFiles = registered.rows.length;
const registered = await target.query<{ storage_key: string; byte_size: number }>(`select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[])
union all select original_storage_key storage_key,byte_size from legacy_media_quarantine`, [Object.values(BUCKET_KIND)]);
result.targetFiles = registered.rows.filter((row) => sourceKeys.has(row.storage_key)).length;
result.preservedTargetFiles = registered.rows.length - result.targetFiles;
for (const row of registered.rows) {
try { if ((await stat(destination(options.mediaRoot, row.storage_key))).size !== row.byte_size) result.missingOnDisk.push(row.storage_key); }
catch { result.missingOnDisk.push(row.storage_key); }
+2
View File
@@ -22,7 +22,9 @@ export interface TableResult {
export interface StorageResult {
sourceFiles: number;
targetFiles: number;
preservedTargetFiles: number;
copiedFiles: number;
quarantinedFiles: number;
skippedFiles: number;
prunedFiles: number;
failedFiles: number;