Spaces:
Runtime error
Runtime error
File size: 10,352 Bytes
46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Normalizes a PostgreSQL schema previously bootstrapped with DATABASE_SYNCHRONIZE=true.
*
* Under synchronize, `@PrimaryGeneratedColumn('uuid')` creates NATIVE uuid id/FK columns, and every
* `@ManyToOne(() => Session)` FK column is derived as uuid too. The canonical migration chain, however,
* builds the schema with varchar ids. The two strategies are not interchangeable on Postgres:
* `AddUuidDefaultsForPostgres` (1779235200000) is the first migration to touch a uuid column β its
* `gen_random_uuid()::varchar` DEFAULT on a uuid column is rejected β and later CREATE TABLE migrations
* (AddTemplates, AddBaileysStoredMessages) cannot add a varchar FK referencing the uuid sessions(id).
* Because `migrationsRun: true` is hardcoded for the Postgres data connection, this crash-loops boot.
*
* Ordered immediately after the baseline (1770108659848) and before that first collision, this migration
* converts the 9 generated-uuid PKs + 3 session-FK columns to varchar in lockstep (dropping/recreating
* the CASCADE FKs), then sets the `gen_random_uuid()::varchar` DEFAULTs. It is a no-op on SQLite and on
* an already-varchar (healthy) Postgres schema via a single information_schema probe.
*
* See issue #690. Operational note: `ALTER COLUMN ... TYPE varchar` is a full-table rewrite under an
* ACCESS EXCLUSIVE lock; for very large `messages` tables, run via the CLI (`npm run migration:run`)
* against the stopped app during a maintenance window.
*/
export class NormalizeSynchronizeUuidColumns1770200000000 implements MigrationInterface {
name = 'NormalizeSynchronizeUuidColumns1770200000000';
// The 9 generated-uuid PKs on the DATA connection. api_keys/audit_logs live on the sqlite 'main'
// connection (immune). lid_mappings/ingress_events/plugin_instances use a varchar @PrimaryColumn by
// design β do NOT add them here.
private readonly uuidPkTables = [
'sessions',
'webhooks',
'messages',
'message_batches',
'templates',
'baileys_stored_messages',
'webhook_delivery_failures',
'conversation_mappings',
'integration_delivery_failures',
];
// The 3 @ManyToOne(() => Session) FK-bearing tables. `name` is the CANONICAL re-add name matching the
// hasTable-guarded CREATE TABLE migrations (which no-op on a sync-built DB, so they do NOT re-add the
// FK β this migration owns it). The DROP name is DISCOVERED at runtime: synchronize names constraints
// with a TypeORM hash, not these canonical names.
private readonly sessionFks = [
{ table: 'webhooks', column: 'sessionId', name: 'FK_d209715bb62b12255e825580af6' },
{ table: 'templates', column: 'sessionId', name: 'FK_templates_sessionId' },
{ table: 'baileys_stored_messages', column: 'sessionId', name: 'FK_baileys_stored_messages_sessionId' },
];
public async up(queryRunner: QueryRunner): Promise<void> {
if (queryRunner.dataSource.options.type !== 'postgres') return;
// Gate: synchronize builds the WHOLE schema atomically (all-uuid, or synchronize itself errors), so a
// single representative probe on sessions.id is sufficient to detect a drifted schema. Per-column
// guards inside the loops below add defense-in-depth for the (non-reachable) partial-drift case.
if (!(await this.columnIsUuid(queryRunner, 'sessions', 'id'))) return;
await queryRunner.query(`SET LOCAL statement_timeout = 0`);
await this.ensureGenRandomUuid(queryRunner);
// 1. Drop FKs referencing sessions(id) FIRST β a uuid FK blocks ALTER of the referenced PK.
for (const fk of this.sessionFks) {
if (!(await queryRunner.hasTable(fk.table))) continue;
for (const c of await this.fkConstraintNames(queryRunner, fk.table, fk.column, 'sessions')) {
await queryRunner.query(`ALTER TABLE "${fk.table}" DROP CONSTRAINT IF EXISTS "${c}"`);
}
}
// 2. PK uuid -> varchar. DROP DEFAULT first: gen_random_uuid() (native uuid) is not assignment-coercible
// to varchar, so Postgres rejects ALTER TYPE while the default is still attached.
for (const t of this.uuidPkTables) {
if (!(await queryRunner.hasTable(t))) continue;
if (!(await this.columnIsUuid(queryRunner, t, 'id'))) continue; // per-column idempotency
await queryRunner.query(`ALTER TABLE "${t}" ALTER COLUMN "id" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "${t}" ALTER COLUMN "id" TYPE varchar USING "id"::text`);
}
// 3. Session-FK columns uuid -> varchar.
for (const fk of this.sessionFks) {
if (!(await queryRunner.hasTable(fk.table))) continue;
if (!(await this.columnIsUuid(queryRunner, fk.table, fk.column))) continue;
await queryRunner.query(
`ALTER TABLE "${fk.table}" ALTER COLUMN "${fk.column}" TYPE varchar USING "${fk.column}"::text`,
);
}
// 4. Recreate the 3 CASCADE FKs with canonical names.
for (const fk of this.sessionFks) {
if (!(await queryRunner.hasTable(fk.table))) continue;
await queryRunner.query(
`ALTER TABLE "${fk.table}" ADD CONSTRAINT "${fk.name}" ` +
`FOREIGN KEY ("${fk.column}") REFERENCES "sessions" ("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
// 5. gen_random_uuid()::varchar DEFAULT on every existing PK. Mandatory for tables whose CREATE TABLE
// migration no-op'd on the sync-built DB; idempotent SET DEFAULT for the rest.
for (const t of this.uuidPkTables) {
if (!(await queryRunner.hasTable(t))) continue;
await queryRunner.query(`ALTER TABLE "${t}" ALTER COLUMN "id" SET DEFAULT gen_random_uuid()::varchar`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Best-effort inverse (varchar -> native uuid); only meaningful to re-enable synchronize. USING id::uuid
// validates every value β Postgres aborts on non-uuid strings (no silent corruption).
if (queryRunner.dataSource.options.type !== 'postgres') return;
if (await this.columnIsUuid(queryRunner, 'sessions', 'id')) return; // already native uuid: nothing to revert
await queryRunner.query(`SET LOCAL statement_timeout = 0`);
await this.ensureGenRandomUuid(queryRunner);
// Drop FKs (discovered, same as up β robust to either hash or canonical names).
for (const fk of this.sessionFks) {
if (!(await queryRunner.hasTable(fk.table))) continue;
for (const c of await this.fkConstraintNames(queryRunner, fk.table, fk.column, 'sessions')) {
await queryRunner.query(`ALTER TABLE "${fk.table}" DROP CONSTRAINT IF EXISTS "${c}"`);
}
}
for (const t of this.uuidPkTables) {
if (!(await queryRunner.hasTable(t))) continue;
await queryRunner.query(`ALTER TABLE "${t}" ALTER COLUMN "id" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "${t}" ALTER COLUMN "id" TYPE uuid USING "id"::uuid`);
}
for (const fk of this.sessionFks) {
if (!(await queryRunner.hasTable(fk.table))) continue;
await queryRunner.query(
`ALTER TABLE "${fk.table}" ALTER COLUMN "${fk.column}" TYPE uuid USING "${fk.column}"::uuid`,
);
}
for (const fk of this.sessionFks) {
if (!(await queryRunner.hasTable(fk.table))) continue;
await queryRunner.query(
`ALTER TABLE "${fk.table}" ADD CONSTRAINT "${fk.name}" ` +
`FOREIGN KEY ("${fk.column}") REFERENCES "sessions" ("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
for (const t of this.uuidPkTables) {
if (!(await queryRunner.hasTable(t))) continue;
await queryRunner.query(`ALTER TABLE "${t}" ALTER COLUMN "id" SET DEFAULT gen_random_uuid()`);
}
}
// --- helpers ---
private async columnIsUuid(queryRunner: QueryRunner, table: string, column: string): Promise<boolean> {
const rows = (await queryRunner.query(
`SELECT udt_name FROM information_schema.columns ` +
`WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2`,
[table, column],
)) as { udt_name?: string }[] | undefined;
return rows?.[0]?.udt_name === 'uuid';
}
// Schema-scoped on BOTH sides so a custom POSTGRES_SCHEMA (search_path `<schema>,public`) does not match
// a namesake table in public. current_schema() returns the first existing schema in the path.
private async fkConstraintNames(
queryRunner: QueryRunner,
table: string,
column: string,
referenced: string,
): Promise<string[]> {
const rows = (await queryRunner.query(
`SELECT c.conname FROM pg_constraint c ` +
`JOIN pg_class cl ON c.conrelid = cl.oid JOIN pg_namespace n ON cl.relnamespace = n.oid ` +
`JOIN pg_attribute a ON a.attrelid = cl.oid AND a.attnum = ANY(c.conkey) ` +
`JOIN pg_class ref ON c.confrelid = ref.oid JOIN pg_namespace rn ON ref.relnamespace = rn.oid ` +
`WHERE c.contype = 'f' AND n.nspname = current_schema() AND cl.relname = $1 ` +
`AND a.attname = $2 AND rn.nspname = current_schema() AND ref.relname = $3`,
[table, column, referenced],
)) as { conname: string }[] | undefined;
return (rows ?? []).map(r => r.conname);
}
// gen_random_uuid() is core on PG13+; pgcrypto on <=12. This migration sets varchar DEFAULTs before
// AddUuidDefaultsForPostgres/AddIntegrationUuidDefaults, so it owns the gate (mirrors their logic).
private async ensureGenRandomUuid(queryRunner: QueryRunner): Promise<void> {
const v = (await queryRunner.query(`SELECT current_setting('server_version_num')::int AS num`)) as
{ num?: number | string }[] | undefined;
const num = Number(v?.[0]?.num ?? 0);
if (num > 0 && num < 130000) {
const installed = (await queryRunner.query(`SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto'`)) as
unknown[] | undefined;
if (!installed?.length) {
try {
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto`);
} catch (err) {
throw new Error(
`PostgreSQL ${num} (< 13) needs the pgcrypto extension for gen_random_uuid(), but it is ` +
`not installed and this database role cannot create it. Have a superuser run ` +
`"CREATE EXTENSION pgcrypto;" once, then restart.`,
{ cause: err },
);
}
}
}
}
}
|