mirror of
https://github.com/ever-co/ever-gauzy.git
synced 2026-10-02 01:54:50 +08:00
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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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}"],
|
||||
|
||||
+14
-6
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user