diff --git a/apps/api/src/characters/entities/character.entity.ts b/apps/api/src/characters/entities/character.entity.ts index f8ca986..19faf68 100644 --- a/apps/api/src/characters/entities/character.entity.ts +++ b/apps/api/src/characters/entities/character.entity.ts @@ -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; } diff --git a/apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts b/apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts index f9f884d..18669c8 100644 --- a/apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts +++ b/apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts @@ -2,9 +2,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateVisibleVerticalSlice1787072400000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { - 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, diff --git a/apps/api/src/database/migrations/visible-vertical-slice.migration.spec.ts b/apps/api/src/database/migrations/visible-vertical-slice.migration.spec.ts index 08c2f88..af2c793 100644 --- a/apps/api/src/database/migrations/visible-vertical-slice.migration.spec.ts +++ b/apps/api/src/database/migrations/visible-vertical-slice.migration.spec.ts @@ -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"', + ]); }); }); diff --git a/apps/api/src/travel/entities/travel.entity.ts b/apps/api/src/travel/entities/travel.entity.ts index 1029f3d..3f62508 100644 --- a/apps/api/src/travel/entities/travel.entity.ts +++ b/apps/api/src/travel/entities/travel.entity.ts @@ -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; } diff --git a/apps/api/src/world/entities/location-connection.entity.ts b/apps/api/src/world/entities/location-connection.entity.ts index a2432b6..2effd01 100644 --- a/apps/api/src/world/entities/location-connection.entity.ts +++ b/apps/api/src/world/entities/location-connection.entity.ts @@ -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;