fix(migration): 재생성된 유산 저장소 자연키를 보존한다

This commit is contained in:
2026-08-23 12:00:42 +00:00
parent 3213f51125
commit 03086611b0
3 changed files with 27 additions and 8 deletions
+9 -2
View File
@@ -93,10 +93,11 @@ 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. |
| `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<general_no>.txt` seasons | Hash each season; import new immutable seasons after a full checkpoint. |
@@ -105,6 +106,12 @@ 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/<profile>_*/batres<general_no>.txt` regular files. It maps the
profile and season directory to the archived general's `(source_profile,
+3 -1
View File
@@ -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']);
}
@@ -30,6 +30,14 @@ const sourceRows = {
data: JSON.stringify({ leader: 80, power: 70, intel: 60, history: 'first<br>second<br>' }),
},
],
storage: [
{
id: 12,
namespace: 'inheritance_42',
key: 'point',
value: '[30,null]',
},
],
} satisfies Record<string, Array<Record<string, unknown>>>;
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'`);