fix: harden world travel migration
This commit is contained in:
@@ -41,7 +41,9 @@ export class Character {
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => LocationDefinition, (location) => location.characters)
|
||||
@ManyToOne(() => LocationDefinition, (location) => location.characters, {
|
||||
onDelete: 'RESTRICT',
|
||||
})
|
||||
@JoinColumn({ name: 'current_location_id' })
|
||||
currentLocation!: LocationDefinition;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,15 +41,15 @@ export class Travel {
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'character_id' })
|
||||
character!: Character;
|
||||
|
||||
@ManyToOne(() => LocationDefinition)
|
||||
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'origin_location_id' })
|
||||
originLocation!: LocationDefinition;
|
||||
|
||||
@ManyToOne(() => LocationDefinition)
|
||||
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'target_location_id' })
|
||||
targetLocation!: LocationDefinition;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export class LocationConnection {
|
||||
@ManyToOne(
|
||||
() => LocationDefinition,
|
||||
(location) => location.outgoingConnections,
|
||||
{ onDelete: 'RESTRICT' },
|
||||
)
|
||||
@JoinColumn({ name: 'from_location_id' })
|
||||
fromLocation!: LocationDefinition;
|
||||
@@ -45,6 +46,7 @@ export class LocationConnection {
|
||||
@ManyToOne(
|
||||
() => LocationDefinition,
|
||||
(location) => location.incomingConnections,
|
||||
{ onDelete: 'RESTRICT' },
|
||||
)
|
||||
@JoinColumn({ name: 'to_location_id' })
|
||||
toLocation!: LocationDefinition;
|
||||
|
||||
Reference in New Issue
Block a user