diff --git a/tools/legacy-db-migration/README.md b/tools/legacy-db-migration/README.md index 1a65aeda..a31a1fae 100644 --- a/tools/legacy-db-migration/README.md +++ b/tools/legacy-db-migration/README.md @@ -92,19 +92,26 @@ apply. It also refuses a changed host/database/user identity or a source table whose maximum ID moved behind its checkpoint. Password rotation does not change the source fingerprint. -| Source data | Incremental policy | -| --------------------------------------------- | ----------------------------------------------------------------------- | -| `member_log` | Read only IDs after the committed high-water mark. | -| game archive/event-history tables | Read only IDs after the profile checkpoint. | -| `member`, root/game `storage`, `system`, bans | Rescan and idempotently upsert because old rows are mutable. | -| `ng_games` | Rescan because a season row can gain its final winner after creation. | -| preserved `batres.txt` seasons | Hash each season; import new immutable seasons after a full checkpoint. | +| Source data | Incremental policy | +| ------------------------------------------ | ----------------------------------------------------------------------- | +| `member_log` | Read only IDs after the committed high-water mark. | +| game archive/event-history tables | Read only IDs after the profile checkpoint. | +| `member`, root `storage`, `system`, bans | Rescan and idempotently upsert because old rows are mutable. | +| game `storage` | Rescan by `(namespace, key)` and refresh a recreated row's source ID. | +| `ng_games` | Rescan because a season row can gain its final winner after creation. | +| preserved `batres.txt` seasons | Hash each season; import new immutable seasons after a full checkpoint. | The append policy assumes Ref primary keys are never reused and completed archive rows are immutable. Incremental mode does not mirror source deletions. If either assumption is false, take a new reviewed backup and run full mode; do not edit checkpoint rows by hand. +Ref may delete and recreate a mutable game-storage tuple with the same +`(namespace, key)` and a new auto-increment ID. That tuple is the durable +identity; the latest source ID is retained only as recovery metadata. Rows for +deleted tuples remain archived because incremental mode does not infer +tombstones. + The optional file importer reads only immediate `logs/preserved/_*/batres.txt` regular files. It maps the profile and season directory to the archived general's `(source_profile, diff --git a/tools/legacy-db-migration/src/game.ts b/tools/legacy-db-migration/src/game.ts index d897d7da..784f6b2c 100644 --- a/tools/legacy-db-migration/src/game.ts +++ b/tools/legacy-db-migration/src/game.ts @@ -504,7 +504,9 @@ const migrateStorage = async ( } } if (target) { - await upsertRows(target, 'legacy_game_storage', archives, ['source_id']); + // Ref storage는 같은 namespace/key를 삭제 후 새 auto-increment ID로 다시 만들 수 있다. + // 장기 상태의 권위 identity로 자연키를 사용하고 최신 source ID까지 함께 갱신한다. + await upsertRows(target, 'legacy_game_storage', archives, ['namespace', 'key']); await upsertRows(target, 'inheritance_point', points, ['user_id', 'key']); await upsertRows(target, 'inheritance_user_state', userStates, ['user_id']); } diff --git a/tools/legacy-db-migration/test/game.test.ts b/tools/legacy-db-migration/test/game.test.ts index 79b86ef4..df780d91 100644 --- a/tools/legacy-db-migration/test/game.test.ts +++ b/tools/legacy-db-migration/test/game.test.ts @@ -30,6 +30,14 @@ const sourceRows = { data: JSON.stringify({ leader: 80, power: 70, intel: 60, history: 'first
second
' }), }, ], + storage: [ + { + id: 12, + namespace: 'inheritance_42', + key: 'point', + value: '[30,null]', + }, + ], } satisfies Record>>; const sourcePool = (): MariaPool => { @@ -148,6 +156,8 @@ describe('legacy archive game migration', () => { expect(summary.importRunId).toBe('77'); expect(sql).toContain('INSERT INTO "legacy_archive"."game_history"'); expect(sql).toContain('INSERT INTO "legacy_archive"."general"'); + expect(sql).toContain('INSERT INTO "legacy_game_storage"'); + expect(sql).toContain('ON CONFLICT ("namespace", "key")'); expect(sql).not.toContain('INSERT INTO "ng_games"'); expect(sql).not.toContain('INSERT INTO "ng_old_generals"'); expect(sql).toContain(`SET "status" = 'COMPLETED'`);