Merge pull request #10124 from ever-co/codex/fix-stage-uuid-migration

fix(database): unblock stage tenant migration on PostgreSQL UUIDs
This commit is contained in:
Ruslan Konviser
2026-09-07 11:59:41 +02:00
committed by GitHub
parent 01bd9c45ca
commit cb4e276e04
4 changed files with 117 additions and 6 deletions
+22
View File
@@ -243,6 +243,18 @@ jobs:
if: ${{ !cancelled() }}
runs-on: ${{ vars.RUNNER_LINUX_X64_8 || 'ubuntu-latest' }}
timeout-minutes: 180
services:
postgres:
image: postgres:18-alpine@sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2
env:
POSTGRES_USER: migration_test
POSTGRES_PASSWORD: migration_test
POSTGRES_DB: migration_test
ports:
- 5432/tcp
options: >-
--health-cmd "pg_isready -U migration_test -d migration_test"
--health-interval 5s --health-timeout 5s --health-retries 12
steps:
- uses: actions/checkout@v4
@@ -295,6 +307,16 @@ jobs:
# unavailable. One step, so there is no `if:` on a previous step's outcome to get wrong.
run: .github/scripts/restore-node-modules.sh
- name: Test PostgreSQL migrations
# cspell:words PGHOST PGPORT PGDATABASE
env:
PGHOST: 127.0.0.1
PGPORT: ${{ job.services.postgres.ports[5432] }}
PGUSER: migration_test
PGPASSWORD: migration_test
PGDATABASE: migration_test
run: yarn nx run core:test-postgres-migrations
- name: Build API
run: yarn build:api:prod:ci
@@ -0,0 +1,74 @@
// cspell:words PGHOST PGPORT PGDATABASE SAVEPOINT
// Run with PGHOST, PGPORT, PGUSER, PGPASSWORD and PGDATABASE pointing to a test database:
// yarn nx run core:test-postgres-migrations
// All fixtures use a temporary table and each test rolls back its transaction.
require('ts-node').register({ transpileOnly: true, compilerOptions: { module: 'CommonJS' } });
require('tsconfig-paths/register');
const assert = require('node:assert/strict');
const test = require('node:test');
const { Client } = require('pg');
const {
UniqueTenantStripeCustomer1790000009000
} = require('../packages/core/src/lib/database/migrations/1790000009000-UniqueTenantStripeCustomer.ts');
const id = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
async function withTenantTable(run) {
const client = new Client({ connectionTimeoutMillis: 10000 });
await client.connect();
try {
await client.query('BEGIN');
// Never resolve an unqualified migration table or index against application data.
await client.query('SET LOCAL search_path TO pg_temp');
await client.query('SET LOCAL statement_timeout TO 10000');
await client.query('CREATE TEMP TABLE tenant (id uuid PRIMARY KEY, "stripeCustomerId" text)');
await client.query('CREATE INDEX "IDX_tenant_stripe_customer_id" ON tenant ("stripeCustomerId")');
const queryRunner = {
connection: { options: { type: 'postgres' } },
query: async (sql, parameters) => (await client.query(sql, parameters)).rows
};
await run(client, queryRunner, new UniqueTenantStripeCustomer1790000009000());
} finally {
await client.query('ROLLBACK');
await client.end();
}
}
test('migrates UUID tenants with no Stripe links and permits multiple null links', async () => {
await withTenantTable(async (client, queryRunner, migration) => {
await client.query('INSERT INTO tenant VALUES ($1, NULL), ($2, NULL)', [id(1), id(2)]);
await migration.up(queryRunner);
await client.query('INSERT INTO tenant VALUES ($1, NULL)', [id(3)]);
assert.equal((await client.query('SELECT count(*)::int AS count FROM tenant')).rows[0].count, 3);
});
});
test('retains one UUID tenant per customer, clears only duplicates and enforces uniqueness', async () => {
await withTenantTable(async (client, queryRunner, migration) => {
await client.query('INSERT INTO tenant VALUES ($1, $6), ($2, $6), ($3, $6), ($4, $7), ($5, NULL)', [
id(3),
id(1),
id(2),
id(4),
id(5),
'cus_shared',
'cus_independent'
]);
await migration.up(queryRunner);
assert.deepEqual((await client.query('SELECT * FROM tenant ORDER BY id')).rows, [
{ id: id(1), stripeCustomerId: 'cus_shared' },
{ id: id(2), stripeCustomerId: null },
{ id: id(3), stripeCustomerId: null },
{ id: id(4), stripeCustomerId: 'cus_independent' },
{ id: id(5), stripeCustomerId: null }
]);
await client.query('SAVEPOINT duplicate_insert');
await assert.rejects(client.query('INSERT INTO tenant VALUES ($1, $2)', [id(6), 'cus_shared']), {
code: '23505'
});
await client.query('ROLLBACK TO SAVEPOINT duplicate_insert');
await migration.down(queryRunner);
await client.query('INSERT INTO tenant VALUES ($1, $2)', [id(6), 'cus_shared']);
});
});
+7
View File
@@ -47,6 +47,13 @@
"lint": {
"executor": "@nx/eslint:lint"
},
"test-postgres-migrations": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"command": "node --test .scripts/tenant-stripe-customer.postgres.test.cjs"
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
@@ -41,7 +41,7 @@ export class UniqueTenantStripeCustomer1790000009000 implements MigrationInterfa
// Trading a hypothetical for a possible outage is a bad deal, so the collision is resolved
// rather than hit.
//
// The oldest row keeps the customer and the others are set back to NULL. That is the safe
// The row with the smallest id keeps the customer and the others are set back to NULL. That is the safe
// direction: a tenant with no link simply shows no billing and can be relinked, whereas leaving
// two tenants pointed at one customer is the exact state this index exists to forbid. Anything
// cleared is logged loudly, because it means two tenants were sharing a billing account and
@@ -87,21 +87,25 @@ export class UniqueTenantStripeCustomer1790000009000 implements MigrationInterfa
}
}
/** Tenant ids that would violate the unique index, oldest row per customer excluded. */
/** Tenant ids that would violate the unique index, smallest id per customer excluded. */
private async findDuplicates(queryRunner: QueryRunner, type: DatabaseTypeEnum): Promise<string[]> {
// PostgreSQL can order UUIDs but has no MIN(uuid); aggregate their canonical text,
// then cast back so the outer predicate still compares UUIDs to UUIDs.
const minId = type === DatabaseTypeEnum.postgres ? 'MIN("id"::text)::uuid' : 'MIN("id")';
const q =
type === DatabaseTypeEnum.mysql
? 'SELECT `id` FROM `tenant` WHERE `stripeCustomerId` IS NOT NULL AND `id` NOT IN (SELECT id FROM (SELECT MIN(`id`) AS id FROM `tenant` WHERE `stripeCustomerId` IS NOT NULL GROUP BY `stripeCustomerId`) keep)'
: `SELECT "id" FROM "tenant" WHERE "stripeCustomerId" IS NOT NULL AND "id" NOT IN (SELECT MIN("id") FROM "tenant" WHERE "stripeCustomerId" IS NOT NULL GROUP BY "stripeCustomerId")`;
: `SELECT "id" FROM "tenant" WHERE "stripeCustomerId" IS NOT NULL AND "id" NOT IN (SELECT ${minId} FROM "tenant" WHERE "stripeCustomerId" IS NOT NULL GROUP BY "stripeCustomerId")`;
const rows: Array<{ id: string }> = await queryRunner.query(q);
return (rows ?? []).map((r) => r.id);
}
/** Sets those same rows back to NULL. Same predicate, so the two cannot drift apart. */
private clearDuplicatesSql(type: DatabaseTypeEnum): string {
const minId = type === DatabaseTypeEnum.postgres ? 'MIN("id"::text)::uuid' : 'MIN("id")';
return type === DatabaseTypeEnum.mysql
? 'UPDATE `tenant` SET `stripeCustomerId` = NULL WHERE `stripeCustomerId` IS NOT NULL AND `id` NOT IN (SELECT id FROM (SELECT MIN(`id`) AS id FROM `tenant` WHERE `stripeCustomerId` IS NOT NULL GROUP BY `stripeCustomerId`) keep)'
: `UPDATE "tenant" SET "stripeCustomerId" = NULL WHERE "stripeCustomerId" IS NOT NULL AND "id" NOT IN (SELECT MIN("id") FROM "tenant" WHERE "stripeCustomerId" IS NOT NULL GROUP BY "stripeCustomerId")`;
: `UPDATE "tenant" SET "stripeCustomerId" = NULL WHERE "stripeCustomerId" IS NOT NULL AND "id" NOT IN (SELECT ${minId} FROM "tenant" WHERE "stripeCustomerId" IS NOT NULL GROUP BY "stripeCustomerId")`;
}
public async down(queryRunner: QueryRunner): Promise<void> {
@@ -114,13 +118,17 @@ export class UniqueTenantStripeCustomer1790000009000 implements MigrationInterfa
switch (type) {
case DatabaseTypeEnum.postgres:
await queryRunner.query(`DROP INDEX "IDX_tenant_stripe_customer_id"`);
await queryRunner.query(`CREATE INDEX "IDX_tenant_stripe_customer_id" ON "tenant" ("stripeCustomerId")`);
await queryRunner.query(
`CREATE INDEX "IDX_tenant_stripe_customer_id" ON "tenant" ("stripeCustomerId")`
);
break;
case DatabaseTypeEnum.sqlite:
case DatabaseTypeEnum.betterSqlite3:
await queryRunner.query(`DROP INDEX "IDX_tenant_stripe_customer_id"`);
await queryRunner.query(`CREATE INDEX "IDX_tenant_stripe_customer_id" ON "tenant" ("stripeCustomerId")`);
await queryRunner.query(
`CREATE INDEX "IDX_tenant_stripe_customer_id" ON "tenant" ("stripeCustomerId")`
);
break;
case DatabaseTypeEnum.mysql: