diff --git a/app/game-api/src/router/betting/index.ts b/app/game-api/src/router/betting/index.ts index 61e0f480..142d2c91 100644 --- a/app/game-api/src/router/betting/index.ts +++ b/app/game-api/src/router/betting/index.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { TRPCError } from '@trpc/server'; import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra'; import { z } from 'zod'; @@ -223,7 +224,7 @@ export const bettingRouter = router({ world.currentMonth, `${input.amount} 포인트를 베팅에 사용` ); - await ctx.db.rankData.upsert({ + await upsertWithoutSequence(ctx.db.rankData, { where: { generalId_type: { generalId: general.id, @@ -245,7 +246,7 @@ export const bettingRouter = router({ }); } - await ctx.db.nationBet.upsert({ + await upsertWithoutSequence(ctx.db.nationBet, { where: { bettingId_userId_selectionKey: { bettingId: input.bettingId, diff --git a/app/game-api/src/services/deferredGeneralAccess.ts b/app/game-api/src/services/deferredGeneralAccess.ts index 54ec73e4..00fde457 100644 --- a/app/game-api/src/services/deferredGeneralAccess.ts +++ b/app/game-api/src/services/deferredGeneralAccess.ts @@ -1,3 +1,4 @@ +import { existingRowIdOrNext } from '@sammo-ts/infra'; import { createHash, randomUUID } from 'node:crypto'; import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common'; @@ -94,7 +95,13 @@ export const enqueueDeferredGeneralAccess = async ( } await redis.eval(ENQUEUE_SCRIPT, { keys: [activeKey(profileName)], - arguments: [String(generalId), auth.user.id, String(weight), String(now.getTime()), String(ACTIVE_BATCH_TTL_MS)], + arguments: [ + String(generalId), + auth.user.id, + String(weight), + String(now.getTime()), + String(ACTIVE_BATCH_TTL_MS), + ], }); return true; }; @@ -258,9 +265,10 @@ export const flushDeferredGeneralAccessBatch = async ( ), missing_periods AS ( INSERT INTO "traffic_period" ( - "world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online" + "id", "world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online" ) SELECT + ${existingRowIdOrNext('traffic_period', GamePrisma.sql`stable_id.world_state_id = ${worldState.id} AND stable_id.year = (missing_key / 12)::INTEGER AND stable_id.month = (missing_key % 12 + 1)::INTEGER`)}, ${worldState.id}, (missing_key / 12)::INTEGER, (missing_key % 12 + 1)::INTEGER, @@ -281,9 +289,10 @@ export const flushDeferredGeneralAccessBatch = async ( ), period_upsert AS ( INSERT INTO "traffic_period" ( - "world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online" + "id", "world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online" ) SELECT + ${existingRowIdOrNext('traffic_period', GamePrisma.sql`stable_id.world_state_id = ${worldState.id} AND stable_id.year = ${worldState.currentYear} AND stable_id.month = ${worldState.currentMonth}`)}, ${worldState.id}, ${worldState.currentYear}, ${worldState.currentMonth}, @@ -343,10 +352,11 @@ export const flushDeferredGeneralAccessBatch = async ( ), access_updates AS ( INSERT INTO "general_access_log" ( - "general_id", "user_id", "last_refresh", "refresh", "refresh_total", + "id", "general_id", "user_id", "last_refresh", "refresh", "refresh_total", "refresh_score", "refresh_score_total" ) SELECT + ${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = resolved."general_id"`)}, resolved."general_id", resolved."user_id", resolved."last_refresh", @@ -471,11 +481,9 @@ export class DeferredGeneralAccessWorker { await this.redis.del(key); return; } - await this.redis.set( - key, - JSON.stringify({ nextAccessAt: state.nextAccessAt.toISOString() }), - { PX: Math.max(1, state.nextAccessAt.getTime() - now.getTime()) } - ); + await this.redis.set(key, JSON.stringify({ nextAccessAt: state.nextAccessAt.toISOString() }), { + PX: Math.max(1, state.nextAccessAt.getTime() - now.getTime()), + }); }) ); } diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index 9eec67de..4a713aa5 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -1,3 +1,4 @@ +import { existingRowIdOrNext } from '@sammo-ts/infra'; import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common'; import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra'; @@ -269,6 +270,7 @@ export const upsertGeneralAccess = async ( RETURNING id ) INSERT INTO traffic_period ( + id, world_state_id, year, month, @@ -277,6 +279,7 @@ export const upsertGeneralAccess = async ( refresh ) VALUES ( + ${existingRowIdOrNext('traffic_period', GamePrisma.sql`stable_id.world_state_id = ${input.worldStateId} AND stable_id.year = ${input.year} AND stable_id.month = ${input.month}`)}, ${input.worldStateId}, ${input.year}, ${input.month}, @@ -341,6 +344,7 @@ export const upsertGeneralAccess = async ( await transaction.$executeRaw( GamePrisma.sql` INSERT INTO general_access_log ( + id, general_id, user_id, last_refresh, @@ -350,6 +354,7 @@ export const upsertGeneralAccess = async ( refresh_score_total ) VALUES ( + ${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = ${input.generalId}`)}, ${input.generalId}, ${input.userId}, ${input.now}, diff --git a/app/game-api/src/services/generalActivity.ts b/app/game-api/src/services/generalActivity.ts index c1b72b16..b1462480 100644 --- a/app/game-api/src/services/generalActivity.ts +++ b/app/game-api/src/services/generalActivity.ts @@ -1,3 +1,4 @@ +import { existingRowIdOrNext } from '@sammo-ts/infra'; import { GamePrisma } from '@sammo-ts/infra'; import type { GameApiContext } from '../context.js'; @@ -20,11 +21,13 @@ export const recordGeneralActivity = async ( const written = await ctx.db.$executeRaw( GamePrisma.sql` INSERT INTO general_access_log ( + id, general_id, user_id, last_action_at ) SELECT + ${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = "general".id`)}, id, ${user.id}, ${now} diff --git a/app/game-api/src/services/inheritance.ts b/app/game-api/src/services/inheritance.ts index fce6c558..3b580de4 100644 --- a/app/game-api/src/services/inheritance.ts +++ b/app/game-api/src/services/inheritance.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { asNumber, asRecord } from '@sammo-ts/common'; import { GamePrisma } from '@sammo-ts/infra'; import { @@ -132,7 +133,7 @@ export const setInheritancePoint = async ( key: InheritPointKey, value: number ): Promise => { - await db.inheritancePoint.upsert({ + await upsertWithoutSequence(db.inheritancePoint, { where: { userId_key: { userId, diff --git a/app/game-api/test/deferredGeneralAccess.integration.test.ts b/app/game-api/test/deferredGeneralAccess.integration.test.ts index 07cf3e57..259fef14 100644 --- a/app/game-api/test/deferredGeneralAccess.integration.test.ts +++ b/app/game-api/test/deferredGeneralAccess.integration.test.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { DeferredGeneralAccessWorker, + flushDeferredGeneralAccessBatch, enqueueDeferredGeneralAccess, getDeferredGeneralAccessLimit, } from '../src/services/deferredGeneralAccess.js'; @@ -91,7 +92,7 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => { await redis.disconnect(); } if (db) { - await db.generalAccessBatch.deleteMany({ where: { id: batchId } }); + await db.generalAccessBatch.deleteMany({ where: { id: { startsWith: batchId } } }); await db.generalAccessLog.deleteMany({ where: { generalId } }); await db.general.deleteMany({ where: { id: generalId } }); await db.worldState.deleteMany({ where: { id: worldStateId } }); @@ -153,9 +154,19 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => { nextAccessAt, }); + const counters = + await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq`; + const accessId = (await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id; + await flushDeferredGeneralAccessBatch(workerDb as never, `${batchId}-second`, [ + { generalId, userId, weight: 1, lastRefresh: now }, + ]); + expect( + await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq` + ).toEqual(counters); + expect((await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id).toBe(accessId); await worker.flushOnce(); await expect(db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).resolves.toMatchObject({ - refreshTotal: 2, + refreshTotal: 3, }); }); }); diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts index 6e86c4d7..edd3b3eb 100644 --- a/app/game-api/test/generalAccessTracking.integration.test.ts +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -25,7 +25,7 @@ const deferredBatchId = `deferred-access-${endpointGeneralId}`; const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({ version: 1, - profile: `${yearbookProfile}:default`, + profile: yearbookProfile, issuedAt: '2026-07-26T00:00:00.000Z', expiresAt: '2026-07-27T00:00:00.000Z', sessionId: `access-session-${endpointGeneralId}`, @@ -167,6 +167,9 @@ integration('general access tracking persistence', () => { scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'), }; await upsertGeneralAccess(db, { ...firstWindow, weight: 2 }); + const counters = + await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq`; + const accessId = (await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id; await Promise.all( Array.from({ length: 20 }, (_, index) => upsertGeneralAccess(db, { @@ -177,6 +180,10 @@ integration('general access tracking persistence', () => { ) ); + expect( + await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq` + ).toEqual(counters); + expect((await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id).toBe(accessId); expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({ userId: 'access-user-a', refresh: 22, diff --git a/app/game-api/test/reservedTurns.test.ts b/app/game-api/test/reservedTurns.test.ts index d12888b6..d09fa2c4 100644 --- a/app/game-api/test/reservedTurns.test.ts +++ b/app/game-api/test/reservedTurns.test.ts @@ -69,8 +69,7 @@ const buildDb = (autorunLimit: number | null = null) => { }, createMany: async ({ data }: GeneralTurnCreateManyArgs) => { const dataList = (Array.isArray(data) ? data : [data]) as Record[]; - const rows: GeneralTurnRow[] = dataList.map((row, index: number) => ({ - id: BigInt(index + 1), + const rows: GeneralTurnRow[] = dataList.map((row) => ({ generalId: row.generalId as number, turnIdx: row.turnIdx as number, actionCode: row.actionCode as string, @@ -136,8 +135,7 @@ const buildDb = (autorunLimit: number | null = null) => { }, createMany: async ({ data }: NationTurnCreateManyArgs) => { const dataList = (Array.isArray(data) ? data : [data]) as Record[]; - const rows: NationTurnRow[] = dataList.map((row, index: number) => ({ - id: BigInt(index + 1), + const rows: NationTurnRow[] = dataList.map((row) => ({ nationId: row.nationId as number, officerLevel: row.officerLevel as number, turnIdx: row.turnIdx as number, @@ -207,7 +205,6 @@ describe('reservedTurns', () => { async () => [ { - id: 1n, nationId: 4, officerLevel: 12, turnIdx: 1, @@ -216,7 +213,6 @@ describe('reservedTurns', () => { createdAt: new Date(), }, { - id: 2n, nationId: 4, officerLevel: 10, turnIdx: 0, @@ -414,7 +410,6 @@ describe('reservedTurns', () => { nationRevisions.set('6:12', 2); nationTurns.set('6:12', [ { - id: 1n, nationId: 6, officerLevel: 12, turnIdx: 0, @@ -423,7 +418,6 @@ describe('reservedTurns', () => { createdAt: new Date(), }, { - id: 2n, nationId: 6, officerLevel: 12, turnIdx: 1, diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index fc64ebcf..3250331e 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -901,7 +901,6 @@ describe('appRouter', () => { const general = buildGeneralRow({ id: 11, meta: { autorun_limit: 2408 } }); const generalTurns: GeneralTurnRow[] = [ { - id: 1n, generalId: 11, turnIdx: 0, actionCode: 'che_화계', @@ -922,7 +921,6 @@ describe('appRouter', () => { const general = buildGeneralRow({ id: 12, nationId: 3, officerLevel: 5 }); const nationTurns: NationTurnRow[] = [ { - id: 1n, nationId: 3, officerLevel: 5, turnIdx: 0, @@ -1008,7 +1006,6 @@ describe('appRouter', () => { general, generalTurns: [ { - id: 1n, generalId: general.id, turnIdx: 0, actionCode: 'che_훈련', @@ -1016,7 +1013,6 @@ describe('appRouter', () => { createdAt: new Date(), }, { - id: 2n, generalId: general.id, turnIdx: 1, actionCode: 'che_사기진작', diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 7360a9d7..394f24f2 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -969,7 +969,7 @@ integration('game API security over HTTP transport', () => { const telemetryAfter = await readAccessTelemetryState(); expect(telemetryAfter.periods).toEqual([ { - id: expect.any(Number), + id: expect.any(BigInt), worldStateId: fixtureWorldId, year: 190, month: 1, @@ -992,7 +992,7 @@ integration('game API security over HTTP transport', () => { ]); expect(telemetryAfter.accessLogs).toEqual([ { - id: expect.any(Number), + id: expect.any(BigInt), generalId, userId, lastRefresh: trafficPeriod.lastRefresh, @@ -1548,8 +1548,8 @@ integration('game API security over HTTP transport', () => { arg, })) ).toEqual(databaseAfter.generalTurns); - expect(new Set(committedGeneralTurns.map(({ id }) => id)).size).toBe(30); - expect(committedGeneralTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true); + expect(new Set(committedGeneralTurns.map(({ generalId, turnIdx }) => `${generalId}:${turnIdx}`)).size).toBe(30); + expect(committedGeneralTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true); expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId !== generalId)).toEqual( allowedTablesBefore.generalTurnRevisions.filter((row) => row.generalId !== generalId) ); @@ -1567,7 +1567,7 @@ integration('game API security over HTTP transport', () => { ); expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([ { - id: expect.any(Number), + id: expect.any(BigInt), generalId, userId, lastRefresh: null, @@ -1733,8 +1733,14 @@ integration('game API security over HTTP transport', () => { arg, })) ).toEqual(expectedTurns); - expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12); - expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true); + expect( + new Set( + committedNationTurns.map( + ({ nationId, officerLevel, turnIdx }) => `${nationId}:${officerLevel}:${turnIdx}` + ) + ).size + ).toBe(12); + expect(committedNationTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true); expect( allowedTablesAfter.nationTurnRevisions.filter( (row) => row.nationId !== ownerNationId || row.officerLevel !== 5 @@ -1763,7 +1769,7 @@ integration('game API security over HTTP transport', () => { ); expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === sameNationGeneralId)).toEqual([ { - id: expect.any(Number), + id: expect.any(BigInt), generalId: sameNationGeneralId, userId: sameNationUserId, lastRefresh: null, @@ -1880,8 +1886,14 @@ integration('game API security over HTTP transport', () => { arg, })) ).toEqual(committed.nationTurns); - expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12); - expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true); + expect( + new Set( + committedNationTurns.map( + ({ nationId, officerLevel, turnIdx }) => `${nationId}:${officerLevel}:${turnIdx}` + ) + ).size + ).toBe(12); + expect(committedNationTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true); expect( allowedTablesAfter.nationTurnRevisions.filter( (row) => row.nationId !== ownerNationId || row.officerLevel !== 12 @@ -1910,7 +1922,7 @@ integration('game API security over HTTP transport', () => { ); expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([ { - id: expect.any(Number), + id: expect.any(BigInt), generalId, userId, lastRefresh: null, @@ -2868,7 +2880,7 @@ integration('game API security over HTTP transport', () => { result: { data: { coverage: 'IDENTIFIED_LOGS_ONLY', - items: [{ id: ownLogs[index]!.id }], + items: [{ id: Number(ownLogs[index]!.id) }], nextCursor: null, }, }, @@ -2877,17 +2889,19 @@ integration('game API security over HTTP transport', () => { } expect( (await get('generalLogs', admin, { generalId: logGeneralId, type: 'generalHistory', limit: 1 })).body - ).toMatchObject({ result: { data: { items: [{ id: latest.id }], nextCursor: latest.id } } }); + ).toMatchObject({ + result: { data: { items: [{ id: Number(latest.id) }], nextCursor: Number(latest.id) } }, + }); expect( ( await get('generalLogs', admin, { generalId: logGeneralId, type: 'generalHistory', limit: 1, - cursor: latest.id, + cursor: Number(latest.id), }) ).body - ).toMatchObject({ result: { data: { items: [{ id: ownLogs[0]!.id }], nextCursor: null } } }); + ).toMatchObject({ result: { data: { items: [{ id: Number(ownLogs[0]!.id) }], nextCursor: null } } }); for (const invalid of [ { limit: 201 }, { cursor: 0 }, diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts index 7b68fbec..a30f198a 100644 --- a/app/game-engine/src/auction/bidder.ts +++ b/app/game-engine/src/auction/bidder.ts @@ -1,3 +1,4 @@ +import { existingRowIdOrNext } from '@sammo-ts/infra'; import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import { randomUUID } from 'node:crypto'; @@ -580,8 +581,8 @@ export const createAuctionBidder = async (options: { } await tx.$executeRaw( GamePrisma.sql` - INSERT INTO rank_data (nation_id, general_id, type, value) - SELECT nation_id, id, 'inherit_spent_dyn', ${morePoint} + INSERT INTO rank_data (id, nation_id, general_id, type, value) + SELECT ${existingRowIdOrNext('rank_data', GamePrisma.sql`stable_id.general_id = "general".id AND stable_id.type = 'inherit_spent_dyn'`)}, nation_id, id, 'inherit_spent_dyn', ${morePoint} FROM general WHERE id = ${command.generalId} ON CONFLICT (general_id, type) @@ -598,8 +599,9 @@ export const createAuctionBidder = async (options: { } await tx.$executeRaw( GamePrisma.sql` - INSERT INTO inheritance_point (user_id, key, value, updated_at) + INSERT INTO inheritance_point (id, user_id, key, value, updated_at) VALUES ( + ${existingRowIdOrNext('inheritance_point', GamePrisma.sql`stable_id.user_id = ${prevUserId} AND stable_id.key = 'previous'`)}, ${prevUserId}, 'previous', ${highestBid.amount}, diff --git a/app/game-engine/src/auction/finalizer.ts b/app/game-engine/src/auction/finalizer.ts index deaffc17..89d12c3d 100644 --- a/app/game-engine/src/auction/finalizer.ts +++ b/app/game-engine/src/auction/finalizer.ts @@ -1,3 +1,4 @@ +import { existingRowIdOrNext } from '@sammo-ts/infra'; import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic'; @@ -212,8 +213,8 @@ const refundInheritancePoint = async (options: { } await prisma.$executeRaw( GamePrisma.sql` - INSERT INTO inheritance_point (user_id, key, value, updated_at) - VALUES (${userId}, 'previous', ${amount}, ${new Date()}) + 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', ${amount}, ${new Date()}) ON CONFLICT (user_id, key) DO UPDATE SET value = inheritance_point.value + EXCLUDED.value, diff --git a/app/game-engine/src/auction/opener.ts b/app/game-engine/src/auction/opener.ts index f3972362..24d3f6a2 100644 --- a/app/game-engine/src/auction/opener.ts +++ b/app/game-engine/src/auction/opener.ts @@ -1,3 +1,4 @@ +import { existingRowIdOrNext } from '@sammo-ts/infra'; import { randomUUID } from 'node:crypto'; import { asRecord, JosaUtil } from '@sammo-ts/common'; @@ -289,8 +290,8 @@ const openUniqueAuction = async ( }); await db.$executeRaw( GamePrisma.sql` - INSERT INTO rank_data (nation_id, general_id, type, value) - VALUES (${general.nationId}, ${general.id}, 'inherit_spent_dyn', ${command.amount}) + INSERT INTO rank_data (id, nation_id, general_id, type, value) + VALUES (${existingRowIdOrNext('rank_data', GamePrisma.sql`stable_id.general_id = ${general.id} AND stable_id.type = 'inherit_spent_dyn'`)}, ${general.nationId}, ${general.id}, 'inherit_spent_dyn', ${command.amount}) ON CONFLICT (general_id, type) DO UPDATE SET nation_id = EXCLUDED.nation_id, diff --git a/app/game-engine/src/scenario/gameCancellation.ts b/app/game-engine/src/scenario/gameCancellation.ts index 7923e159..5849c706 100644 --- a/app/game-engine/src/scenario/gameCancellation.ts +++ b/app/game-engine/src/scenario/gameCancellation.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { asRecord } from '@sammo-ts/common'; import { createGamePostgresConnector, type GamePrisma, type InputJsonValue } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/logic'; @@ -424,7 +425,7 @@ const cancelGameInTransaction = async ( earnedPointRetentionPercent: request.earnedPointRetentionPercent, }); const currentPoint = pointsByUser.get(userId)?.get('previous') ?? 0; - await prisma.inheritancePoint.upsert({ + await upsertWithoutSequence(prisma.inheritancePoint, { where: { userId_key: { userId, key: 'previous' } }, update: { value: calculated.finalPoint }, create: { userId, key: 'previous', value: calculated.finalPoint }, @@ -470,7 +471,7 @@ const cancelGameInTransaction = async ( } for (const general of activeGenerals) { if (!general.userId || general.npcState >= 2) continue; - await prisma.oldGeneral.upsert({ + await upsertWithoutSequence(prisma.oldGeneral, { where: { by_no: { serverId, generalNo: general.id } }, update: { owner: general.userId, diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 07429fcd..67d7a9a3 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { prunePreviousAuditBatch } from '../playAudit/retention.js'; import { randomBytes } from 'node:crypto'; @@ -497,7 +498,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom ); } } - await prisma.gameHistory.upsert({ + await upsertWithoutSequence(prisma.gameHistory, { where: { serverId: worldMeta.serverId }, create: { serverId: worldMeta.serverId, diff --git a/app/game-engine/src/tournament/finalizer.ts b/app/game-engine/src/tournament/finalizer.ts index c8bbda02..c47594a5 100644 --- a/app/game-engine/src/tournament/finalizer.ts +++ b/app/game-engine/src/tournament/finalizer.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js'; import { JosaUtil, asRecord } from '@sammo-ts/common'; import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra'; @@ -256,7 +257,7 @@ export const createTournamentRewardFinalizer = async (options: { const recordsFinalized = await areSeasonRecordsFinalized(db, world.getState().meta.serverId); for (const entry of recordsFinalized ? [] : pointUpdates) { - await db.inheritancePoint.upsert({ + await upsertWithoutSequence(db.inheritancePoint, { where: { userId_key: { userId: entry.userId!, key: 'tournament' }, }, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index ca6d8dfb..e2fcd58f 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { persistAuditDecisions } from '../playAudit/decisionPersistence.js'; import { collectPlayAudit, markAuditGap } from '../playAudit/bestEffort.js'; import { persistAuditDiplomacyEvents, withPlayAuditSavepoint } from '@sammo-ts/infra'; @@ -697,12 +698,12 @@ const persistNationBettingFinish = async ( }); const previousPoint = existing?.value ?? 0; const nextPoint = previousPoint + reward.amount; - await prisma.inheritancePoint.upsert({ + await upsertWithoutSequence(prisma.inheritancePoint, { where: { userId_key: { userId: reward.userId, key: 'previous' } }, update: { value: nextPoint }, create: { userId: reward.userId, key: 'previous', value: nextPoint }, }); - await prisma.rankData.upsert({ + await upsertWithoutSequence(prisma.rankData, { where: { generalId_type: { generalId: reward.generalId, @@ -1574,7 +1575,7 @@ export const createDatabaseTurnHooks = async ( } } for (const entry of grouped.values()) { - await prisma.inheritancePoint.upsert({ + await upsertWithoutSequence(prisma.inheritancePoint, { where: { userId_key: { userId: entry.userId, key: entry.key } }, update: { value: { increment: entry.amount } }, create: { userId: entry.userId, key: entry.key, value: entry.amount }, @@ -1642,7 +1643,7 @@ export const createDatabaseTurnHooks = async ( } await Promise.all( deletedNationSnapshots.map((snapshot) => - prisma.oldNation.upsert({ + upsertWithoutSequence(prisma.oldNation, { where: { serverId_nation_sourceId: { serverId, diff --git a/app/game-engine/src/turn/generalBatchPersistence.ts b/app/game-engine/src/turn/generalBatchPersistence.ts index 3b4a931f..7af4dcec 100644 --- a/app/game-engine/src/turn/generalBatchPersistence.ts +++ b/app/game-engine/src/turn/generalBatchPersistence.ts @@ -1,3 +1,4 @@ +import { existingRowIdOrNext } from '@sammo-ts/infra'; import { GamePrisma, type TurnEngineGeneralUpdateInput } from '@sammo-ts/infra'; // Fixed schema identifiers only; all row values remain bound parameters. @@ -91,8 +92,8 @@ export const persistGeneralAccessScores = async ( for (let offset = 0; offset < updates.length; offset += GENERAL_UPDATE_BATCH_SIZE) { const batch = updates.slice(offset, offset + GENERAL_UPDATE_BATCH_SIZE); await database.$executeRaw(GamePrisma.sql` - INSERT INTO "general_access_log" ("general_id", "user_id", "refresh_score_total") - VALUES ${GamePrisma.join(batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.userId}, ${row.refreshScoreTotal})`))} + INSERT INTO "general_access_log" ("id", "general_id", "user_id", "refresh_score_total") + VALUES ${GamePrisma.join(batch.map((row) => GamePrisma.sql`(${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = ${row.generalId}`)}, ${row.generalId}, ${row.userId}, ${row.refreshScoreTotal})`))} ON CONFLICT ("general_id") DO UPDATE SET "refresh_score_total" = EXCLUDED."refresh_score_total" `); diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts index 8461fa52..1caa397b 100644 --- a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { asRecord, @@ -77,7 +78,7 @@ const persistPostRetirementRankValues = async ( return; } for (const row of buildPersistedRankRows(event.after)) { - await prisma.rankData.upsert({ + await upsertWithoutSequence(prisma.rankData, { where: { generalId_type: { generalId: row.generalId, type: row.type } }, update: { nationId: row.nationId, value: row.value }, create: row, @@ -142,7 +143,7 @@ const settleInheritance = async ( ); const total = Math.trunc(previous + refund + settlement.totalEarned); - await prisma.inheritancePoint.upsert({ + await upsertWithoutSequence(prisma.inheritancePoint, { where: { userId_key: { userId, key: 'previous' } }, update: { value: total }, create: { userId, key: 'previous', value: total }, @@ -152,7 +153,7 @@ const settleInheritance = async ( ([key, value]) => key === 'max_belong' || points.has(key) || value !== 0 ); for (const [key, value] of retainedEntries) { - await prisma.inheritancePoint.upsert({ + await upsertWithoutSequence(prisma.inheritancePoint, { where: { userId_key: { userId, key } }, update: { value }, create: { userId, key, value }, @@ -367,7 +368,7 @@ const archiveGeneral = async ( records: { battleResult: battleResults }, availability: { battleResultLogs: true }, }; - await prisma.oldGeneral.upsert({ + await upsertWithoutSequence(prisma.oldGeneral, { where: { by_no: { serverId, generalNo: event.generalId } }, update: { owner: event.before.userId ?? null, diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index c8a2bb1f..5ea57e84 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra'; @@ -246,7 +247,7 @@ const applyInheritanceUser = async ( }; const setInheritancePoint = async (db: DatabaseClient, userId: string, value: number): Promise => { - await db.inheritancePoint.upsert({ + await upsertWithoutSequence(db.inheritancePoint, { where: { userId_key: { userId, key: 'previous' } }, update: { value }, create: { userId, key: 'previous', value }, @@ -860,7 +861,7 @@ export const createGeneralFromJoin = async (options: { await db.generalTurnRevision.create({ data: { generalId, revision: 0 }, }); - await db.generalAccessLog.upsert({ + await upsertWithoutSequence(db.generalAccessLog, { where: { generalId }, update: { userId: input.userId, lastRefresh: operationalAcceptedAt }, create: { diff --git a/app/game-engine/src/turn/npcPossessionService.ts b/app/game-engine/src/turn/npcPossessionService.ts index bd9b428d..865327b4 100644 --- a/app/game-engine/src/turn/npcPossessionService.ts +++ b/app/game-engine/src/turn/npcPossessionService.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { randomInt } from 'node:crypto'; import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; @@ -337,18 +338,12 @@ export const reserveNpcPossessionCandidates = async (options: { } return toReservation(inFlightToken, options.createdGameTick, ticksPerSecond); } - if ( - existing && - (existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick) - ) { + if (existing && (existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick)) { await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId, nonce: existing.nonce, - OR: [ - { validUntilTick: null }, - { validUntilTick: { lt: BigInt(options.createdGameTick) } }, - ], + OR: [{ validUntilTick: null }, { validUntilTick: { lt: BigInt(options.createdGameTick) } }], }, }); existing = null; @@ -425,9 +420,7 @@ export const reserveNpcPossessionCandidates = async (options: { const validUntilTick = options.createdGameTick + Math.round(validSeconds * ticksPerSecond); const pickMoreFromTick = options.createdGameTick + Math.round(pickMoreSeconds * ticksPerSecond); const validUntil = new Date(now.getTime() + validSeconds * 1000); - const refreshedPickMoreFrom = new Date( - now.getTime() + pickMoreSeconds * 1000 - ); + const refreshedPickMoreFrom = new Date(now.getTime() + pickMoreSeconds * 1000); const nonce = randomInt(0, 0x10000000); if (existing) { @@ -566,7 +559,7 @@ export const possessNpcGeneral = async (options: { }, }); - await db.generalAccessLog.upsert({ + await upsertWithoutSequence(db.generalAccessLog, { where: { generalId }, update: { userId, diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index a409cea3..cfddf94e 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { z } from 'zod'; import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; @@ -567,11 +568,7 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor } }; -const clearUnusedReservations = async ( - db: DatabaseClient, - userId: string, - nowTick: number -): Promise => { +const clearUnusedReservations = async (db: DatabaseClient, userId: string, nowTick: number): Promise => { await db.selectPoolEntry.updateMany({ where: { generalId: null, @@ -914,7 +911,7 @@ export const createGeneralFromSelectionPool = async (options: { if (occupied.count === 0) { throw new Error('장수 등록 중 선택 후보 점유에 실패했습니다.'); } - await db.generalAccessLog.upsert({ + await upsertWithoutSequence(db.generalAccessLog, { where: { generalId }, update: { userId, lastRefresh: options.operationalAcceptedAt }, create: { generalId, userId, lastRefresh: options.operationalAcceptedAt }, diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 6f51db89..ae335cc8 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; @@ -209,12 +210,12 @@ const cancelPendingUniqueAuctions = async ( if (!bidder?.userId || bidder.userId !== dbBidder?.userId) { throw new Error(`Unification auction refund owner is unavailable: ${planned.auctionId}.`); } - await transaction.inheritancePoint.upsert({ + await upsertWithoutSequence(transaction.inheritancePoint, { where: { userId_key: { userId: bidder.userId, key: 'previous' } }, update: { value: { increment: highest.amount } }, create: { userId: bidder.userId, key: 'previous', value: highest.amount }, }); - await transaction.rankData.upsert({ + await upsertWithoutSequence(transaction.rankData, { where: { generalId_type: { generalId: bidder.id, type: 'inherit_spent_dyn' }, }, @@ -345,7 +346,7 @@ export const persistUnificationFinalization = async ( ) as Record<(typeof ALL_MERGED_INHERITANCE_KEYS)[number], number>; const total = Math.floor(previous + Object.values(mergedPoints).reduce((sum, value) => sum + value, 0)); - await transaction.inheritancePoint.upsert({ + await upsertWithoutSequence(transaction.inheritancePoint, { where: { userId_key: { userId, key: 'previous' } }, update: { value: total }, create: { userId, key: 'previous', value: total }, @@ -508,7 +509,7 @@ export const persistUnificationFinalization = async ( }), generationKey: input.generationKey, }; - await transaction.oldNation.upsert({ + await upsertWithoutSequence(transaction.oldNation, { where: { serverId_nation_sourceId: { serverId, nation: input.winnerNationId, sourceId: 0 } }, update: { data: asJson(winnerData), date: input.completedAt }, create: { @@ -525,7 +526,7 @@ export const persistUnificationFinalization = async ( generals: neutralGenerals.map((general) => general.id), generationKey: input.generationKey, }; - await transaction.oldNation.upsert({ + await upsertWithoutSequence(transaction.oldNation, { where: { serverId_nation_sourceId: { serverId, nation: 0, sourceId: 0 } }, update: { data: neutralData, date: input.completedAt }, create: { serverId, nation: 0, sourceId: 0, data: neutralData, date: input.completedAt }, @@ -572,7 +573,7 @@ export const persistUnificationFinalization = async ( availability: { battleResultLogs: true }, generationKey: input.generationKey, }; - await transaction.oldGeneral.upsert({ + await upsertWithoutSequence(transaction.oldGeneral, { where: { by_no: { serverId, generalNo: general.id } }, update: { owner: general.userId ?? null, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 20e61e6a..7778e46d 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -1,3 +1,4 @@ +import { existingRowIdOrNext } from '@sammo-ts/infra'; import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js'; import type { TurnDaemonCommandHandler, @@ -2864,8 +2865,8 @@ const insertVoteSelection = async ( } const createdAt = new Date(); const rows = await ctx.commandDb.$queryRaw>(GamePrisma.sql` - INSERT INTO vote (vote_id, general_id, nation_id, selection, created_at) - SELECT poll.id, + INSERT INTO vote (id, vote_id, general_id, nation_id, selection, created_at) + SELECT ${existingRowIdOrNext('vote', GamePrisma.sql`stable_id.vote_id = poll.id AND stable_id.general_id = ${general.id}`)}, poll.id, ${general.id}, ${general.nationId}, CAST(${JSON.stringify(selection)} AS jsonb), diff --git a/app/game-engine/src/turn/yearbookPersistence.ts b/app/game-engine/src/turn/yearbookPersistence.ts index 71e8a9ed..8a3795a9 100644 --- a/app/game-engine/src/turn/yearbookPersistence.ts +++ b/app/game-engine/src/turn/yearbookPersistence.ts @@ -1,3 +1,4 @@ +import { upsertWithoutSequence } from '@sammo-ts/infra'; import { createHash } from 'node:crypto'; import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; @@ -44,7 +45,7 @@ export const persistYearbookSnapshot = async ( globalAction, }); - await transaction.yearbookHistory.upsert({ + await upsertWithoutSequence(transaction.yearbookHistory, { where: { profileName_year_month_sourceId: { profileName: snapshot.serverId, diff --git a/app/game-engine/test/generalBatchPersistence.integration.test.ts b/app/game-engine/test/generalBatchPersistence.integration.test.ts index 7842f92c..41403199 100644 --- a/app/game-engine/test/generalBatchPersistence.integration.test.ts +++ b/app/game-engine/test/generalBatchPersistence.integration.test.ts @@ -198,6 +198,23 @@ integration('general batch persistence', () => { ); }); expect(writes).toBe(2); + const originalIds = ( + await db.generalAccessLog.findMany({ + where: { generalId: { gte: firstId, lte: firstId + GENERAL_UPDATE_BATCH_SIZE } }, + orderBy: { generalId: 'asc' }, + }) + ).map((row) => row.id); + const sequence = await db.$queryRaw`SELECT last_value FROM general_access_log_id_seq`; + await db.$transaction((tx) => persistGeneralAccessScores(tx, updates)); + expect( + ( + await db.generalAccessLog.findMany({ + where: { generalId: { gte: firstId, lte: firstId + GENERAL_UPDATE_BATCH_SIZE } }, + orderBy: { generalId: 'asc' }, + }) + ).map((row) => row.id) + ).toEqual(originalIds); + expect(await db.$queryRaw`SELECT last_value FROM general_access_log_id_seq`).toEqual(sequence); expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: firstId } })).toMatchObject({ userId: 'original-owner', lastRefresh: at, diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts index a7ad2ff0..5f1e9e6b 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts @@ -1,12 +1,20 @@ import { describe, expect, it, vi } from 'vitest'; -import type { GamePrisma } from '@sammo-ts/infra'; +import { GamePrisma } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/logic'; import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js'; import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js'; import type { TurnGeneral } from '../src/turn/types.js'; +// This harness begins without the durable archive/point row. +const missingRecordUpdate = async (): Promise => { + throw new GamePrisma.PrismaClientKnownRequestError('fixture record missing', { + code: 'P2025', + clientVersion: 'fixture', + }); +}; + const archivedGeneral = (): TurnGeneral => ({ id: 91, userId: null, @@ -65,12 +73,13 @@ describe('general lifecycle archive history', () => { ]), }, rankData: { + update: missingRecordUpdate, findMany: vi.fn(async () => [ { type: 'warnum', value: 2 }, { type: 'killnum', value: 1 }, ]), }, - oldGeneral: { upsert }, + oldGeneral: { update: missingRecordUpdate, upsert }, } as unknown as GamePrisma.TransactionClient; const event: GeneralLifecycleEvent = { generalId: general.id, @@ -137,6 +146,7 @@ describe('general lifecycle archive history', () => { updateMany: vi.fn(async () => ({ count: 1 })), }, rankData: { + update: missingRecordUpdate, findMany: vi.fn(async () => [ { type: 'warnum', value: 10 }, { type: 'inherit_earned', value: 123 }, @@ -156,6 +166,7 @@ describe('general lifecycle archive history', () => { update: vi.fn(async () => undefined), }, inheritancePoint: { + update: missingRecordUpdate, findMany: vi.fn(async () => [{ key: 'previous', value: 0 }]), upsert: vi.fn(async () => undefined), deleteMany: vi.fn(async () => ({ count: 0 })), diff --git a/app/game-engine/test/rankDataPersistence.integration.test.ts b/app/game-engine/test/rankDataPersistence.integration.test.ts index 195c4ae8..361d35c2 100644 --- a/app/game-engine/test/rankDataPersistence.integration.test.ts +++ b/app/game-engine/test/rankDataPersistence.integration.test.ts @@ -68,8 +68,6 @@ describe.skipIf(!databaseUrl)('rank data bigint PostgreSQL persistence', () => { it('reloads bigint IDs for high churn queues, logs, upserts and the traffic foreign key', async () => { const tables = [ - 'general_turn', - 'nation_turn', 'general_access_log', 'diplomacy', 'inheritance_point', @@ -93,10 +91,6 @@ describe.skipIf(!databaseUrl)('rank data bigint PostgreSQL persistence', () => { }, }); const rows = await Promise.all([ - tx.generalTurn.create({ data: { generalId, turnIdx: 0, actionCode: 'fixture' } }), - tx.nationTurn.create({ - data: { nationId: 3, officerLevel: 12, turnIdx: 0, actionCode: 'fixture' }, - }), tx.generalAccessLog.create({ data: { generalId } }), tx.diplomacy.create({ data: { srcNationId: 3, destNationId: 4, stateCode: 0 } }), tx.inheritancePoint.create({ data: { userId: 'bigint-fixture', key: 'previous', value: 123 } }), @@ -109,6 +103,14 @@ describe.skipIf(!databaseUrl)('rank data bigint PostgreSQL persistence', () => { }), ]); expect(rows.every((row) => row.id > 2_147_483_647n)).toBe(true); + const generalQueue = await tx.generalTurn.create({ + data: { generalId, turnIdx: 0, actionCode: 'fixture' }, + }); + const nationQueue = await tx.nationTurn.create({ + data: { nationId: 3, officerLevel: 12, turnIdx: 0, actionCode: 'fixture' }, + }); + expect(generalQueue).not.toHaveProperty('id'); + expect(nationQueue).not.toHaveProperty('id'); const period = await tx.trafficPeriod.create({ data: { worldStateId: world.id, @@ -122,7 +124,7 @@ describe.skipIf(!databaseUrl)('rank data bigint PostgreSQL persistence', () => { }); expect(period.id).toBeGreaterThan(2_147_483_647n); expect(period.generals[0]?.periodId).toBe(period.id); - expect((await tx.logEntry.findUniqueOrThrow({ where: { id: rows[5]!.id } })).text).toBe('fixture'); + expect((await tx.logEntry.findUniqueOrThrow({ where: { id: rows[3]!.id } })).text).toBe('fixture'); throw new Error('high churn fixture rollback'); }) ).rejects.toThrow('high churn fixture rollback'); diff --git a/app/game-engine/test/unificationPersistence.test.ts b/app/game-engine/test/unificationPersistence.test.ts index b1e19111..47698214 100644 --- a/app/game-engine/test/unificationPersistence.test.ts +++ b/app/game-engine/test/unificationPersistence.test.ts @@ -1,13 +1,21 @@ import { describe, expect, it, vi } from 'vitest'; import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; -import type { GamePrisma } from '@sammo-ts/infra'; +import { GamePrisma } from '@sammo-ts/infra'; import { LogCategory, LogScope, type City, type Nation } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { persistUnificationFinalization, resolveStoredInheritancePoint } from '../src/turn/unificationPersistence.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +// This harness begins without the durable archive/point row. +const missingRecordUpdate = async (): Promise => { + throw new GamePrisma.PrismaClientKnownRequestError('fixture record missing', { + code: 'P2025', + clientVersion: 'fixture', + }); +}; + const buildWorld = (): InMemoryTurnWorld => { const turnTime = new Date('0190-07-01T00:00:00.000Z'); const general: TurnGeneral = { @@ -206,6 +214,7 @@ describe('persistUnificationFinalization', () => { create: vi.fn().mockResolvedValue({}), }, inheritancePoint: { + update: missingRecordUpdate, findMany: vi.fn().mockResolvedValue([ { userId: 'user-1', key: 'previous', value: 100 }, { userId: 'user-1', key: 'unifier', value: 7 }, @@ -216,6 +225,7 @@ describe('persistUnificationFinalization', () => { inheritanceResult: { create: inheritanceResultCreate }, inheritanceLog: { create: inheritanceLogCreate }, rankData: { + update: missingRecordUpdate, findMany: vi.fn().mockResolvedValue([ { generalId: 1, type: 'warnum', value: 15 }, { generalId: 1, type: 'killnum', value: 9 }, @@ -251,10 +261,11 @@ describe('persistUnificationFinalization', () => { ), }, oldNation: { + update: missingRecordUpdate, upsert: oldNationUpsert, findMany: vi.fn().mockResolvedValue([]), }, - oldGeneral: { upsert: oldGeneralUpsert }, + oldGeneral: { update: missingRecordUpdate, upsert: oldGeneralUpsert }, emperor: { create: emperorCreate }, }); await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({ diff --git a/app/game-engine/test/yearbookPersistence.test.ts b/app/game-engine/test/yearbookPersistence.test.ts index f30104a6..1140f7b2 100644 --- a/app/game-engine/test/yearbookPersistence.test.ts +++ b/app/game-engine/test/yearbookPersistence.test.ts @@ -1,10 +1,18 @@ import { describe, expect, it, vi } from 'vitest'; -import type { GamePrisma } from '@sammo-ts/infra'; +import { GamePrisma } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/logic'; import { persistYearbookSnapshot } from '../src/turn/yearbookPersistence.js'; +// This harness begins without the durable archive/point row. +const missingRecordUpdate = async (): Promise => { + throw new GamePrisma.PrismaClientKnownRequestError('fixture record missing', { + code: 'P2025', + clientVersion: 'fixture', + }); +}; + describe('persistYearbookSnapshot', () => { it('archives canonical summary logs and compatible action logs together in descending ID order', async () => { const findMany = vi @@ -14,7 +22,7 @@ describe('persistYearbookSnapshot', () => { const upsert = vi.fn().mockResolvedValue(undefined); const transaction = { logEntry: { findMany }, - yearbookHistory: { upsert }, + yearbookHistory: { update: missingRecordUpdate, upsert }, } as unknown as GamePrisma.TransactionClient; await persistYearbookSnapshot(transaction, { diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 8177dc28..856d3468 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20261107000000_kakao_identity_link_history', - gameSchemaHead: '20261114010000_high_churn_bigint_ids', + gameSchemaHead: '20261114020000_reserved_turn_slot_primary_keys', }); }); diff --git a/docs/developer/request-turn-persistence.md b/docs/developer/request-turn-persistence.md index c272b58e..99c141a9 100644 --- a/docs/developer/request-turn-persistence.md +++ b/docs/developer/request-turn-persistence.md @@ -77,6 +77,24 @@ Lease를 잃은 process는 fencing 검사에서 commit하지 못합니다. `InMemoryTurnWorld`에서 읽습니다. 따라서 flush 실패 뒤 다음 run도 복원된 checkpoint에서 시작합니다. +## 내부 ID와 처리 순서 + +예약턴의 순서는 `turnIdx`, 변경 감지는 revision, 실행 권한은 lease/fencing으로 +판정합니다. `GeneralTurn`은 `(generalId, turnIdx)`, `NationTurn`은 +`(nationId, officerLevel, turnIdx)` 기본키를 사용합니다. 기존 transaction 안의 +삭제·재삽입 순서를 유지하면서 사용하지 않는 자동 ID/sequence를 제거했습니다. + +접속 기록·현재 월 통계·랭킹·유산 포인트·베팅·보존 기록/연감은 갱신 시 기존 ID를 +유지하고 신규 업무 키에만 ID를 발급합니다. Prisma 갱신은 +`packages/infra/src/stableRowId.ts`의 `upsertWithoutSequence()`가 먼저 UPDATE하고, +미존재 결과인 P2025에서만 원래 upsert로 생성합니다. 제약 위반·다른 오류는 그대로 +전파하고, 새 키의 동시 생성 경합은 ON CONFLICT가 처리합니다. raw SQL은 같은 +module의 `existingRowIdOrNext()`로 기존 ID를 재사용합니다. + +`input_event.sequence`, 외교 감사 sequence, outbox/log ID는 실행 순서·cursor· +claim/완료 식별자이므로 자동 증가를 유지합니다. 새 행 생성의 rollback이나 동시 +생성 경합에서 생기는 sequence gap은 정상이며 번호를 되감지 않습니다. + ## 저장 위치 | 상태 | 기준 | diff --git a/docs/release-operations.md b/docs/release-operations.md index 27f04930..9a566ae1 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -116,8 +116,11 @@ server build는 기존 Turbo cache를 재사용할 수 있지만, 자원 보호 profile frontend release task의 `NODE_OPTIONS`만 덮어쓰며 server package Turbo build와 배포 후 PM2 process의 heap은 바꾸지 않습니다. 전용 heap을 늘릴 때는 runtime container hard limit과 전체 process RSS를 먼저 확인합니다. -- migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이 - 유지됩니다. +- migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성을 + 버전별로 확인합니다. `20261114020000_reserved_turn_slot_primary_keys`는 미사용 + 예약턴 ID/sequence를 제거하므로 구형 Prisma client로 단독 rollback할 수 없습니다. + Profile의 모든 구형 game process를 정지한 뒤 matching client로 전환하며, + 복구는 forward fix 또는 검증한 사전 DB backup의 restore 경로를 사용합니다. ## Commit worktree 자동 정리 diff --git a/docs/testing-policy.md b/docs/testing-policy.md index 1a7adac7..08f3b997 100644 --- a/docs/testing-policy.md +++ b/docs/testing-policy.md @@ -219,6 +219,21 @@ no-op, unique/FK/index, runtime query와 reload를 확인합니다. 기존 migra Prisma reload, 유일성 및 transaction rollback을 검증합니다. `log_entry`·`inheritance_log`의 API 숫자 cursor는 `Number.MAX_SAFE_INTEGER`까지 정확성을 보장하며 그 이상은 명시적으로 거부합니다. +### 예약턴 복합키와 일반 갱신 ID 회귀 + +`RESERVED_TURN_MIGRATION_TEST_DATABASE_URL`을 격리 개발 DB에 주입해 +`pnpm --filter @sammo-ts/infra verify:migration:reserved-turn-keys`를 실행합니다. +기존 기수의 명령·인자·시간·revision·lease 보존, 자연키 유일성, queue 교체 rollback, +sequence 제거, 새 schema 전체 migration과 deploy 재실행을 확인합니다. +이전 `verify:migration:rank-data`는 64비트 확장 당시의 migration head까지만 +검사합니다. 이후 예약턴 ID를 제거한 현재 전체 chain은 새 검증기가 소유합니다. + +`stableRowId.integration.test.ts`는 `IMMEDIATE_ACTION_DATABASE_URL` 전용 schema에서 +실제 Prisma의 기존 ID 보존, 원자적 increment, 신규 키 동시 생성, 미존재 fallback의 +transaction 생존, 제약 위반 전파와 rollback을 검사합니다. 접속량/지연 접속량 및 +batch 저장 test는 같은 행을 갱신할 때 sequence가 증가하지 않는지도 확인합니다. +예약 API의 실제 HTTP 권한 행렬과 daemon/API lease 통합 검증도 함께 실행합니다. + ## 결과 기록 Report에는 실행 명령, commit, 서비스와 fixture, pass/fail/skip 수, baseline diff --git a/packages/infra/package.json b/packages/infra/package.json index e924b8ef..c1c7032f 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -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", diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 6e4db81f..89b24fe0 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -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") } diff --git a/packages/infra/prisma/migrations/20261114020000_reserved_turn_slot_primary_keys/migration.sql b/packages/infra/prisma/migrations/20261114020000_reserved_turn_slot_primary_keys/migration.sql new file mode 100644 index 00000000..2c401c88 --- /dev/null +++ b/packages/infra/prisma/migrations/20261114020000_reserved_turn_slot_primary_keys/migration.sql @@ -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; diff --git a/packages/infra/scripts/verify-rank-data-bigint-migration.mjs b/packages/infra/scripts/verify-rank-data-bigint-migration.mjs index b8d89667..807273d7 100644 --- a/packages/infra/scripts/verify-rank-data-bigint-migration.mjs +++ b/packages/infra/scripts/verify-rank-data-bigint-migration.mjs @@ -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, diff --git a/packages/infra/scripts/verify-reserved-turn-key-migration.mjs b/packages/infra/scripts/verify-reserved-turn-key-migration.mjs new file mode 100644 index 00000000..997f691d --- /dev/null +++ b/packages/infra/scripts/verify-reserved-turn-key-migration.mjs @@ -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 }); +} diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index ea155cb0..bb3da75c 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -16,3 +16,4 @@ export * from './messageEnvelope.js'; export * from './webPushOutbox.js'; export * from './playAuditBestEffort.js'; export * from './playAuditDecisionCodec.js'; +export * from './stableRowId.js'; diff --git a/packages/infra/src/stableRowId.ts b/packages/infra/src/stableRowId.ts new file mode 100644 index 00000000..60e3e750 --- /dev/null +++ b/packages/infra/src/stableRowId.ts @@ -0,0 +1,29 @@ +import { GamePrisma } from './gamePrisma.js'; + +/** Internal natural-key records retain their identity across ordinary updates. */ +export const upsertWithoutSequence = async ( + delegate: { + update(args: { where: Where; data: Update }): PromiseLike; + upsert(args: { where: Where; update: Update; create: Create }): PromiseLike; + }, + args: { where: Where; update: Update; create: Create } +): Promise => { + 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')) + )`; diff --git a/packages/infra/test/stableRowId.integration.test.ts b/packages/infra/test/stableRowId.integration.test.ts new file mode 100644 index 00000000..b2571059 --- /dev/null +++ b/packages/infra/test/stableRowId.integration.test.ts @@ -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; + const sequence = async (table: 'rank_data' | 'inheritance_point'): Promise => + ( + 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); + }); +}); diff --git a/release-manifest.json b/release-manifest.json index d1dea23e..4fff5fb1 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20261107000000_kakao_identity_link_history", - "gameSchemaHead": "20261114010000_high_churn_bigint_ids", + "gameSchemaHead": "20261114020000_reserved_turn_slot_primary_keys", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }