fix: harden world travel migration

This commit is contained in:
Bastian Wagner
2026-08-18 19:03:21 +02:00
parent 492765d8ca
commit 21f701e904
5 changed files with 131 additions and 27 deletions

View File

@@ -2,9 +2,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateVisibleVerticalSlice1787072400000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
await queryRunner.query(`CREATE TABLE "location_definitions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"description" text NOT NULL,
@@ -23,7 +22,7 @@ export class CreateVisibleVerticalSlice1787072400000 implements MigrationInterfa
'CREATE UNIQUE INDEX "IDX_location_definitions_key" ON "location_definitions" ("key")',
);
await queryRunner.query(`CREATE TABLE "characters" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"name" character varying(150) NOT NULL,
"level" integer NOT NULL,
"experience" integer NOT NULL,
@@ -40,7 +39,7 @@ export class CreateVisibleVerticalSlice1787072400000 implements MigrationInterfa
'CREATE INDEX "IDX_characters_current_location" ON "characters" ("current_location_id")',
);
await queryRunner.query(`CREATE TABLE "location_connections" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"from_location_id" uuid NOT NULL,
"to_location_id" uuid NOT NULL,
"travel_duration_seconds" integer NOT NULL,
@@ -62,7 +61,7 @@ export class CreateVisibleVerticalSlice1787072400000 implements MigrationInterfa
"CREATE TYPE \"travel_status_enum\" AS ENUM ('TRAVELLING', 'COMPLETED')",
);
await queryRunner.query(`CREATE TABLE "travels" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"origin_location_id" uuid NOT NULL,
"target_location_id" uuid NOT NULL,

View File

@@ -1,8 +1,7 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { getMetadataArgsStorage } from 'typeorm';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { CreateVisibleVerticalSlice1787072400000 } from './1787072400000-CreateVisibleVerticalSlice';
import { Travel } from '../../travel/entities/travel.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
@@ -25,6 +24,11 @@ describe('visible vertical slice schema', () => {
expect(locationIndex).toBeDefined();
const relations = metadata.relations.filter((relation) =>
[Character, LocationConnection, Travel].includes(
relation.target as typeof Character,
),
);
const joinColumns = metadata.joinColumns
.filter((joinColumn) =>
[Character, LocationConnection, Travel].includes(
@@ -43,25 +47,122 @@ describe('visible vertical slice schema', () => {
'target_location_id',
]),
);
expect(
relations.map((relation) => ({
onDelete: relation.options.onDelete,
propertyName: relation.propertyName,
target: relation.target,
})),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'currentLocation',
target: Character,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'fromLocation',
target: LocationConnection,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'toLocation',
target: LocationConnection,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'character',
target: Travel,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'originLocation',
target: Travel,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'targetLocation',
target: Travel,
}),
]),
);
});
it('creates the complete schema with explicit reversible SQL', () => {
const migrationText = readFileSync(
join(__dirname, '1787072400000-CreateVisibleVerticalSlice.ts'),
'utf8',
);
it('emits reversible SQL in dependency order without extension ownership', async () => {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateVisibleVerticalSlice1787072400000();
expect(migrationText).toContain('CREATE TABLE "location_definitions"');
expect(migrationText).toContain('CREATE TABLE "characters"');
expect(migrationText).toContain('CREATE TABLE "location_connections"');
expect(migrationText).toContain('CREATE TABLE "travels"');
expect(migrationText).toContain(
'CREATE UNIQUE INDEX "IDX_location_connection_direction"',
await migration.up(queryRunner);
const upQueries = query.mock.calls.map(([sql]) => sql as string);
expect(upQueries).toHaveLength(14);
expect(upQueries).toEqual(
expect.arrayContaining([
expect.stringContaining('CREATE TABLE "location_definitions"'),
expect.stringContaining('CREATE TABLE "characters"'),
expect.stringContaining('CREATE TABLE "location_connections"'),
expect.stringContaining('CREATE TABLE "travels"'),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_location_connection_direction"',
),
expect.stringContaining(
'CREATE UNIQUE INDEX "IDX_active_travel_per_character"',
),
expect.stringContaining('WHERE "status" = \'TRAVELLING\''),
]),
);
expect(migrationText).toContain(
'CREATE UNIQUE INDEX "IDX_active_travel_per_character"',
expect(upQueries[0]).toContain('CREATE TABLE "location_definitions"');
expect(upQueries).not.toEqual(
expect.arrayContaining([expect.stringContaining('CREATE EXTENSION')]),
);
expect(migrationText).toContain('DROP TYPE "travel_status_enum"');
expect(migrationText).not.toContain('synchronize');
expect(upQueries).not.toEqual(
expect.arrayContaining([expect.stringContaining('uuid_generate_v4')]),
);
const tableQueries = upQueries.filter((sql) =>
sql.includes('CREATE TABLE'),
);
expect(tableQueries).toHaveLength(4);
expect(
tableQueries.every((sql) => sql.includes('DEFAULT gen_random_uuid()')),
).toBe(true);
const foreignKeySql = upQueries
.filter((sql) => sql.includes('ON DELETE RESTRICT'))
.join('\n');
expect(
upQueries.filter((sql) => sql.includes('ON DELETE RESTRICT')),
).toHaveLength(3);
expect(foreignKeySql).toContain('FK_characters_current_location');
expect(foreignKeySql).toContain('FK_location_connections_from_location');
expect(foreignKeySql).toContain('FK_location_connections_to_location');
expect(foreignKeySql).toContain('FK_travels_character');
expect(foreignKeySql).toContain('FK_travels_origin_location');
expect(foreignKeySql).toContain('FK_travels_target_location');
await migration.down(queryRunner);
const downQueries = query.mock.calls
.slice(upQueries.length)
.map(([sql]) => sql as string);
expect(downQueries).toEqual([
'DROP INDEX "IDX_active_travel_per_character"',
'DROP INDEX "IDX_travels_target_location"',
'DROP INDEX "IDX_travels_origin_location"',
'DROP INDEX "IDX_travels_character"',
'DROP TABLE "travels"',
'DROP INDEX "IDX_location_connections_to_location"',
'DROP INDEX "IDX_location_connections_from_location"',
'DROP INDEX "IDX_location_connection_direction"',
'DROP TABLE "location_connections"',
'DROP INDEX "IDX_characters_current_location"',
'DROP TABLE "characters"',
'DROP INDEX "IDX_location_definitions_key"',
'DROP TABLE "location_definitions"',
'DROP TYPE "travel_status_enum"',
]);
});
});