From aedba2a480bd5c819899def35e65eb55b5b06721 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 00:04:11 +0100 Subject: [PATCH] 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. --- .../drizzle/0018_cutover_source_parity.sql | 80 +++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/domains/packs/schema.ts | 26 + apps/api/src/migration/database.ts | 127 ++++- apps/api/src/migration/storage.ts | 110 +++- apps/api/src/migration/types.ts | 2 + apps/api/test/migration.integration.test.ts | 508 ++++++++++++++++-- docs/operations/backend-cutover.md | 20 +- 8 files changed, 795 insertions(+), 85 deletions(-) create mode 100644 apps/api/drizzle/0018_cutover_source_parity.sql diff --git a/apps/api/drizzle/0018_cutover_source_parity.sql b/apps/api/drizzle/0018_cutover_source_parity.sql new file mode 100644 index 0000000..8675844 --- /dev/null +++ b/apps/api/drizzle/0018_cutover_source_parity.sql @@ -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() +); diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index cf3238f..57d97a9 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -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 } ] } diff --git a/apps/api/src/domains/packs/schema.ts b/apps/api/src/domains/packs/schema.ts index 08eab1d..f8a7718 100644 --- a/apps/api/src/domains/packs/schema.ts +++ b/apps/api/src/domains/packs/schema.ts @@ -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), +]); diff --git a/apps/api/src/migration/database.ts b/apps/api/src/migration/database.ts index 9129adf..ab3a056 100644 --- a/apps/api/src/migration/database.ts +++ b/apps/api/src/migration/database.ts @@ -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; + /** Target-column SQL expressions for legacy rows that need a derived value. */ + sourceExpressions?: Record; + /** 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(`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> { + 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 { 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[]): Promise { - if (spec.replace) await client.query(`delete from ${quote(spec.name)}`); +async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record[], jsonNames: Set, finalSync: boolean): Promise { + 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 { 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>(`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>(`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>(`select ${spec.key.map(quote).join(',')} from ${quote(spec.name)}`)).rows + : []; + const keyValue = (row: Record) => 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; } diff --git a/apps/api/src/migration/storage.ts b/apps/api/src/migration/storage.ts index 8ec435a..a8141b8 100644 --- a/apps/api/src/migration/storage.ts +++ b/apps/api/src/migration/storage.ts @@ -16,7 +16,9 @@ interface StorageObject { const BUCKET_KIND: Record = { 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 { +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 { 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(`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(); 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); } diff --git a/apps/api/src/migration/types.ts b/apps/api/src/migration/types.ts index 2d9b0ad..14c039b 100644 --- a/apps/api/src/migration/types.ts +++ b/apps/api/src/migration/types.ts @@ -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; diff --git a/apps/api/test/migration.integration.test.ts b/apps/api/test/migration.integration.test.ts index ab99120..7cc71dc 100644 --- a/apps/api/test/migration.integration.test.ts +++ b/apps/api/test/migration.integration.test.ts @@ -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});}); }); diff --git a/docs/operations/backend-cutover.md b/docs/operations/backend-cutover.md index edcda0a..2952eda 100644 --- a/docs/operations/backend-cutover.md +++ b/docs/operations/backend-cutover.md @@ -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:` 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.