갱신 행의 ID를 보존하고 예약턴의 불필요한 시퀀스를 제거한다

This commit is contained in:
Hide_D committed 2026-11-14 18:44:22 +09:00
1 parent bc54b157c1
commit 81e101ffef
43 files changed
+587 -113

No files matched your search

@@ -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);
});
});