fix: harden world travel migration
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
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"',
|
'CREATE UNIQUE INDEX "IDX_location_connection_direction"',
|
||||||
);
|
),
|
||||||
expect(migrationText).toContain(
|
expect.stringContaining(
|
||||||
'CREATE UNIQUE INDEX "IDX_active_travel_per_character"',
|
'CREATE UNIQUE INDEX "IDX_active_travel_per_character"',
|
||||||
|
),
|
||||||
|
expect.stringContaining('WHERE "status" = \'TRAVELLING\''),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
expect(migrationText).toContain('DROP TYPE "travel_status_enum"');
|
expect(upQueries[0]).toContain('CREATE TABLE "location_definitions"');
|
||||||
expect(migrationText).not.toContain('synchronize');
|
expect(upQueries).not.toEqual(
|
||||||
|
expect.arrayContaining([expect.stringContaining('CREATE EXTENSION')]),
|
||||||
|
);
|
||||||
|
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' })
|
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user