fix: complete production data migration rehearsal
Staging Backend Quality Gate / validate (push) Failing after 49m36s
Staging Backend Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-09 14:17:01 +08:00
parent d36ca7aac2
commit ffbe505c23
3 changed files with 123 additions and 31 deletions
@@ -117,6 +117,7 @@ The operator supplies these values only on the trusted migration host; do not st
- `SUPABASE_SOURCE_DATABASE_URL`: the consistent read-only Supabase snapshot/source URL;
- `PRODUCTION_TARGET_DATABASE_URL`: the PostgreSQL 17 target URL using the migration role;
- `PRODUCTION_OWNER_USER_ID`: the UUID of the designated active source administrator;
- `PRODUCTION_OWNER_EMAIL`: the canonical email that must match that UUID in source `auth.users`;
- `PRODUCTION_CIPHERTEXT_MODE=preserve|exclude`; preserve additionally requires `PRODUCTION_CIPHERTEXT_KEYS_CONFIRMED=true`.
Run each phase separately and retain its redacted JSON manifest:
@@ -136,7 +137,9 @@ Because migrated users have no portable password/session, all sessions are inval
## Rehearsal
Complete at least one isolated full-data rehearsal before scheduling the final window:
On 2026-08-09, an isolated PostgreSQL 17 rehearsal completed `--preflight`, `--apply`, and `--verify` against a read-only production Supabase transaction. It reconciled 85 identities and all 18 selected non-empty/seeded legacy public tables by count, primary-key hash, normalized row hash, credit totals, consultation states, and rectification counts. This is rehearsal evidence only; it does not authorize the final write freeze, production import, or DNS change.
Complete the remaining runtime and restore rehearsal before scheduling the final window:
1. Apply all target schema migrations to an empty rehearsal database using the same schema migrator path as `Migrate Production Database`.
2. Run migration preflight, apply, post-import reconciliation, and verify.
@@ -65,6 +65,10 @@ export function readConfiguration(env) {
if (!ownerUserId || !uuidPattern.test(ownerUserId)) {
throw new SafeProductionMigrationError("PRODUCTION_OWNER_USER_ID must be a UUID");
}
const ownerEmail = env.PRODUCTION_OWNER_EMAIL?.trim().toLowerCase();
if (!ownerEmail || !ownerEmail.includes("@")) {
throw new SafeProductionMigrationError("PRODUCTION_OWNER_EMAIL must be an email address");
}
const ciphertextMode = env.PRODUCTION_CIPHERTEXT_MODE?.trim();
if (!new Set(["preserve", "exclude"]).has(ciphertextMode)) {
throw new SafeProductionMigrationError(
@@ -79,7 +83,7 @@ export function readConfiguration(env) {
"preserving ciphertext requires confirmed production encryption keys",
);
}
return { sourceUrl, targetUrl, ownerUserId, ciphertextMode };
return { sourceUrl, targetUrl, ownerUserId, ownerEmail, ciphertextMode };
}
export function parseMode(arguments_) {
@@ -106,6 +110,7 @@ export async function readSchema(client, schema) {
select c.table_name, c.column_name, c.is_nullable = 'YES' as nullable,
c.column_default, c.is_generated <> 'NEVER' as generated,
c.is_identity = 'YES' as identity, c.identity_generation,
c.data_type, c.udt_name,
c.ordinal_position
from information_schema.columns c
join information_schema.tables t
@@ -167,6 +172,8 @@ export async function readSchema(client, schema) {
generated: row.generated,
identity: row.identity,
identityGeneration: row.identity_generation,
dataType: row.data_type,
udtName: row.udt_name,
});
}
for (const row of primaryKeysResult.rows) {
@@ -188,6 +195,17 @@ function columnMap(table) {
return new Map(table.columns.map((column) => [column.name, column]));
}
export function prepareColumnValues(row, columns, table) {
const columnsByName = columnMap(table);
return columns.map((name) => {
const value = row[name];
if (value === null || value === undefined) return value;
return ["json", "jsonb"].includes(columnsByName.get(name)?.dataType)
? JSON.stringify(value)
: value;
});
}
function commonColumns(sourceTable, targetTable) {
const sourceColumns = new Set(sourceTable.columns.map((column) => column.name));
return targetTable.columns
@@ -401,13 +419,16 @@ export async function readActiveAdminUserIds(source, sourceTables, ownerUserId)
return new Set(result.rows.map((row) => String(row.user_id).toLowerCase()));
}
export function assertActiveAdminUsers(users, activeAdminUserIds, ownerUserId) {
export function assertActiveAdminUsers(users, activeAdminUserIds, ownerUserId, ownerEmail) {
if (!activeAdminUserIds.has(ownerUserId)) {
throw new SafeProductionMigrationError("the designated Owner is not an active source administrator");
}
const usersById = new Map(users.map((user) => [user.id, user]));
const owner = usersById.get(ownerUserId);
if (!owner) throw new SafeProductionMigrationError("the designated Owner is absent from source auth users");
if (owner.email !== ownerEmail) {
throw new SafeProductionMigrationError("the designated Owner UUID does not match PRODUCTION_OWNER_EMAIL");
}
if (owner.banned) throw new SafeProductionMigrationError("the designated Owner is blocked");
for (const userId of activeAdminUserIds) {
const user = usersById.get(userId);
@@ -506,10 +527,29 @@ function deferredForeignKeys(tableName, table, selectedTables) {
);
}
export function copiedDeferredForeignKeys(tableName, table, selectedTables, copiedColumns) {
return deferredForeignKeys(tableName, table, selectedTables).filter(
(foreignKey) => foreignKey.columns.every((column) => copiedColumns.includes(column)),
);
}
function parameterList(length) {
return Array.from({ length }, (_, index) => `$${index + 1}`).join(", ");
}
async function migrateTable(tableName, operation) {
try {
return await operation();
} catch (error) {
if (error instanceof SafeProductionMigrationError) throw error;
const diagnostic = [error?.code, error?.constraint, error?.column].filter(Boolean).join(":");
throw new SafeProductionMigrationError(
`failed to migrate public.${tableName}${diagnostic ? ` (${diagnostic})` : ""}`,
);
}
}
async function insertIdentityUsers(target, users) {
const columns = [
"id", "name", "email", "email_verified", "email_verified_at", "image", "role",
@@ -545,7 +585,7 @@ async function mergeSeedTable(source, target, tableName, sourceTable, targetTabl
tableName,
"preserve",
);
const result = await target.query(sql, columns.map((column) => row[column]));
const result = await target.query(sql, prepareColumnValues(row, columns, targetTable));
map.set(String(sourceRow.id), result.rows[0].id);
}
maps.set(`public.${tableName}`, map);
@@ -573,12 +613,12 @@ async function mergeSeedRelation(source, target, tableName, sourceTable, targetT
tableName,
ciphertextMode,
);
await target.query(sql, columns.map((column) => row[column]));
await target.query(sql, prepareColumnValues(row, columns, targetTable));
if (map) {
const where = naturalKey.map((column, index) => `${quoted(column)} = $${index + 1}`).join(" and ");
const result = await target.query(
`select id from ${qualified("public", tableName)} where ${where}`,
naturalKey.map((column) => row[column]),
prepareColumnValues(row, naturalKey, targetTable),
);
map.set(String(sourceRow.id), result.rows[0].id);
}
@@ -590,7 +630,7 @@ async function mergeSeedRelation(source, target, tableName, sourceTable, targetT
async function copyTable(source, target, tableName, sourceTable, targetTable, maps, selectedTables, ciphertextMode) {
const columns = commonColumns(sourceTable, targetTable);
const rows = await readRows(source, "public", tableName, columns, sourceTable.primaryKey);
const deferred = deferredForeignKeys(tableName, targetTable, selectedTables);
const deferred = copiedDeferredForeignKeys(tableName, targetTable, selectedTables, columns);
const deferredColumns = new Set(
deferred.flatMap((foreignKey) => {
const columnsByName = columnMap(targetTable);
@@ -617,7 +657,7 @@ async function copyTable(source, target, tableName, sourceTable, targetTable, ma
);
row = { ...row };
for (const column of deferredColumns) row[column] = null;
await target.query(sql, columns.map((column) => row[column]));
await target.query(sql, prepareColumnValues(row, columns, targetTable));
}
for (const column of targetTable.columns.filter(
(column) => column.identity && columns.includes(column.name),
@@ -643,7 +683,10 @@ async function restoreDeferredForeignKeys(source, target, tableName, sourceTable
);
await target.query(
`update ${qualified("public", tableName)} set ${assignments.join(", ")} where ${where.join(" and ")}`,
[...updateColumns.map((column) => row[column]), ...sourceTable.primaryKey.map((column) => row[column])],
[
...prepareColumnValues(row, updateColumns, targetTable),
...prepareColumnValues(row, sourceTable.primaryKey, targetTable),
],
);
}
}
@@ -717,7 +760,7 @@ async function preflightContext(source, target, config, { requireEmpty = true }
]);
const activeAdminUserIds = await readActiveAdminUserIds(source, sourcePublic, config.ownerUserId);
const users = await readSourceUsers(source, sourceAuth, activeAdminUserIds);
assertActiveAdminUsers(users, activeAdminUserIds, config.ownerUserId);
assertActiveAdminUsers(users, activeAdminUserIds, config.ownerUserId, config.ownerEmail);
if (requireEmpty) await assertTargetEmpty(target, targetPublic);
await assertNoUnmappedSourceTables(source, sourcePublic, targetPublic);
await assertActiveAdminRoles(source, sourcePublic, config.ownerUserId);
@@ -732,31 +775,31 @@ async function applyMigration(source, target, config, context) {
await insertIdentityUsers(target, users);
for (const tableName of plan.seedTables) {
counts[`public.${tableName}`] = await mergeSeedTable(
counts[`public.${tableName}`] = await migrateTable(tableName, () => mergeSeedTable(
source, target, tableName, sourcePublic.get(tableName), targetPublic.get(tableName), maps,
);
));
}
for (const tableName of plan.seedRelations) {
counts[`public.${tableName}`] = await mergeSeedRelation(
counts[`public.${tableName}`] = await migrateTable(tableName, () => mergeSeedRelation(
source, target, tableName, sourcePublic.get(tableName), targetPublic.get(tableName), maps,
config.ciphertextMode,
);
));
}
const deferredByTable = new Map();
for (const tableName of plan.ordered) {
const result = await copyTable(
const result = await migrateTable(tableName, () => copyTable(
source, target, tableName, sourcePublic.get(tableName), targetPublic.get(tableName), maps,
plan.selected, config.ciphertextMode,
);
));
counts[`public.${tableName}`] = result.count;
deferredByTable.set(tableName, result.deferred);
}
for (const tableName of plan.ordered) {
await restoreDeferredForeignKeys(
await migrateTable(tableName, () => restoreDeferredForeignKeys(
source, target, tableName, sourcePublic.get(tableName), targetPublic.get(tableName),
deferredByTable.get(tableName), maps,
);
));
}
await forceOwner(target, config.ownerUserId);
await assertTargetAdminState(target, config.ownerUserId);
@@ -817,7 +860,7 @@ async function tableManifest(source, target, tableName, sourceTable, targetTable
const where = naturalKey.map((column, index) => `${quoted(column)} = $${index + 1}`).join(" and ");
const match = await target.query(
`select ${columns.map(quoted).join(", ")} from ${qualified("public", tableName)} where ${where}`,
naturalKey.map((column) => row[column]),
prepareColumnValues(row, naturalKey, targetTable),
);
targetRows.push(...match.rows);
}
@@ -849,39 +892,39 @@ async function queryAggregate(client, text) {
return (await client.query(text)).rows.map((row) => normalizeValue(row));
}
async function reconciliationAggregates(client, tables) {
async function reconciliationAggregates(client, selectedTables) {
const result = {};
if (tables.has("credit_transactions")) {
if (selectedTables.has("credit_transactions")) {
result.credits = await queryAggregate(
client,
`select transaction_type as state, count(*)::bigint as count, coalesce(sum(amount),0)::text as amount from public.credit_transactions group by transaction_type order by transaction_type`,
);
}
if (tables.has("payment_orders")) {
if (selectedTables.has("payment_orders")) {
result.orders = await queryAggregate(
client,
`select status, count(*)::bigint as count, coalesce(sum(money_cents),0)::text as money_cents, coalesce(sum(refund_amount_cents),0)::text as refund_cents from public.payment_orders group by status order by status`,
);
}
if (tables.has("user_subscriptions")) {
if (selectedTables.has("user_subscriptions")) {
result.subscriptions = await queryAggregate(
client,
`select status, count(*)::bigint as count from public.user_subscriptions group by status order by status`,
);
}
if (tables.has("personal_reports")) {
if (selectedTables.has("personal_reports")) {
result.personal_reports = await queryAggregate(
client,
`select status, count(*)::bigint as count from public.personal_reports group by status order by status`,
);
}
if (tables.has("consultation_requests")) {
if (selectedTables.has("consultation_requests")) {
result.consultations = await queryAggregate(
client,
`select status, count(*)::bigint as count from public.consultation_requests group by status order by status`,
);
}
const rectificationTables = [...tables.keys()].filter((table) => table.includes("rectification"));
const rectificationTables = [...selectedTables].filter((table) => table.includes("rectification"));
result.rectification = [];
for (const table of rectificationTables.sort()) {
result.rectification.push({ table, count: await countRows(client, "public", table) });
@@ -908,8 +951,8 @@ async function verifyMigration(source, target, config, context) {
);
}
await assertTargetAdminState(target, config.ownerUserId);
const sourceAggregates = await reconciliationAggregates(source, sourcePublic);
const targetAggregates = await reconciliationAggregates(target, targetPublic);
const sourceAggregates = await reconciliationAggregates(source, plan.selected);
const targetAggregates = await reconciliationAggregates(target, plan.selected);
const aggregatesOk = JSON.stringify(sourceAggregates) === JSON.stringify(targetAggregates);
const ok =
users.length === targetUsers.length &&
@@ -7,9 +7,11 @@ import {
SafeProductionMigrationError,
assertActiveAdminUsers,
assertTargetEmpty,
copiedDeferredForeignKeys,
normalizeAuthUser,
normalizeAuthUsers,
parseMode,
prepareColumnValues,
readActiveAdminUserIds,
readConfiguration,
readSchema,
@@ -29,6 +31,8 @@ function table(
generated?: boolean;
identity?: boolean;
identityGeneration?: string | null;
dataType?: string;
udtName?: string;
}>,
primaryKey = ["id"],
foreignKeys: Array<{
@@ -45,6 +49,8 @@ function table(
generated: false,
identity: false,
identityGeneration: null,
dataType: "text",
udtName: "text",
...column,
})),
primaryKey,
@@ -65,6 +71,7 @@ test("configuration requires an explicit Owner and ciphertext decision", () => {
SUPABASE_SOURCE_DATABASE_URL: "postgresql://source.invalid/jyotisha",
PRODUCTION_TARGET_DATABASE_URL: "postgresql://target.invalid/jyotisha",
PRODUCTION_OWNER_USER_ID: "018f4e6d-7a11-7000-8000-000000000001",
PRODUCTION_OWNER_EMAIL: "luna@copse.life",
};
assert.equal(
@@ -161,15 +168,19 @@ test("active administrators become usable identity admins", () => {
);
assert.deepEqual(users.map((user) => user.role), ["admin", "admin"]);
assert.doesNotThrow(() => assertActiveAdminUsers(users, activeAdminUserIds, ownerId));
assert.doesNotThrow(() => assertActiveAdminUsers(users, activeAdminUserIds, ownerId, `${ownerId}@example.com`));
assert.throws(
() => assertActiveAdminUsers(users, new Set([adminId]), ownerId),
() => assertActiveAdminUsers(users, new Set([adminId]), ownerId, `${ownerId}@example.com`),
/Owner is not an active source administrator/,
);
assert.throws(
() => assertActiveAdminUsers([{ ...users[0], banned: true }, users[1]], activeAdminUserIds, ownerId),
() => assertActiveAdminUsers([{ ...users[0], banned: true }, users[1]], activeAdminUserIds, ownerId, `${ownerId}@example.com`),
/Owner is blocked/,
);
assert.throws(
() => assertActiveAdminUsers(users, activeAdminUserIds, ownerId, "wrong@example.com"),
/UUID does not match/,
);
});
test("schema reader preserves PostgreSQL identity metadata", async () => {
@@ -185,6 +196,8 @@ test("schema reader preserves PostgreSQL identity metadata", async () => {
generated: false,
identity: true,
identity_generation: "ALWAYS",
data_type: "bigint",
udt_name: "int8",
}],
};
}
@@ -203,9 +216,42 @@ test("schema reader preserves PostgreSQL identity metadata", async () => {
generated: false,
identity: true,
identityGeneration: "ALWAYS",
dataType: "bigint",
udtName: "int8",
});
});
test("JSON columns are serialized without changing PostgreSQL arrays", () => {
const target = table([
{ name: "payload", dataType: "jsonb", udtName: "jsonb" },
{ name: "tags", dataType: "ARRAY", udtName: "_text" },
]);
const tags = ["a", "b"];
assert.deepEqual(
prepareColumnValues({ payload: [], tags }, ["payload", "tags"], target),
["[]", tags],
);
});
test("target-only nullable foreign keys are not restored from the legacy source", () => {
const target = table(
[{ name: "id" }, { name: "new_session_id", nullable: true }],
["id"],
[{
columns: ["new_session_id"],
refSchema: "public",
refTable: "chat_sessions",
refColumns: ["id"],
}],
);
assert.deepEqual(
copiedDeferredForeignKeys("requests", target, new Set(["chat_sessions"]), ["id"]),
[],
);
});
test("transfer plan rejects source-only public columns", () => {
const source = new Map([
["profiles", table([{ name: "id" }, { name: "legacy_value" }])],