import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm' /** * **대상별 사건 목록** — 조회용 파생 표를 만든다. * * ── 왜 (2026-08-27 실측) ──────────────────────────────────────────────────── * 360 화면이 「이 대상의 이력」을 묻고 그 조회가 64초다. 「무엇으로 걸러 시각순으로 정렬해 앞에서 * 30개」라는 모양에 맞는 목차가 저널에 없고, 얹어도 풀리지 않는다. * * 여러 값으로 거르면(IN) 정렬 때문에 목차를 못 쓴다 * 축이 늘 때마다 저널의 목차가 늘어난다 — 360 의 축 넷이면 13개가 된다 * 품목과 로트가 한 문자열에 붙어 있어 품목만으로는 찾을 수 없다 * * ── 비어서 시작한다 ──────────────────────────────────────────────────────── * 이 표는 **앞으로만 채운다.** 기존 3,128만 건을 소급해서 채우지 않는다 — 그래야 새 조회 축이 필요할 * 때마다 전체를 다시 만드는 구조가 되지 않는다. 담지 않은 구간은 조회가 `coveredFrom` 으로 말한다. * * 그래서 이 마이그레이션은 큰 기록에서도 값싸다(표와 목차를 만들 뿐, 채우지 않는다). * * 멱등이고 TypeORM API 만 쓴다(드라이버 다섯을 다 지나가야 하므로 raw SQL 금지). 컬럼 타입 이름을 * 적지 않는 규율은 엔티티와 같다 — `datetime` 은 Postgres 가 거절한다. * * 설계: `operato-twin/design/plans/read-models.md` */ const TABLE = 'twin_subject_events' export class CreateTwinSubjectEvents1786500000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { /* * 표마다 따로 본다 — 하나가 이미 있다고 여기서 돌아가면 나머지가 만들어지지 않는다. 실제로 그렇게 * 적어 두었다가, 사건 목록만 있고 진행 지점 표가 없는 상태를 만들었다. */ if (!(await queryRunner.hasTable(TABLE))) { await this.createSubjectEvents(queryRunner) } } private async createSubjectEvents(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: TABLE, columns: [ { name: 'id', type: 'varchar', length: '36', isPrimary: true }, { name: 'domain_id', type: 'varchar', length: '36', isNullable: true }, { name: 'instance_id', type: 'varchar', isNullable: false }, { name: 'subject_kind', type: 'varchar', isNullable: false }, { name: 'subject_id', type: 'varchar', isNullable: false }, /* 시각 컬럼의 타입 이름은 드라이버가 정한다 — `timestamp` 는 sqlite 가 모르고 `datetime` 은 Postgres 가 거절한다. */ { name: 'event_time', type: queryRunner.connection.driver.mappedDataTypes.createDate as string, isNullable: false }, { name: 'revision', type: 'int', isNullable: false }, { name: 'event_type', type: 'varchar', isNullable: false }, { name: 'event_id', type: 'varchar', length: '36', isNullable: true }, { name: 'created_at', type: queryRunner.connection.driver.mappedDataTypes.createDate as string, isNullable: true } ] }), true ) const table = await queryRunner.getTable(TABLE) if (!table) return /* 이 표의 존재 이유 — 「이 대상의 것을 최신순으로」가 목차를 위에서 읽고 끝난다(정렬 없음). */ if (!table.indices.some(i => i.name === 'ix_twin_subject_event_0')) { await queryRunner.createIndex( table, new TableIndex({ name: 'ix_twin_subject_event_0', columnNames: ['domain_id', 'instance_id', 'subject_kind', 'subject_id', 'event_time'] }) ) } /* 보관 상한을 지우는 축 — 「이 트윈에서 오래된 줄」. */ if (!table.indices.some(i => i.name === 'ix_twin_subject_event_1')) { await queryRunner.createIndex( table, new TableIndex({ name: 'ix_twin_subject_event_1', columnNames: ['domain_id', 'instance_id', 'event_time'] }) ) } } public async down(queryRunner: QueryRunner): Promise { if (!(await queryRunner.hasTable(TABLE))) return await queryRunner.dropTable(TABLE) } }