mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
78900a24fb
commit
aedba2a480
8 files changed
+795
-85
No files matched your search
@@ -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),
|
||||
]);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user