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
+14 -7
View File
@@ -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 whose maximum ID moved behind its checkpoint. Password rotation does not change
the source fingerprint. the source fingerprint.
| Source data | Incremental policy | | Source data | Incremental policy |
| --------------------------------------------- | ----------------------------------------------------------------------- | | ------------------------------------------ | ----------------------------------------------------------------------- |
| `member_log` | Read only IDs after the committed high-water mark. | | `member_log` | Read only IDs after the committed high-water mark. |
| game archive/event-history tables | Read only IDs after the profile checkpoint. | | 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. |
| `ng_games` | Rescan because a season row can gain its final winner after creation. | | game `storage` | Rescan by `(namespace, key)` and refresh a recreated row's source ID. |
| preserved `batres<general_no>.txt` seasons | Hash each season; import new immutable seasons after a full checkpoint. | | `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. |
The append policy assumes Ref primary keys are never reused and completed The append policy assumes Ref primary keys are never reused and completed
archive rows are immutable. Incremental mode does not mirror source deletions. 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; If either assumption is false, take a new reviewed backup and run full mode;
do not edit checkpoint rows by hand. 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 The optional file importer reads only immediate
`logs/preserved/<profile>_*/batres<general_no>.txt` regular files. It maps the `logs/preserved/<profile>_*/batres<general_no>.txt` regular files. It maps the
profile and season directory to the archived general's `(source_profile, profile and season directory to the archived general's `(source_profile,
+3 -1
View File
@@ -504,7 +504,9 @@ const migrateStorage = async (
} }
} }
if (target) { 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_point', points, ['user_id', 'key']);
await upsertRows(target, 'inheritance_user_state', userStates, ['user_id']); 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>' }), 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>>>; } satisfies Record<string, Array<Record<string, unknown>>>;
const sourcePool = (): MariaPool => { const sourcePool = (): MariaPool => {
@@ -148,6 +156,8 @@ describe('legacy archive game migration', () => {
expect(summary.importRunId).toBe('77'); expect(summary.importRunId).toBe('77');
expect(sql).toContain('INSERT INTO "legacy_archive"."game_history"'); expect(sql).toContain('INSERT INTO "legacy_archive"."game_history"');
expect(sql).toContain('INSERT INTO "legacy_archive"."general"'); 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_games"');
expect(sql).not.toContain('INSERT INTO "ng_old_generals"'); expect(sql).not.toContain('INSERT INTO "ng_old_generals"');
expect(sql).toContain(`SET "status" = 'COMPLETED'`); expect(sql).toContain(`SET "status" = 'COMPLETED'`);