Merge pull request #7 from jjbrunton/task/production-cutover-import

feat(api): harden production data import
This commit is contained in:
jjbrunton authored and GitHub committed 2026-08-29 00:08:15 +01:00
commit e6bb368bc8
8 files changed
+795 -85

No files matched your search

@@ -0,0 +1,80 @@
-- Preserve the reviewed production catalogue and its append-only decision history
-- when importing from the legacy Supabase data plane.
DO $$
BEGIN
IF to_regtype('content_entity_type') IS NULL THEN
CREATE TYPE content_entity_type AS ENUM (
'categories', 'question_packs', 'questions', 'dare_packs', 'dares'
);
END IF;
END $$;
--> statement-breakpoint
ALTER TABLE categories
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
ADD COLUMN IF NOT EXISTS content_review_reason text,
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS categories_content_status_idx
ON categories (content_status, is_public, sort_order);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS question_packs_content_status_idx
ON question_packs (content_status, is_public, sort_order);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS questions_content_status_idx
ON questions (pack_id, content_status, intensity);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS content_reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type content_entity_type NOT NULL,
entity_id uuid NOT NULL,
previous_status content_review_status NOT NULL,
new_status content_review_status NOT NULL,
reason text NOT NULL,
changed_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS content_reviews_entity_idx
ON content_reviews (entity_type, entity_id, created_at DESC);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS content_reviews_created_at_idx
ON content_reviews (created_at DESC);
--> statement-breakpoint
-- The standalone audit model separates the acting profile from the admin row.
-- Retain legacy descriptive fields rather than dropping production history.
ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS changed_fields text[],
ADD COLUMN IF NOT EXISTS admin_role text,
ADD COLUMN IF NOT EXISTS admin_username text;
--> statement-breakpoint
-- A small number of legacy Supabase objects have neither an owner nor a live
-- database reference. Preserve their bytes and metadata without making them
-- addressable through the authenticated media API or assigning false ownership.
CREATE TABLE IF NOT EXISTS legacy_media_quarantine (
id uuid PRIMARY KEY,
original_storage_key text NOT NULL UNIQUE,
bucket_id text NOT NULL,
object_name text NOT NULL,
source_owner_id uuid,
metadata jsonb,
mime_type text NOT NULL,
byte_size integer NOT NULL CHECK (byte_size > 0),
reason text NOT NULL,
source_created_at timestamptz NOT NULL,
source_updated_at timestamptz,
imported_at timestamptz NOT NULL DEFAULT now()
);
+7
View File
@@ -127,6 +127,13 @@
"when": 1788081420000,
"tag": "0017_streak_reminder_preference",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1788081480000,
"tag": "0018_cutover_source_parity",
"breakpoints": true
}
]
}
+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;
+473 -35
View File
@@ -1,38 +1,476 @@
import { randomUUID } from 'node:crypto';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { migrateDatabase, relationalParity } from '../src/migration/database.js';
import { migrateStorage, fileDigest } from '../src/migration/storage.js';
import type { MigrationCheckpoint } from '../src/migration/types.js';
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Pool } from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
migrateDatabase,
relationalParity,
} from "../src/migration/database.js";
import { migrateStorage, fileDigest } from "../src/migration/storage.js";
import type { MigrationCheckpoint } from "../src/migration/types.js";
const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false;if(databaseUrl&&!local)throw new Error('Migration integration tests only permit localhost');
describe.skipIf(!databaseUrl||!local)('cutover migration fixtures',()=>{
const admin=new Pool({connectionString:databaseUrl});const suffix=randomUUID().replaceAll('-','');const sourceSchema=`migration_source_${suffix}`;const targetSchema=`migration_target_${suffix}`;const storageSchema=`migration_storage_${suffix}`;let source:Pool;let target:Pool;let mediaRoot:string;
const couple='11111111-1111-4111-8111-111111111111',user='22222222-2222-4222-8222-222222222222',pack='33333333-3333-4333-8333-333333333333',question='44444444-4444-4444-8444-444444444444',response='55555555-5555-4555-8555-555555555555',object='66666666-6666-4666-8666-666666666666';
const checkpoint=():MigrationCheckpoint=>({version:1,runId:randomUUID(),mode:'initial',completedTables:[],storage:{},updatedAt:new Date().toISOString()});
beforeAll(async()=>{
await admin.query(`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`);
const sourceUrl=new URL(databaseUrl!);sourceUrl.searchParams.set('options',`-c search_path=${sourceSchema}`);source=new Pool({connectionString:sourceUrl.toString()});
const targetUrl=new URL(databaseUrl!);targetUrl.searchParams.set('options',`-c search_path=${targetSchema}`);target=new Pool({connectionString:targetUrl.toString()});
for(let index=0;index<=12;index++){const files=(await import('node:fs/promises')).readdir(new URL('../drizzle',import.meta.url));const name=(await files).find(file=>file.startsWith(`${String(index).padStart(4,'0')}_`));if(!name)continue;const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await target.query(statement);}
await source.query(`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);`);
await source.query(`insert into couples values($1,'CUTOVER1','2024-01-01T00:00:00Z')`,[couple]);
await source.query(`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,[user,couple]);
await source.query('alter table profiles add column avatar_url text');
await source.query('update profiles set avatar_url=$2 where id=$1',[user,`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`]);
await source.query(`insert into question_packs values($1,'Core',true,false,3.36,'2024-01-04T00:00:00Z')`,[pack]);
await source.query(`insert into questions values($1,$2,'Question',2,array['blindfold'],'2024-01-05T00:00:00Z')`,[question,pack]);
await source.query(`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,[response,user,question,couple]);
await admin.query(`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`);
await admin.query(`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,[object,`${user}/avatar.png`,user,{size:4,mimetype:'image/png'}]);
mediaRoot=await mkdtemp(join(tmpdir(),'sauci-migration-'));
const databaseUrl = process.env.DATABASE_URL;
const local = databaseUrl
? ["127.0.0.1", "localhost", "::1"].includes(new URL(databaseUrl).hostname)
: false;
if (databaseUrl && !local)
throw new Error("Migration integration tests only permit localhost");
describe.skipIf(!databaseUrl || !local)("cutover migration fixtures", () => {
const admin = new Pool({ connectionString: databaseUrl });
const suffix = randomUUID().replaceAll("-", "");
const sourceSchema = `migration_source_${suffix}`;
const targetSchema = `migration_target_${suffix}`;
const storageSchema = `migration_storage_${suffix}`;
let source: Pool;
let target: Pool;
let mediaRoot: string;
const couple = "11111111-1111-4111-8111-111111111111",
user = "22222222-2222-4222-8222-222222222222",
pack = "33333333-3333-4333-8333-333333333333",
question = "44444444-4444-4444-8444-444444444444",
inverseQuestion = "45454545-4545-4545-8545-454545454545",
response = "55555555-5555-4555-8555-555555555555",
object = "66666666-6666-4666-8666-666666666666",
adminRow = "77777777-7777-4777-8777-777777777777",
category = "88888888-8888-4888-8888-888888888888",
review = "99999999-9999-4999-8999-999999999999",
audit = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
legacyUser = "a0000000-0000-0000-0000-000000000001",
sourceAppConfig = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const checkpoint = (): MigrationCheckpoint => ({
version: 1,
runId: randomUUID(),
mode: "initial",
completedTables: [],
storage: {},
updatedAt: new Date().toISOString(),
});
beforeAll(async () => {
await admin.query(
`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`,
);
const sourceUrl = new URL(databaseUrl!);
sourceUrl.searchParams.set("options", `-c search_path=${sourceSchema}`);
source = new Pool({ connectionString: sourceUrl.toString() });
const targetUrl = new URL(databaseUrl!);
targetUrl.searchParams.set("options", `-c search_path=${targetSchema}`);
target = new Pool({ connectionString: targetUrl.toString() });
for (let index = 0; index <= 18; index++) {
const files = (await import("node:fs/promises")).readdir(
new URL("../drizzle", import.meta.url),
);
const name = (await files).find((file) =>
file.startsWith(`${String(index).padStart(4, "0")}_`),
);
if (!name) continue;
const sql = await readFile(
new URL(`../drizzle/${name}`, import.meta.url),
"utf8",
);
for (const statement of sql.split("--> statement-breakpoint"))
if (statement.trim()) await target.query(statement);
}
await source.query(
`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table admin_users(id uuid primary key,user_id uuid,role text,created_at timestamptz,created_by uuid,updated_at timestamptz,permissions jsonb);create table categories(id uuid primary key,name text,is_public boolean,sort_order int,content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],inverse_of uuid,content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table content_reviews(id uuid primary key,entity_type text,entity_id uuid,previous_status text,new_status text,reason text,changed_by uuid,created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);create table feature_interests(id uuid primary key,user_id uuid,feature_name text,created_at timestamptz);create table live_draw_sessions(id uuid primary key,couple_id uuid,strokes jsonb,updated_at timestamptz,created_at timestamptz);create table audit_logs(id uuid primary key,table_name text,record_id uuid,action text,old_values jsonb,new_values jsonb,changed_fields text[],admin_user_id uuid,admin_role text,created_at timestamptz,admin_username text);create table app_config(id uuid primary key,answer_gap_threshold int,daily_response_limit int,updated_at timestamptz);`,
);
await source.query(
`insert into couples values($1,'e5d3012e','2024-01-01T00:00:00Z')`,
[couple],
);
await source.query(
`insert into app_config values($1,99,99,'2024-01-01T00:00:00Z')`,
[sourceAppConfig],
);
await source.query(
`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,
[user, couple],
);
await source.query(
`insert into admin_users values($1,$2,'super_admin',now(),null,now(),$3::jsonb)`,
[adminRow, user, JSON.stringify(["manage_packs"])],
);
await source.query("alter table profiles add column avatar_url text");
await source.query("update profiles set avatar_url=$2 where id=$1", [
user,
`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`,
]);
await source.query(
`insert into categories values($1,'Core',true,1,'allowed','Reviewed',null,null,'2024-01-03T00:00:00Z')`,
[category],
);
await source.query(
`insert into question_packs values($1,'Core',true,false,3.36,'allowed','Reviewed',null,null,'2024-01-04T00:00:00Z')`,
[pack],
);
await source.query(
`insert into questions values($1,$2,'Question',2,array['blindfold'],$3,'allowed','Reviewed',null,null,'2024-01-05T00:00:00Z'),($3,$2,'Inverse',2,array[]::text[],null,'allowed','Reviewed',null,null,'2024-01-05T00:00:01Z')`,
[question, pack, inverseQuestion],
);
await source.query(
`insert into content_reviews values($1,'categories',$2,'unreviewed','allowed','Reviewed',null,'2024-01-03T00:00:00Z')`,
[review, category],
);
await source.query(
`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,
[response, user, question, couple],
);
await source.query(
`insert into feature_interests values(gen_random_uuid(),$1,'daily_dares','2024-01-07T00:00:00Z')`,
[user],
);
await source.query(
`insert into live_draw_sessions values(gen_random_uuid(),$1,'[]'::jsonb,'2024-01-08T00:00:00Z','2024-01-08T00:00:00Z')`,
[couple],
);
await source.query(
`insert into audit_logs values($1,'categories',$2,'UPDATE',null,$3::jsonb,array['content_status'],$4,'super_admin','2024-01-09T00:00:00Z','alice')`,
[audit, category, JSON.stringify({ content_status: "allowed" }), user],
);
await admin.query(
`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`,
);
await admin.query(
`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,
[object, `${user}/avatar.png`, user, { size: 4, mimetype: "image/png" }],
);
mediaRoot = await mkdtemp(join(tmpdir(), "sauci-migration-"));
});
afterAll(async () => {
await Promise.all([source?.end(), target?.end()]);
await admin.query(
`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`,
);
await admin.end();
if (mediaRoot) await rm(mediaRoot, { recursive: true, force: true });
});
it("preserves IDs, schema mappings, catalogue reviews, timestamps, side-effect suppression, and FK parity", async () => {
const state = checkpoint();
const result = await migrateDatabase(source, target, {
dryRun: false,
finalSync: false,
prune: false,
checkpoint: state,
});
expect(result.find((row) => row.table === "responses")).toMatchObject({
sourceRows: 1,
targetRows: 1,
status: "equal",
});
expect(result.find((row) => row.table === "app_config")).toMatchObject({
sourceRows: 1,
targetRows: 1,
importedRows: 0,
status: "equal",
});
expect(
(await target.query("select id from app_config")).rows[0].id,
).not.toBe(sourceAppConfig);
const row = await target.query("select id,created_at from responses");
expect(row.rows[0].id).toBe(response);
expect(new Date(row.rows[0].created_at).toISOString()).toBe(
"2024-01-06T00:00:00.000Z",
);
expect(
(
await target.query("select invite_code from couples where id=$1", [
couple,
])
).rows[0].invite_code,
).toBe("E5D3012E");
expect(
(
await target.query(
"select avg_intensity,content_status from question_packs where id=$1",
[pack],
)
).rows[0],
).toMatchObject({ avg_intensity: "3.36", content_status: "allowed" });
expect(
(
await target.query(
"select required_props,inverse_of,content_status from questions where id=$1",
[question],
)
).rows[0],
).toMatchObject({
required_props: ["blindfold"],
inverse_of: inverseQuestion,
content_status: "allowed",
});
expect(
(
await target.query(
"select feature from feature_interests where user_id=$1",
[user],
)
).rows[0].feature,
).toBe("daily_dares");
expect(
(
await target.query(
"select updated_by from live_draw_sessions where couple_id=$1",
[couple],
)
).rows[0].updated_by,
).toBe(user);
expect(
(
await target.query(
"select actor_user_id,admin_user_id,changed_fields,admin_role,admin_username from audit_logs where id=$1",
[audit],
)
).rows[0],
).toMatchObject({
actor_user_id: user,
admin_user_id: adminRow,
changed_fields: ["content_status"],
admin_role: "super_admin",
admin_username: "alice",
});
expect(
(
await target.query(
"select count(*)::int count from content_reviews where id=$1",
[review],
)
).rows[0].count,
).toBe(1);
expect(
(await target.query("select count(*)::int count from operations_outbox"))
.rows[0].count,
).toBe(0);
expect(await relationalParity(target)).toEqual({
profiles_without_couple: 0,
responses_with_missing_owner: 0,
matches_with_missing_relation: 0,
messages_with_missing_relation: 0,
couple_mismatch_responses: 0,
});
});
it("keeps a newer live profile during an initial merge", async () => {
const liveCouple = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
await target.query(
"insert into couples(id,invite_code,created_at) values($1,'LIVE0001',now())",
[liveCouple],
);
await target.query(
"update profiles set name='Live Alice',couple_id=$2,updated_at='2026-08-28T00:00:00Z' where id=$1",
[user, liveCouple],
);
await migrateDatabase(source, target, {
dryRun: false,
finalSync: false,
prune: false,
checkpoint: checkpoint(),
});
expect(
(
await target.query(
"select name,couple_id,updated_at from profiles where id=$1",
[user],
)
).rows[0],
).toMatchObject({ name: "Live Alice", couple_id: couple });
});
it("dry-run reads and reports without changing the target", async () => {
await source.query(
"update profiles set name='Changed at source' where id=$1",
[user],
);
await migrateDatabase(source, target, {
dryRun: true,
finalSync: false,
prune: false,
checkpoint: checkpoint(),
});
expect(
(await target.query("select name from profiles where id=$1", [user]))
.rows[0].name,
).toBe("Live Alice");
await source.query("update profiles set name='Alice' where id=$1", [user]);
});
it("is resumable and a stopped-source prune restores exact row parity", async () => {
const state = checkpoint();
state.completedTables = ["couples"];
await target.query(
`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`,
);
const resumed = await migrateDatabase(source, target, {
dryRun: false,
finalSync: false,
prune: false,
checkpoint: state,
});
expect(resumed.find((row) => row.table === "couples")?.note).toContain(
"resumed from checkpoint",
);
const final = await migrateDatabase(source, target, {
dryRun: false,
finalSync: true,
prune: true,
checkpoint: checkpoint(),
});
expect(final.find((row) => row.table === "couples")).toMatchObject({
sourceRows: 1,
targetRows: 1,
prunedRows: 2,
status: "equal",
});
});
it("copies storage atomically, quarantines unattributable legacy objects, rewrites references, verifies checkpoints, and prunes source-deleted files", async () => {
const legacyAvatar = randomUUID(),
orphanChat = randomUUID(),
unmatchedChat = randomUUID(),
feedbackObject = randomUUID(),
feedbackId = randomUUID();
await source.query(
"insert into profiles values($1,$2,$3,$4,now(),now(),null)",
[legacyUser, "Legacy", "legacy@test", couple],
);
await target.query(
"insert into profiles(id,name,email,couple_id,created_at,updated_at) values($1,$2,$3,$4,now(),now())",
[legacyUser, "Legacy", "legacy@test", couple],
);
await source.query(
"create table messages(user_id uuid,match_id uuid,media_path text);create table feedback(id uuid primary key,user_id uuid,screenshot_url text)",
);
await source.query("insert into feedback values($1,$2,$3)", [
feedbackId,
user,
`https://source.test/storage/v1/object/public/feedback-screenshots/${user}/shot.jpg`,
]);
await target.query(
"insert into feedback(id,user_id,type,title,description) values($1,$2,'bug','Screenshot','Details')",
[feedbackId, user],
);
await admin.query(
`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now()),($5,'chat-media',$6,null,$4,now(),now()),($7,'chat-media',$8,$9,$4,now(),now()),($10,'feedback-screenshots',$11,$9,$4,now(),now())`,
[
legacyAvatar,
`${legacyUser}/legacy.jpg`,
legacyUser,
{ size: 4, mimetype: "image/jpeg" },
orphanChat,
`${randomUUID()}/orphan.jpg`,
unmatchedChat,
`${randomUUID()}/chat.jpg`,
user,
feedbackObject,
`${user}/shot.jpg`,
],
);
const state = checkpoint();
const first = await migrateStorage(source, target, {
dryRun: false,
mediaRoot,
storageSchema,
checkpoint: state,
download: async () => new Uint8Array([1, 2, 3, 4]),
});
expect(first.failures).toEqual([]);
expect(first).toMatchObject({
sourceFiles: 5,
targetFiles: 5,
copiedFiles: 5,
quarantinedFiles: 1,
failedFiles: 0,
missingOnDisk: [],
});
const path = join(mediaRoot, "avatars", user, "avatar.png");
expect(await fileDigest(path)).toBe(
"9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a",
);
expect(
(
await target.query(
"select owner_id,kind,byte_size from media_objects where id=$1",
[object],
)
).rows[0],
).toMatchObject({ owner_id: user, kind: "avatar", byte_size: 4 });
expect(
(
await target.query("select owner_id from media_objects where id=$1", [
legacyAvatar,
])
).rows[0].owner_id,
).toBe(legacyUser);
expect(
(
await target.query("select match_id from media_objects where id=$1", [
unmatchedChat,
])
).rows[0].match_id,
).toBeNull();
expect(
(
await target.query(
"select reason from legacy_media_quarantine where id=$1",
[orphanChat],
)
).rowCount,
).toBe(1);
expect(
(
await target.query(
"select screenshot_media_id from feedback where id=$1",
[feedbackId],
)
).rows[0].screenshot_media_id,
).toBe(feedbackObject);
expect(
(
await target.query("select avatar_url from profiles where id=$1", [
user,
])
).rows[0].avatar_url,
).toBe(`media:${object}`);
const resumed = await migrateStorage(source, target, {
dryRun: false,
mediaRoot,
storageSchema,
checkpoint: state,
download: async () => {
throw new Error("must not redownload");
},
});
expect(resumed).toMatchObject({
copiedFiles: 0,
skippedFiles: 5,
failedFiles: 0,
});
const stale = "77777777-7777-4777-8777-777777777777";
const staleKey = `avatars/${user}/stale.png`;
await target.query(
"insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",
[stale, user, couple, staleKey],
);
await writeFile(join(mediaRoot, staleKey), new Uint8Array([9]));
await admin.query(
`delete from "${storageSchema}".objects where id=$1`,
[orphanChat],
);
const pruned = await migrateStorage(source, target, {
dryRun: false,
prune: true,
mediaRoot,
storageSchema,
checkpoint: state,
download: async () => {
throw new Error("must not redownload");
},
});
expect(pruned).toMatchObject({
sourceFiles: 4,
targetFiles: 4,
prunedFiles: 2,
skippedFiles: 4,
failedFiles: 0,
});
expect(
(await target.query("select 1 from legacy_media_quarantine where id=$1", [orphanChat])).rowCount,
).toBe(0);
});
afterAll(async()=>{await Promise.all([source?.end(),target?.end()]);await admin.query(`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`);await admin.end();if(mediaRoot)await rm(mediaRoot,{recursive:true,force:true});});
it('preserves IDs, precision, catalogue fields, timestamps, side-effect suppression, and FK parity',async()=>{const state=checkpoint();const result=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(result.find(row=>row.table==='responses')).toMatchObject({sourceRows:1,targetRows:1,status:'equal'});const row=await target.query('select id,created_at from responses');expect(row.rows[0].id).toBe(response);expect(new Date(row.rows[0].created_at).toISOString()).toBe('2024-01-06T00:00:00.000Z');expect((await target.query('select avg_intensity from question_packs where id=$1',[pack])).rows[0].avg_intensity).toBe('3.36');expect((await target.query('select required_props from questions where id=$1',[question])).rows[0].required_props).toEqual(['blindfold']);expect((await target.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(0);expect(await relationalParity(target)).toEqual({profiles_without_couple:0,responses_with_missing_owner:0,matches_with_missing_relation:0,messages_with_missing_relation:0,couple_mismatch_responses:0});});
it('dry-run reads and reports without changing the target',async()=>{await source.query("update profiles set name='Changed at source' where id=$1",[user]);await migrateDatabase(source,target,{dryRun:true,finalSync:false,prune:false,checkpoint:checkpoint()});expect((await target.query('select name from profiles where id=$1',[user])).rows[0].name).toBe('Alice');await source.query("update profiles set name='Alice' where id=$1",[user]);});
it('is resumable and a stopped-source prune restores exact row parity',async()=>{const state=checkpoint();state.completedTables=['couples'];await target.query(`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`);const resumed=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(resumed.find(row=>row.table==='couples')?.note).toBe('resumed from checkpoint');const final=await migrateDatabase(source,target,{dryRun:false,finalSync:true,prune:true,checkpoint:checkpoint()});expect(final.find(row=>row.table==='couples')).toMatchObject({sourceRows:1,targetRows:1,prunedRows:1,status:'equal'});});
it('copies storage atomically, rewrites legacy references, verifies checkpoints, and prunes source-deleted files',async()=>{const state=checkpoint();const first=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>new Uint8Array([1,2,3,4])});expect(first).toMatchObject({sourceFiles:1,targetFiles:1,copiedFiles:1,failedFiles:0,missingOnDisk:[]});const path=join(mediaRoot,'avatars',user,'avatar.png');expect(await fileDigest(path)).toBe('9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a');expect((await target.query('select owner_id,kind,byte_size from media_objects where id=$1',[object])).rows[0]).toMatchObject({owner_id:user,kind:'avatar',byte_size:4});expect((await target.query('select avatar_url from profiles where id=$1',[user])).rows[0].avatar_url).toBe(`media:${object}`);const resumed=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(resumed).toMatchObject({copiedFiles:0,skippedFiles:1,failedFiles:0});const stale='77777777-7777-4777-8777-777777777777';const staleKey=`avatars/${user}/stale.png`;await target.query("insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",[stale,user,couple,staleKey]);await writeFile(join(mediaRoot,staleKey),new Uint8Array([9]));const pruned=await migrateStorage(source,target,{dryRun:false,prune:true,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(pruned).toMatchObject({sourceFiles:1,targetFiles:1,prunedFiles:1,skippedFiles:1,failedFiles:0});});
});
+16 -4
View File
@@ -27,6 +27,10 @@ order, preserves common IDs/timestamps, maps legacy-to-target schema differences
by explicit target-column intersection, and upserts on stable primary/business
keys. An initial interrupted run resumes from its atomic JSON checkpoint. A
`--final-sync` always replays every table, ignoring initial-run table checkpoints.
Initial merges preserve target-only rows, the live target singleton configuration,
and newer target profile fields. When an established source couple conflicts with
a one-person target couple, only the couple membership is restored so historical
relationship data stays isolated while newer profile settings remain intact.
The manifest includes the complete supported legacy product and administrative
set: `couples`, `profiles`, `admin_users`, `master_keys`, `categories`,
`question_packs`, `topics`, `pack_topics`, `questions`, `app_config`,
@@ -35,8 +39,14 @@ set: `couples`, `profiles`, `admin_users`, `master_keys`, `categories`,
`match_archives`, `message_deletions`, `message_reports`,
`notification_preferences`, `feedback`, `subscriptions`,
`revenuecat_webhook_events`, `redemption_codes`, `code_redemptions`,
`feature_interests`, `live_draw_sessions`, `catchup_reminder_tracking`, and
`audit_logs`. A missing source or target table is reported as skipped rather
`feature_interests`, `live_draw_sessions`, `catchup_reminder_tracking`,
`content_reviews`, and `audit_logs`. The importer maps the legacy
`feature_interests.feature_name` column to `feature`, canonicalizes legacy
invite codes to the uppercase format accepted by the standalone API, derives
the standalone live-draw updater from the couple's profiles, imports question
inverse links in a second pass, and separates the legacy audit actor identity
into the standalone actor/admin fields. A missing source or target table is
reported as skipped rather
than silently treated as copied.
Supported storage buckets are `avatars`, `response-media`, `chat-media`, and
@@ -44,12 +54,14 @@ Supported storage buckets are `avatars`, `response-media`, `chat-media`, and
through temporary files and atomic renames. Unsafe paths are rejected. Imported
objects are registered in `media_objects`; object ID, owner, creation time, MIME
type, and byte size are retained. The checkpoint uses object update time and size
so retries do not redownload completed objects.
so retries do not redownload completed objects. Ownerless, unreferenced legacy
objects are retained in `legacy_media_quarantine` and on disk, but are deliberately
not registered with the authenticated media API.
After each copied **or checkpoint-resumed** object, the importer rewrites legacy
object names, bucket paths, and Supabase public/signed URLs to the canonical
`media:<uuid>` reference. This covers `profiles.avatar_url`,
`messages.media_path`, `feedback.screenshot_url`, and
`messages.media_path`, `feedback.screenshot_media_id`, and
`responses.response_data.media_path`. Treat any remaining legacy storage URL in
those fields as a failed migration even when file counts match.