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

@@ -41,7 +41,9 @@ export class Character {
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@ManyToOne(() => LocationDefinition, (location) => location.characters) @ManyToOne(() => LocationDefinition, (location) => location.characters, {
onDelete: 'RESTRICT',
})
@JoinColumn({ name: 'current_location_id' }) @JoinColumn({ name: 'current_location_id' })
currentLocation!: LocationDefinition; currentLocation!: LocationDefinition;
} }

View File

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

View File

@@ -1,8 +1,7 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { readFileSync } from 'node:fs'; import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { join } from 'node:path';
import { getMetadataArgsStorage } from 'typeorm';
import { Character } from '../../characters/entities/character.entity'; import { Character } from '../../characters/entities/character.entity';
import { CreateVisibleVerticalSlice1787072400000 } from './1787072400000-CreateVisibleVerticalSlice';
import { Travel } from '../../travel/entities/travel.entity'; import { Travel } from '../../travel/entities/travel.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity';
@@ -25,6 +24,11 @@ describe('visible vertical slice schema', () => {
expect(locationIndex).toBeDefined(); expect(locationIndex).toBeDefined();
const relations = metadata.relations.filter((relation) =>
[Character, LocationConnection, Travel].includes(
relation.target as typeof Character,
),
);
const joinColumns = metadata.joinColumns const joinColumns = metadata.joinColumns
.filter((joinColumn) => .filter((joinColumn) =>
[Character, LocationConnection, Travel].includes( [Character, LocationConnection, Travel].includes(
@@ -43,25 +47,122 @@ describe('visible vertical slice schema', () => {
'target_location_id', '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', () => { it('emits reversible SQL in dependency order without extension ownership', async () => {
const migrationText = readFileSync( const query = jest.fn().mockResolvedValue(undefined);
join(__dirname, '1787072400000-CreateVisibleVerticalSlice.ts'), const queryRunner = { query } as unknown as QueryRunner;
'utf8', const migration = new CreateVisibleVerticalSlice1787072400000();
);
expect(migrationText).toContain('CREATE TABLE "location_definitions"'); await migration.up(queryRunner);
expect(migrationText).toContain('CREATE TABLE "characters"');
expect(migrationText).toContain('CREATE TABLE "location_connections"'); const upQueries = query.mock.calls.map(([sql]) => sql as string);
expect(migrationText).toContain('CREATE TABLE "travels"');
expect(migrationText).toContain( expect(upQueries).toHaveLength(14);
'CREATE UNIQUE INDEX "IDX_location_connection_direction"', 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( expect(upQueries[0]).toContain('CREATE TABLE "location_definitions"');
'CREATE UNIQUE INDEX "IDX_active_travel_per_character"', expect(upQueries).not.toEqual(
expect.arrayContaining([expect.stringContaining('CREATE EXTENSION')]),
); );
expect(migrationText).toContain('DROP TYPE "travel_status_enum"'); expect(upQueries).not.toEqual(
expect(migrationText).not.toContain('synchronize'); 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"',
]);
}); });
}); });

View File

@@ -41,15 +41,15 @@ export class Travel {
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date; createdAt!: Date;
@ManyToOne(() => Character) @ManyToOne(() => Character, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'character_id' }) @JoinColumn({ name: 'character_id' })
character!: Character; character!: Character;
@ManyToOne(() => LocationDefinition) @ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'origin_location_id' }) @JoinColumn({ name: 'origin_location_id' })
originLocation!: LocationDefinition; originLocation!: LocationDefinition;
@ManyToOne(() => LocationDefinition) @ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'target_location_id' }) @JoinColumn({ name: 'target_location_id' })
targetLocation!: LocationDefinition; targetLocation!: LocationDefinition;
} }

View File

@@ -38,6 +38,7 @@ export class LocationConnection {
@ManyToOne( @ManyToOne(
() => LocationDefinition, () => LocationDefinition,
(location) => location.outgoingConnections, (location) => location.outgoingConnections,
{ onDelete: 'RESTRICT' },
) )
@JoinColumn({ name: 'from_location_id' }) @JoinColumn({ name: 'from_location_id' })
fromLocation!: LocationDefinition; fromLocation!: LocationDefinition;
@@ -45,6 +46,7 @@ export class LocationConnection {
@ManyToOne( @ManyToOne(
() => LocationDefinition, () => LocationDefinition,
(location) => location.incomingConnections, (location) => location.incomingConnections,
{ onDelete: 'RESTRICT' },
) )
@JoinColumn({ name: 'to_location_id' }) @JoinColumn({ name: 'to_location_id' })
toLocation!: LocationDefinition; toLocation!: LocationDefinition;