갱신 행의 ID를 보존하고 예약턴의 불필요한 시퀀스를 제거한다
This commit is contained in:
1 parent
bc54b157c1
commit
81e101ffef
43 files changed
+587
-113
No files matched your search
@@ -22,6 +22,7 @@
|
||||
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
|
||||
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh",
|
||||
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
|
||||
"verify:migration:reserved-turn-keys": "node scripts/verify-reserved-turn-key-migration.mjs",
|
||||
"verify:migration:rank-data": "node scripts/verify-rank-data-bigint-migration.mjs",
|
||||
"verify:migration:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh",
|
||||
"verify:migration:time-domains": "sh scripts/verify-time-domain-migration.sh",
|
||||
|
||||
@@ -679,14 +679,13 @@ model Troop {
|
||||
}
|
||||
|
||||
model GeneralTurn {
|
||||
id BigInt @id @default(autoincrement())
|
||||
generalId Int @map("general_id")
|
||||
turnIdx Int @map("turn_idx")
|
||||
actionCode String @map("action_code")
|
||||
arg Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([generalId, turnIdx])
|
||||
@@id([generalId, turnIdx])
|
||||
@@map("general_turn")
|
||||
}
|
||||
|
||||
@@ -701,7 +700,6 @@ model GeneralTurnRevision {
|
||||
}
|
||||
|
||||
model NationTurn {
|
||||
id BigInt @id @default(autoincrement())
|
||||
nationId Int @map("nation_id")
|
||||
officerLevel Int @map("officer_level")
|
||||
turnIdx Int @map("turn_idx")
|
||||
@@ -709,7 +707,7 @@ model NationTurn {
|
||||
arg Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([nationId, officerLevel, turnIdx])
|
||||
@@id([nationId, officerLevel, turnIdx])
|
||||
@@map("nation_turn")
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- Queue position and revision/lease are the domain identity. The unused ID was
|
||||
-- regenerated on every transactional delete/create, consuming 30/12 numbers.
|
||||
-- Reuse the existing proven unique indexes; keep every command and timestamp.
|
||||
BEGIN;
|
||||
ALTER TABLE "general_turn" DROP CONSTRAINT "general_turn_pkey";
|
||||
ALTER TABLE "general_turn" ADD CONSTRAINT "general_turn_pkey"
|
||||
PRIMARY KEY USING INDEX "general_turn_general_id_turn_idx_key";
|
||||
ALTER TABLE "general_turn" DROP COLUMN "id";
|
||||
ALTER TABLE "nation_turn" DROP CONSTRAINT "nation_turn_pkey";
|
||||
ALTER TABLE "nation_turn" ADD CONSTRAINT "nation_turn_pkey"
|
||||
PRIMARY KEY USING INDEX "nation_turn_nation_id_officer_level_turn_idx_key";
|
||||
ALTER TABLE "nation_turn" DROP COLUMN "id";
|
||||
COMMIT;
|
||||
@@ -53,6 +53,11 @@ const readIndexes = async (table) =>
|
||||
).rows;
|
||||
try {
|
||||
await fs.cp(path.join(packageRoot, 'prisma'), path.join(temp, 'prisma'), { recursive: true });
|
||||
for (const name of await fs.readdir(path.join(temp, 'prisma', 'migrations'))) {
|
||||
if (/^\d/.test(name) && name > targets.at(-1)) {
|
||||
await fs.rm(path.join(temp, 'prisma', 'migrations', name), { recursive: true });
|
||||
}
|
||||
}
|
||||
for (const target of targets) await fs.rm(path.join(temp, 'prisma', 'migrations', target), { recursive: true });
|
||||
await fs.writeFile(
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import pg from 'pg';
|
||||
|
||||
const sourceUrl = process.env.RESERVED_TURN_MIGRATION_TEST_DATABASE_URL;
|
||||
if (!sourceUrl) throw new Error('RESERVED_TURN_MIGRATION_TEST_DATABASE_URL is required (isolated development DB only)');
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const target = '20261114020000_reserved_turn_slot_primary_keys';
|
||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'reserved-slot-migration-'));
|
||||
const schema = `reserved_slot_${randomUUID().replaceAll('-', '')}`;
|
||||
const fresh = `${schema}_fresh`;
|
||||
const config = path.join(packageRoot, `.reserved-slot-${process.pid}.config.mjs`);
|
||||
const client = new pg.Client({ connectionString: sourceUrl });
|
||||
const url = new URL(sourceUrl);
|
||||
const deploy = (name) => {
|
||||
url.searchParams.set('schema', name);
|
||||
const result = spawnSync('pnpm', ['exec', 'prisma', 'migrate', 'deploy', '--config', config], {
|
||||
cwd: packageRoot,
|
||||
env: { ...process.env, DATABASE_URL: url.href },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.status !== 0) throw new Error(`Migration failed (exit ${result.status}); datasource output suppressed`);
|
||||
};
|
||||
const snapshot = async () => ({
|
||||
general: (
|
||||
await client.query(
|
||||
'SELECT general_id,turn_idx,action_code,arg,created_at FROM general_turn ORDER BY general_id,turn_idx'
|
||||
)
|
||||
).rows,
|
||||
nation: (
|
||||
await client.query(
|
||||
'SELECT nation_id,officer_level,turn_idx,action_code,arg,created_at FROM nation_turn ORDER BY nation_id,officer_level,turn_idx'
|
||||
)
|
||||
).rows,
|
||||
generalRevision: (await client.query('SELECT * FROM general_turn_revision ORDER BY general_id')).rows,
|
||||
nationRevision: (await client.query('SELECT * FROM nation_turn_revision ORDER BY nation_id,officer_level')).rows,
|
||||
});
|
||||
const assertKeys = async (name) => {
|
||||
assert.equal(
|
||||
(
|
||||
await client.query(
|
||||
"SELECT count(*)::int AS n FROM information_schema.columns WHERE table_schema=$1 AND table_name IN ('general_turn','nation_turn') AND column_name='id'",
|
||||
[name]
|
||||
)
|
||||
).rows[0].n,
|
||||
0
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await client.query(
|
||||
"SELECT count(*)::int AS n FROM pg_sequences WHERE schemaname=$1 AND sequencename IN ('general_turn_id_seq','nation_turn_id_seq')",
|
||||
[name]
|
||||
)
|
||||
).rows[0].n,
|
||||
0
|
||||
);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await client.query(
|
||||
"SELECT t.relname,pg_get_constraintdef(c.oid) AS definition FROM pg_constraint c JOIN pg_class t ON c.conrelid=t.oid JOIN pg_namespace n ON t.relnamespace=n.oid WHERE n.nspname=$1 AND c.contype='p' AND t.relname IN ('general_turn','nation_turn') ORDER BY t.relname",
|
||||
[name]
|
||||
)
|
||||
).rows,
|
||||
[
|
||||
{ relname: 'general_turn', definition: 'PRIMARY KEY (general_id, turn_idx)' },
|
||||
{ relname: 'nation_turn', definition: 'PRIMARY KEY (nation_id, officer_level, turn_idx)' },
|
||||
]
|
||||
);
|
||||
};
|
||||
try {
|
||||
await fs.cp(path.join(packageRoot, 'prisma'), path.join(temp, 'prisma'), { recursive: true });
|
||||
await fs.rm(path.join(temp, 'prisma', 'migrations', target), { recursive: true });
|
||||
await fs.writeFile(
|
||||
config,
|
||||
`export default { schema: ${JSON.stringify(path.join(temp, 'prisma/game.prisma'))}, datasource: { url: process.env.DATABASE_URL } };\n`
|
||||
);
|
||||
await client.connect();
|
||||
await client.query(`CREATE SCHEMA "${schema}"`);
|
||||
await client.query(`CREATE SCHEMA "${fresh}"`);
|
||||
deploy(schema);
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
await client.query(
|
||||
`INSERT INTO general_turn(id,general_id,turn_idx,action_code,arg,created_at) VALUES(3000000001,7,0,'che_훈련','{"x":1}','2026-11-14'),(3000000002,7,1,'휴식','{}','2026-11-14')`
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO nation_turn(id,nation_id,officer_level,turn_idx,action_code,arg,created_at) VALUES(3000000003,3,12,0,'che_포상','{"amount":10}','2026-11-14'),(3000000004,3,10,0,'휴식','{}','2026-11-14')`
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO general_turn_revision(general_id,revision,lease_owner,lease_expires_at,updated_at) VALUES(7,29,'fixture-daemon','2026-11-15','2026-11-14')`
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO nation_turn_revision(nation_id,officer_level,revision,lease_owner,lease_expires_at,updated_at) VALUES(3,12,41,'fixture-daemon','2026-11-15','2026-11-14')`
|
||||
);
|
||||
const before = await snapshot();
|
||||
await fs.cp(
|
||||
path.join(packageRoot, 'prisma', 'migrations', target),
|
||||
path.join(temp, 'prisma', 'migrations', target),
|
||||
{ recursive: true }
|
||||
);
|
||||
deploy(schema);
|
||||
assert.deepEqual(await snapshot(), before);
|
||||
await assertKeys(schema);
|
||||
await assert.rejects(
|
||||
client.query("INSERT INTO general_turn(general_id,turn_idx,action_code) VALUES(7,0,'duplicate')"),
|
||||
{ code: '23505' }
|
||||
);
|
||||
await assert.rejects(
|
||||
client.query(
|
||||
"INSERT INTO nation_turn(nation_id,officer_level,turn_idx,action_code) VALUES(3,12,0,'duplicate')"
|
||||
),
|
||||
{ code: '23505' }
|
||||
);
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM general_turn WHERE general_id=7');
|
||||
await client.query("INSERT INTO general_turn(general_id,turn_idx,action_code) VALUES(7,0,'replacement')");
|
||||
await client.query('ROLLBACK');
|
||||
assert.deepEqual(await snapshot(), before);
|
||||
const migrations = (
|
||||
await client.query('SELECT migration_name,checksum FROM _prisma_migrations ORDER BY migration_name')
|
||||
).rows;
|
||||
deploy(schema);
|
||||
assert.deepEqual(
|
||||
(await client.query('SELECT migration_name,checksum FROM _prisma_migrations ORDER BY migration_name')).rows,
|
||||
migrations
|
||||
);
|
||||
deploy(fresh);
|
||||
await client.query(`SET search_path TO "${fresh}", public`);
|
||||
await assertKeys(fresh);
|
||||
await client.query("INSERT INTO general_turn(general_id,turn_idx,action_code) VALUES(7,0,'휴식')");
|
||||
await client.query("INSERT INTO nation_turn(nation_id,officer_level,turn_idx,action_code) VALUES(3,12,0,'휴식')");
|
||||
console.log(
|
||||
'PASS: populated predecessor and fresh migration, command/arg/timestamp/revision/lease preservation, natural PK uniqueness, queue replacement rollback, removed sequences, deploy no-op'
|
||||
);
|
||||
} finally {
|
||||
await client.query('SET search_path TO public').catch(() => {});
|
||||
await client.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`).catch(() => {});
|
||||
await client.query(`DROP SCHEMA IF EXISTS "${fresh}" CASCADE`).catch(() => {});
|
||||
await client.end();
|
||||
await fs.rm(config, { force: true });
|
||||
await fs.rm(temp, { recursive: true });
|
||||
}
|
||||
@@ -16,3 +16,4 @@ export * from './messageEnvelope.js';
|
||||
export * from './webPushOutbox.js';
|
||||
export * from './playAuditBestEffort.js';
|
||||
export * from './playAuditDecisionCodec.js';
|
||||
export * from './stableRowId.js';
|
||||
@@ -0,0 +1,29 @@
|
||||
import { GamePrisma } from './gamePrisma.js';
|
||||
|
||||
/** Internal natural-key records retain their identity across ordinary updates. */
|
||||
export const upsertWithoutSequence = async <Where, Update, Create>(
|
||||
delegate: {
|
||||
update(args: { where: Where; data: Update }): PromiseLike<object>;
|
||||
upsert(args: { where: Where; update: Update; create: Create }): PromiseLike<object>;
|
||||
},
|
||||
args: { where: Where; update: Update; create: Create }
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await delegate.update({ where: args.where, data: args.update });
|
||||
} catch (error) {
|
||||
if (!(error instanceof GamePrisma.PrismaClientKnownRequestError) || error.code !== 'P2025') throw error;
|
||||
// A missing row is an application-level Prisma result, not a failed SQL
|
||||
// statement. The surrounding transaction remains usable. A competing
|
||||
// creator is still handled atomically by the original native upsert.
|
||||
await delegate.upsert(args);
|
||||
}
|
||||
};
|
||||
|
||||
export type StableIdTable = 'general_access_log' | 'traffic_period' | 'rank_data' | 'inheritance_point' | 'vote';
|
||||
|
||||
/** Only new natural keys allocate an ID; values and identifiers stay separate. */
|
||||
export const existingRowIdOrNext = (table: StableIdTable, predicate: GamePrisma.Sql): GamePrisma.Sql =>
|
||||
GamePrisma.sql`COALESCE(
|
||||
(SELECT stable_id."id" FROM ${GamePrisma.raw(`"${table}"`)} AS stable_id WHERE ${predicate}),
|
||||
nextval(pg_get_serial_sequence(${table}, 'id'))
|
||||
)`;
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '../src/gamePrisma.js';
|
||||
import { existingRowIdOrNext, upsertWithoutSequence } from '../src/stableRowId.js';
|
||||
|
||||
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
||||
const generalId = 7_329;
|
||||
const userId = 'stable-id-fixture';
|
||||
|
||||
describe.skipIf(!databaseUrl)('natural-key ID persistence on PostgreSQL', () => {
|
||||
let db: GamePrismaClient;
|
||||
let close: () => Promise<void>;
|
||||
const sequence = async (table: 'rank_data' | 'inheritance_point'): Promise<bigint> =>
|
||||
(
|
||||
await db.$queryRaw<
|
||||
Array<{ value: bigint }>
|
||||
>`SELECT pg_sequence_last_value(pg_get_serial_sequence(${table}, 'id')::regclass) AS value`
|
||||
)[0]!.value;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!new URL(databaseUrl!).searchParams.get('schema')?.endsWith('immediate_action_integration')) {
|
||||
throw new Error('Dedicated immediate action schema is required');
|
||||
}
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
close = () => connector.disconnect();
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (db) {
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
}
|
||||
await close?.();
|
||||
});
|
||||
|
||||
it('creates missing rows inside the transaction and retains identity on replace and increment', async () => {
|
||||
await db.$transaction(async (tx) => {
|
||||
await upsertWithoutSequence(tx.rankData, {
|
||||
where: { generalId_type: { generalId, type: 'stable_rank' } },
|
||||
update: { value: 11 },
|
||||
create: { generalId, type: 'stable_rank', value: 11 },
|
||||
});
|
||||
await upsertWithoutSequence(tx.inheritancePoint, {
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
update: { value: 100 },
|
||||
create: { userId, key: 'previous', value: 100 },
|
||||
});
|
||||
});
|
||||
const original = await db.rankData.findUniqueOrThrow({
|
||||
where: { generalId_type: { generalId, type: 'stable_rank' } },
|
||||
});
|
||||
const before = await sequence('rank_data');
|
||||
for (let index = 0; index < 5; index++)
|
||||
await db.$transaction((tx) =>
|
||||
upsertWithoutSequence(tx.rankData, {
|
||||
where: { generalId_type: { generalId, type: 'stable_rank' } },
|
||||
update: { value: { increment: 1 }, nationId: 3 },
|
||||
create: { generalId, type: 'stable_rank', value: 1, nationId: 3 },
|
||||
})
|
||||
);
|
||||
expect(await db.rankData.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
|
||||
id: original.id,
|
||||
value: 16,
|
||||
nationId: 3,
|
||||
});
|
||||
expect(await sequence('rank_data')).toBe(before);
|
||||
});
|
||||
|
||||
it('serializes concurrent updates without losing increments or allocating IDs', async () => {
|
||||
const before = await sequence('rank_data');
|
||||
await Promise.all(
|
||||
Array.from({ length: 12 }, () =>
|
||||
db.$transaction((tx) =>
|
||||
upsertWithoutSequence(tx.rankData, {
|
||||
where: { generalId_type: { generalId, type: 'stable_rank' } },
|
||||
update: { value: { increment: 1 } },
|
||||
create: { generalId, type: 'stable_rank', value: 1 },
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
expect(
|
||||
(await db.rankData.findUniqueOrThrow({ where: { generalId_type: { generalId, type: 'stable_rank' } } }))
|
||||
.value
|
||||
).toBe(28);
|
||||
expect(await sequence('rank_data')).toBe(before);
|
||||
// New-key races still converge through the original ON CONFLICT upsert.
|
||||
await Promise.all(
|
||||
Array.from({ length: 6 }, () =>
|
||||
db.$transaction((tx) =>
|
||||
upsertWithoutSequence(tx.rankData, {
|
||||
where: { generalId_type: { generalId, type: 'stable_race' } },
|
||||
update: { value: { increment: 1 } },
|
||||
create: { generalId, type: 'stable_race', value: 1 },
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
expect(
|
||||
(await db.rankData.findUniqueOrThrow({ where: { generalId_type: { generalId, type: 'stable_race' } } }))
|
||||
.value
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('keeps raw refund increments atomic and does not consume an ID for an existing point row', async () => {
|
||||
const point = await db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
});
|
||||
const before = await sequence('inheritance_point');
|
||||
await Promise.all(
|
||||
Array.from({ length: 8 }, () =>
|
||||
db.$executeRaw(GamePrisma.sql`
|
||||
INSERT INTO inheritance_point(id,user_id,key,value,updated_at)
|
||||
VALUES(${existingRowIdOrNext('inheritance_point', GamePrisma.sql`stable_id.user_id=${userId} AND stable_id.key='previous'`)},${userId},'previous',1,CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id,key) DO UPDATE SET value=inheritance_point.value+EXCLUDED.value
|
||||
`)
|
||||
)
|
||||
);
|
||||
expect(await db.inheritancePoint.findUniqueOrThrow({ where: { id: point.id } })).toMatchObject({ value: 108 });
|
||||
expect(await sequence('inheritance_point')).toBe(before);
|
||||
});
|
||||
|
||||
it('preserves rollback and propagates validation/constraint errors without creating a replacement row', async () => {
|
||||
const where = { generalId_type: { generalId, type: 'stable_rank' } };
|
||||
const before = await db.rankData.findUniqueOrThrow({ where });
|
||||
const counter = await sequence('rank_data');
|
||||
await expect(
|
||||
db.$transaction(async (tx) => {
|
||||
await upsertWithoutSequence(tx.rankData, {
|
||||
where,
|
||||
update: { value: 99 },
|
||||
create: { generalId, type: 'stable_rank', value: 99 },
|
||||
});
|
||||
throw new Error('fixture abort');
|
||||
})
|
||||
).rejects.toThrow('fixture abort');
|
||||
expect(await db.rankData.findUniqueOrThrow({ where })).toEqual(before);
|
||||
await expect(
|
||||
upsertWithoutSequence(db.rankData, {
|
||||
where,
|
||||
update: { type: 'stable_race' },
|
||||
create: { generalId, type: 'stable_rank' },
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'P2002' });
|
||||
expect(await sequence('rank_data')).toBe(counter);
|
||||
expect(await db.rankData.count({ where: { generalId } })).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user