From bc54b157c16ce63902b2ebd5c627774253d2b48f Mon Sep 17 00:00:00 2001 From: hide_d Date: Sat, 14 Nov 2026 15:34:21 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=A0=EB=B9=88=EB=8F=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20ID=EB=A5=BC=2064=EB=B9=84=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=ED=95=98=EA=B3=A0=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=EC=9D=98=20=EC=8B=9C=ED=80=80=EC=8A=A4=20?= =?UTF-8?q?=EB=82=AD=EB=B9=84=EB=A5=BC=20=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/auction/index.ts | 18 +- app/game-api/src/router/general/index.ts | 15 +- app/game-api/src/router/inherit/index.ts | 6 +- .../router/nation/endpoints/getGeneralLog.ts | 3 +- .../router/nation/endpoints/getNationInfo.ts | 3 +- app/game-api/src/router/playAudit/logs.ts | 5 +- app/game-api/src/router/public/index.ts | 4 +- app/game-api/src/services/generalAccess.ts | 2 +- app/game-api/test/inheritRouter.test.ts | 27 +-- app/game-api/test/mainRecordsRouter.test.ts | 18 +- .../test/publicCachedMapHistory.test.ts | 4 +- app/game-api/test/reservedTurns.test.ts | 51 +++--- app/game-api/test/router.test.ts | 8 +- app/game-engine/src/turn/databaseHooks.ts | 25 ++- .../rankDataPersistence.integration.test.ts | 145 +++++++++++++++ app/gateway-api/test/releaseManifest.test.ts | 2 +- docs/testing-policy.md | 11 ++ packages/common/src/util/parse.ts | 14 ++ packages/common/test/safeInteger.test.ts | 20 +++ packages/infra/package.json | 1 + packages/infra/prisma/game.prisma | 22 +-- .../migration.sql | 7 + .../migration.sql | 23 +++ .../verify-rank-data-bigint-migration.mjs | 168 ++++++++++++++++++ release-manifest.json | 2 +- 25 files changed, 518 insertions(+), 86 deletions(-) create mode 100644 app/game-engine/test/rankDataPersistence.integration.test.ts create mode 100644 packages/common/test/safeInteger.test.ts create mode 100644 packages/infra/prisma/migrations/20261114000000_rank_data_bigint_id/migration.sql create mode 100644 packages/infra/prisma/migrations/20261114010000_high_churn_bigint_ids/migration.sql create mode 100644 packages/infra/scripts/verify-rank-data-bigint-migration.mjs diff --git a/app/game-api/src/router/auction/index.ts b/app/game-api/src/router/auction/index.ts index 8be7e4bc..04b39d06 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -1,3 +1,4 @@ +import { toSafeInteger } from '@sammo-ts/common'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; @@ -281,7 +282,7 @@ export const auctionRouter = router({ callerAlias, remainPoint: point?.value ?? 0, recentLogs: recentLogs.map((log) => ({ - id: log.id, + id: toSafeInteger(log.id), text: log.text, createdAt: log.createdAt.toISOString(), })), @@ -444,7 +445,10 @@ export const auctionRouter = router({ const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const nextCloseAt = new Date(result.closeAt); await ctx.redis.zAdd(timerKeys.timerKey, [ - { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) }, + { + score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), + value: String(auction.id), + }, ]); return { ok: true }; @@ -521,7 +525,10 @@ export const auctionRouter = router({ const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const nextCloseAt = new Date(result.closeAt); await ctx.redis.zAdd(timerKeys.timerKey, [ - { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) }, + { + score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), + value: String(auction.id), + }, ]); return { ok: true }; @@ -659,7 +666,10 @@ export const auctionRouter = router({ const timerKeys = buildAuctionTimerKeys(ctx.profile.name); const nextCloseAt = new Date(result.closeAt); await ctx.redis.zAdd(timerKeys.timerKey, [ - { score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) }, + { + score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), + value: String(auction.id), + }, ]); return { ok: true }; diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index b62e31e6..8e4408ff 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic'; -import { asRecord, readOfficeRequest, type RankDataType } from '@sammo-ts/common'; +import { asRecord, readOfficeRequest, toSafeInteger, withSafeIntegerId, type RankDataType } from '@sammo-ts/common'; import type { GameApiContext } from '../../context.js'; import { @@ -588,10 +588,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { recentWar: general.recentWarTime?.toISOString() ?? null, defenceTrain: settings.defence_train, killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), - remainingMinutes: resolveRemainingMinutes( - general.turnTime, - gameTime.now - ), + remainingMinutes: resolveRemainingMinutes(general.turnTime, gameTime.now), crewTypeId: general.crewTypeId, crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-', crewTypeInfo: crewTypeDetails.get(general.crewTypeId) ?? null, @@ -916,7 +913,7 @@ export const generalRouter = router({ return { type: input.type, logs: logs.map((entry) => ({ - id: entry.id, + id: toSafeInteger(entry.id), text: entry.text, })), }; @@ -966,9 +963,9 @@ export const generalRouter = router({ ]); return { - global: trimRecentRecords(global, input.lastGeneralRecordId), - general: trimRecentRecords(general, input.lastGeneralRecordId), - history: trimRecentRecords(history, input.lastWorldHistoryId), + global: trimRecentRecords(global.map(withSafeIntegerId), input.lastGeneralRecordId), + general: trimRecentRecords(general.map(withSafeIntegerId), input.lastGeneralRecordId), + history: trimRecentRecords(history.map(withSafeIntegerId), input.lastWorldHistoryId), }; }), // 메인 화면은 SSE invalidation, 탭 복귀와 직접 갱신이 같은 read model을 diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 30a0d5da..c7c463a8 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -1,3 +1,4 @@ +import { withSafeIntegerId } from '@sammo-ts/common'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; @@ -38,7 +39,6 @@ const BUFF_KEYS: InheritBuffType[] = [ const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item']; -const POSTGRES_INTEGER_MAX = 2_147_483_647; const MIN_INHERIT_BUFF_LEVEL = 1; const MAX_INHERIT_BUFF_LEVEL = 5; @@ -283,7 +283,7 @@ export const inheritRouter = router({ getLogs: authedProcedure .input( z.object({ - lastId: z.number().int().min(1).max(POSTGRES_INTEGER_MAX).optional(), + lastId: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional(), }) ) .query(async ({ ctx, input }) => { @@ -300,7 +300,7 @@ export const inheritRouter = router({ take: 30, select: { id: true, year: true, month: true, text: true, createdAt: true }, }); - return logs; + return logs.map(withSafeIntegerId); }), buyHiddenBuff: engineAuthedProcedure .input( diff --git a/app/game-api/src/router/nation/endpoints/getGeneralLog.ts b/app/game-api/src/router/nation/endpoints/getGeneralLog.ts index bc5cf9b9..88c3d96c 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralLog.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralLog.ts @@ -1,3 +1,4 @@ +import { toSafeInteger } from '@sammo-ts/common'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; @@ -77,7 +78,7 @@ export const getGeneralLog = accessLimitAuthedInputProcedure( type: input.type, generalId: target.id, logs: logs.map((entry) => ({ - id: entry.id, + id: toSafeInteger(entry.id), text: entry.text, year: entry.year, month: entry.month, diff --git a/app/game-api/src/router/nation/endpoints/getNationInfo.ts b/app/game-api/src/router/nation/endpoints/getNationInfo.ts index 06489439..f38244bf 100644 --- a/app/game-api/src/router/nation/endpoints/getNationInfo.ts +++ b/app/game-api/src/router/nation/endpoints/getNationInfo.ts @@ -1,3 +1,4 @@ +import { withSafeIntegerId } from '@sammo-ts/common'; import { TRPCError } from '@trpc/server'; import { asRecord } from '@sammo-ts/common'; @@ -129,6 +130,6 @@ export const getNationInfo = authedProcedure.query(async ({ ctx }) => { name: city.name, capital: city.id === nation.capitalCityId, })), - history, + history: history.map(withSafeIntegerId), }; }); diff --git a/app/game-api/src/router/playAudit/logs.ts b/app/game-api/src/router/playAudit/logs.ts index 23065132..33769ef1 100644 --- a/app/game-api/src/router/playAudit/logs.ts +++ b/app/game-api/src/router/playAudit/logs.ts @@ -1,3 +1,4 @@ +import { toSafeInteger, withSafeIntegerId } from '@sammo-ts/common'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { auditProcedure, monthOrdinal, readAudit, readAuditWorld } from './shared.js'; @@ -53,8 +54,8 @@ export const generalLogs = auditProcedure ...world, type: input.type, coverage: world.serverId ? ('IDENTIFIED_LOGS_ONLY' as const) : ('IDENTITY_MISSING' as const), - items: rows.slice(0, input.limit), - nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.id : null, + items: rows.slice(0, input.limit).map(withSafeIntegerId), + nextCursor: rows.length > input.limit ? toSafeInteger(rows[input.limit - 1]!.id) : null, }; }) ); diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index 31728fcd..5d65cde1 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -1,5 +1,5 @@ import { TRPCError } from '@trpc/server'; -import { asNumber, asRecord } from '@sammo-ts/common'; +import { asNumber, asRecord, withSafeIntegerId } from '@sammo-ts/common'; import { LEGACY_DEFAULT_MAX_LEVEL, LogCategory, LogScope } from '@sammo-ts/logic'; import { z } from 'zod'; @@ -306,7 +306,7 @@ export const publicRouter = router({ } const snapshot = { ...map, - history, + history: history.map(withSafeIntegerId), }; await ctx.redis.set(cacheKey, JSON.stringify(snapshot), { EX: PUBLIC_CACHE_TTL_SECONDS }); return snapshot; diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index 82726c6c..9eec67de 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -230,7 +230,7 @@ export const upsertGeneralAccess = async ( await db.$transaction(async (transaction) => { await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK); const periodKey = input.year * 12 + input.month - 1; - const periodRows = await transaction.$queryRaw>( + const periodRows = await transaction.$queryRaw>( GamePrisma.sql` WITH latest_period AS ( SELECT MAX(year * 12 + month - 1)::INTEGER AS period_key diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index 99bf3a11..6e2ee388 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -108,7 +108,7 @@ const buildContext = (options: { inheritancePoint?: number; inheritanceRows?: Array<{ key: string; value: number }>; rankRows?: Array<{ type: string; value: number }>; - inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>; + inheritanceLogs?: Array<{ id: number | bigint; year: number; month: number; text: string; createdAt: Date }>; configConst?: Record; scenarioCode?: string; configMap?: Record; @@ -460,11 +460,11 @@ describe('inherit router actor and permission boundaries', () => { it('loads the first inheritance-log page without an out-of-range integer cursor', async () => { const createdAt = new Date('2026-07-26T00:00:00Z'); const fixture = buildContext({ - inheritanceLogs: [{ id: 2_147_483_647, year: 200, month: 4, text: '경계 로그', createdAt }], + inheritanceLogs: [{ id: 2_147_483_648n, year: 200, month: 4, text: '경계 로그', createdAt }], }); await expect(appRouter.createCaller(fixture.context).inherit.getLogs({})).resolves.toEqual([ - { id: 2_147_483_647, year: 200, month: 4, text: '경계 로그', createdAt }, + { id: 2_147_483_648, year: 200, month: 4, text: '경계 로그', createdAt }, ]); expect(fixture.inheritanceLogFindMany).toHaveBeenCalledWith({ where: { userId: 'user-1' }, @@ -478,23 +478,26 @@ describe('inherit router actor and permission boundaries', () => { const fixture = buildContext({ inheritanceLogs: [] }); const caller = appRouter.createCaller(fixture.context); - await expect(caller.inherit.getLogs({ lastId: 2_147_483_647 })).resolves.toEqual([]); + await expect(caller.inherit.getLogs({ lastId: 2_147_483_648 })).resolves.toEqual([]); expect(fixture.inheritanceLogFindMany).toHaveBeenCalledWith({ - where: { userId: 'user-1', id: { lt: 2_147_483_647 } }, + where: { userId: 'user-1', id: { lt: 2_147_483_648 } }, orderBy: { id: 'desc' }, take: 30, select: { id: true, year: true, month: true, text: true, createdAt: true }, }); }); - it.each([0, -1, 1.5, 2_147_483_648])('rejects an invalid inheritance-log cursor: %s', async (lastId) => { - const fixture = buildContext({}); + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects an invalid inheritance-log cursor: %s', + async (lastId) => { + const fixture = buildContext({}); - await expect(appRouter.createCaller(fixture.context).inherit.getLogs({ lastId })).rejects.toMatchObject({ - code: 'BAD_REQUEST', - }); - expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled(); - }); + await expect(appRouter.createCaller(fixture.context).inherit.getLogs({ lastId })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled(); + } + ); it('delegates the authenticated actor and maps a missing-general daemon rejection', async () => { const fixture = buildContext({ diff --git a/app/game-api/test/mainRecordsRouter.test.ts b/app/game-api/test/mainRecordsRouter.test.ts index 8e235ae0..456923c2 100644 --- a/app/game-api/test/mainRecordsRouter.test.ts +++ b/app/game-api/test/mainRecordsRouter.test.ts @@ -33,7 +33,7 @@ type LogQuery = { select: { id: true; text: true; createdAt: true }; }; -const buildContext = (findMany: (query: LogQuery) => Promise>) => +const buildContext = (findMany: (query: LogQuery) => Promise>) => ({ auth, profile: { id: 'che', name: 'che:default', scenario: 'default' }, @@ -94,6 +94,22 @@ describe('general.getRecentRecords', () => { }); }); + it('returns exact numeric JSON cursors above int4 and trims a bigint cursor row', async () => { + const findMany = vi.fn(async () => [ + { id: 2_147_483_650n, text: '최신' }, + { id: 2_147_483_648n, text: '이전 cursor' }, + ]); + const caller = appRouter.createCaller(buildContext(findMany)); + const result = await caller.general.getRecentRecords({ + lastGeneralRecordId: 2_147_483_648, + lastWorldHistoryId: 2_147_483_648, + }); + expect(result.global).toEqual([{ id: 2_147_483_650, text: '최신' }]); + expect(result.general).toEqual(result.global); + expect(result.history).toEqual(result.global); + expect(() => JSON.stringify(result)).not.toThrow(); + }); + it('caps an initial bucket at the legacy 15-row limit', async () => { const rows = Array.from({ length: 16 }, (_, index) => ({ id: 100 - index, diff --git a/app/game-api/test/publicCachedMapHistory.test.ts b/app/game-api/test/publicCachedMapHistory.test.ts index bd69c2a9..26888902 100644 --- a/app/game-api/test/publicCachedMapHistory.test.ts +++ b/app/game-api/test/publicCachedMapHistory.test.ts @@ -38,8 +38,8 @@ const buildContext = () => { }, logEntry: { findMany: vi.fn(async () => [ - { id: 9, text: '최근 정세' }, - { id: 8, text: '이전 정세' }, + { id: 9n, text: '최근 정세' }, + { id: 8n, text: '이전 정세' }, ]), }, $queryRaw: vi diff --git a/app/game-api/test/reservedTurns.test.ts b/app/game-api/test/reservedTurns.test.ts index 833d2b33..d12888b6 100644 --- a/app/game-api/test/reservedTurns.test.ts +++ b/app/game-api/test/reservedTurns.test.ts @@ -70,7 +70,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: index + 1, + id: BigInt(index + 1), generalId: row.generalId as number, turnIdx: row.turnIdx as number, actionCode: row.actionCode as string, @@ -137,7 +137,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: index + 1, + id: BigInt(index + 1), nationId: row.nationId as number, officerLevel: row.officerLevel as number, turnIdx: row.turnIdx as number, @@ -203,26 +203,29 @@ const buildDb = (autorunLimit: number | null = null) => { describe('reservedTurns', () => { it('loads multiple nation officer queues with two batched queries and preserves defaults', async () => { - const findTurns = vi.fn(async () => [ - { - id: 1, - nationId: 4, - officerLevel: 12, - turnIdx: 1, - actionCode: 'che_징병', - arg: { amount: 100 }, - createdAt: new Date(), - }, - { - id: 2, - nationId: 4, - officerLevel: 10, - turnIdx: 0, - actionCode: 'che_훈련', - arg: {}, - createdAt: new Date(), - }, - ] satisfies NationTurnRow[]); + const findTurns = vi.fn( + async () => + [ + { + id: 1n, + nationId: 4, + officerLevel: 12, + turnIdx: 1, + actionCode: 'che_징병', + arg: { amount: 100 }, + createdAt: new Date(), + }, + { + id: 2n, + nationId: 4, + officerLevel: 10, + turnIdx: 0, + actionCode: 'che_훈련', + arg: {}, + createdAt: new Date(), + }, + ] satisfies NationTurnRow[] + ); const findRevisions = vi.fn(async () => [ { nationId: 4, officerLevel: 12, revision: 7, updatedAt: new Date() }, ]); @@ -411,7 +414,7 @@ describe('reservedTurns', () => { nationRevisions.set('6:12', 2); nationTurns.set('6:12', [ { - id: 1, + id: 1n, nationId: 6, officerLevel: 12, turnIdx: 0, @@ -420,7 +423,7 @@ describe('reservedTurns', () => { createdAt: new Date(), }, { - id: 2, + 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 3096da40..fc64ebcf 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -901,7 +901,7 @@ describe('appRouter', () => { const general = buildGeneralRow({ id: 11, meta: { autorun_limit: 2408 } }); const generalTurns: GeneralTurnRow[] = [ { - id: 1, + id: 1n, generalId: 11, turnIdx: 0, actionCode: 'che_화계', @@ -922,7 +922,7 @@ describe('appRouter', () => { const general = buildGeneralRow({ id: 12, nationId: 3, officerLevel: 5 }); const nationTurns: NationTurnRow[] = [ { - id: 1, + id: 1n, nationId: 3, officerLevel: 5, turnIdx: 0, @@ -1008,7 +1008,7 @@ describe('appRouter', () => { general, generalTurns: [ { - id: 1, + id: 1n, generalId: general.id, turnIdx: 0, actionCode: 'che_훈련', @@ -1016,7 +1016,7 @@ describe('appRouter', () => { createdAt: new Date(), }, { - id: 2, + id: 2n, generalId: general.id, turnIdx: 1, actionCode: 'che_사기진작', diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index adc06520..ca6d8dfb 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -119,7 +119,7 @@ export interface RealtimeReadModelBaseline { } export type PersistedVisibleLogRow = { - id: number; + id: number | bigint; scope: LogScope; category: LogCategory; generalId: number | null; @@ -831,17 +831,28 @@ const buildPersistedGeneralMeta = ( const RANK_DATA_UPSERT_BATCH_SIZE = 1_000; -const upsertRankRows = async ( +export const upsertRankRows = async ( prisma: GamePrisma.TransactionClient, rows: Array<{ generalId: number; nationId: number; type: string; value: number }> ): Promise => { for (let offset = 0; offset < rows.length; offset += RANK_DATA_UPSERT_BATCH_SIZE) { const batch = rows.slice(offset, offset + RANK_DATA_UPSERT_BATCH_SIZE); + // Ref와 같은 (장수, 항목) 값을 저장한다. 이미 있는 행의 ID를 재사용해 + // 매 턴 44종 갱신마다 nextval을 소비하지 않고, 신규 행만 시퀀스를 사용한다. + // 동시 신규 insert 경합은 기존 ON CONFLICT 계약으로 처리한다. await prisma.$executeRaw(GamePrisma.sql` - INSERT INTO "rank_data" ("general_id", "nation_id", "type", "value") - VALUES ${GamePrisma.join( - batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.nationId}, ${row.type}, ${row.value})`) - )} + INSERT INTO "rank_data" ("id", "general_id", "nation_id", "type", "value") + SELECT + COALESCE(existing."id", nextval(pg_get_serial_sequence('rank_data', 'id'))), + incoming."general_id", incoming."nation_id", incoming."type", incoming."value" + FROM (VALUES ${GamePrisma.join( + batch.map( + (row) => + GamePrisma.sql`(${row.generalId}::integer, ${row.nationId}::integer, ${row.type}::text, ${row.value}::integer)` + ) + )}) AS incoming("general_id", "nation_id", "type", "value") + LEFT JOIN "rank_data" AS existing + ON existing."general_id" = incoming."general_id" AND existing."type" = incoming."type" ON CONFLICT ("general_id", "type") DO UPDATE SET "nation_id" = EXCLUDED."nation_id", @@ -1129,7 +1140,7 @@ export const createDatabaseTurnHooks = async ( const persistChanges = async ( transaction?: GamePrisma.TransactionClient, commandCompletion?: { requestId: string; result: TurnDaemonCommandResult }, - directLogFloor?: number + directLogFloor?: number | bigint ): Promise<{ acknowledge: () => void; readModelChanges: RealtimeReadModelChanges; diff --git a/app/game-engine/test/rankDataPersistence.integration.test.ts b/app/game-engine/test/rankDataPersistence.integration.test.ts new file mode 100644 index 00000000..195c4ae8 --- /dev/null +++ b/app/game-engine/test/rankDataPersistence.integration.test.ts @@ -0,0 +1,145 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { upsertRankRows } from '../src/turn/databaseHooks.js'; + +const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL; + +// The conditional runner owns this schema and runs integration files serially. +describe.skipIf(!databaseUrl)('rank data bigint PostgreSQL persistence', () => { + let db: GamePrismaClient; + let disconnect: () => Promise; + const generalId = 7_319; + + beforeAll(async () => { + const schema = new URL(databaseUrl!).searchParams.get('schema'); + if (!schema?.endsWith('immediate_action_integration')) { + throw new Error('Refusing to mutate a non-dedicated rank persistence schema'); + } + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnect = () => connector.disconnect(); + await db.rankData.deleteMany({ where: { generalId } }); + // Even conflict-update on an existing row used to exhaust int4 IDs. + await db.$executeRaw`SELECT setval(pg_get_serial_sequence('rank_data', 'id'), 2147483647)`; + }); + + afterAll(async () => { + if (db) await db.rankData.deleteMany({ where: { generalId } }); + await disconnect?.(); + }); + + it('reuses IDs across batches, updates ownership and values, and allocates bigint IDs only for new rows', async () => { + const rows = Array.from({ length: 1_001 }, (_, index) => ({ + generalId, + nationId: 10, + type: `rank_fixture_${index}`, + value: index, + })); + await db.$transaction((tx) => upsertRankRows(tx, rows)); + const original = await db.rankData.findMany({ where: { generalId }, orderBy: { type: 'asc' } }); + expect(original).toHaveLength(rows.length); + expect(original.every((row) => row.id > 2_147_483_647n)).toBe(true); + const sequenceBefore = await db.$queryRaw< + Array<{ last_value: bigint }> + >`SELECT last_value FROM rank_data_id_seq`; + for (let count = 0; count < 3; count++) { + await db.$transaction((tx) => + upsertRankRows( + tx, + rows.map((row) => ({ ...row, nationId: 20, value: row.value + 3 })) + ) + ); + } + const updated = await db.rankData.findMany({ where: { generalId }, orderBy: { type: 'asc' } }); + expect(updated.map((row) => row.id)).toEqual(original.map((row) => row.id)); + expect(updated.map((row) => [row.nationId, row.value])).toEqual(original.map((row) => [20, row.value + 3])); + expect(await db.$queryRaw`SELECT last_value FROM rank_data_id_seq`).toEqual(sequenceBefore); + await db.$transaction((tx) => + upsertRankRows(tx, [rows[0]!, { generalId, nationId: 30, type: 'new_rank', value: 17 }]) + ); + expect(await db.rankData.count({ where: { generalId } })).toBe(1_002); + expect(await db.$queryRaw`SELECT last_value FROM rank_data_id_seq`).toEqual([ + { last_value: sequenceBefore[0]!.last_value + 1n }, + ]); + }); + + 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', + 'log_entry', + 'error_log', + 'inheritance_log', + 'traffic_period', + ]; + for (const table of tables) { + await db.$executeRaw`SELECT setval(pg_get_serial_sequence(${table}, 'id'), 2147483647)`; + } + await expect( + db.$transaction(async (tx) => { + const now = new Date(); + const world = await tx.worldState.create({ + data: { + scenarioCode: 'bigint-fixture', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + }, + }); + 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 } }), + tx.logEntry.create({ + data: { scope: 'SYSTEM', category: 'HISTORY', year: 200, month: 1, text: 'fixture' }, + }), + tx.errorLog.create({ data: { category: 'fixture', message: 'fixture' } }), + tx.inheritanceLog.create({ + data: { userId: 'bigint-fixture', year: 200, month: 1, text: 'fixture' }, + }), + ]); + expect(rows.every((row) => row.id > 2_147_483_647n)).toBe(true); + const period = await tx.trafficPeriod.create({ + data: { + worldStateId: world.id, + year: 200, + month: 1, + startedAt: now, + lastRefresh: now, + generals: { create: { generalId, lastRefresh: now } }, + }, + include: { generals: true }, + }); + 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'); + throw new Error('high churn fixture rollback'); + }) + ).rejects.toThrow('high churn fixture rollback'); + }); + + it('rolls value updates back atomically and retains the logical unique constraint', async () => { + const where = { generalId_type: { generalId, type: 'new_rank' } }; + const before = await db.rankData.findUniqueOrThrow({ where }); + await expect( + db.$transaction(async (tx) => { + await upsertRankRows(tx, [{ generalId, nationId: 99, type: 'new_rank', value: 999 }]); + throw new Error('fixture rollback'); + }) + ).rejects.toThrow('fixture rollback'); + expect(await db.rankData.findUniqueOrThrow({ where })).toEqual(before); + await expect( + db.rankData.create({ data: { generalId, nationId: 99, type: 'new_rank', value: 999 } }) + ).rejects.toThrow(); + }); +}); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index fdccd99d..8177dc28 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: '20260916080000_index_play_audit_decision_month', + gameSchemaHead: '20261114010000_high_churn_bigint_ids', }); }); diff --git a/docs/testing-policy.md b/docs/testing-policy.md index ae41d871..1a7adac7 100644 --- a/docs/testing-policy.md +++ b/docs/testing-policy.md @@ -208,6 +208,17 @@ no-op, unique/FK/index, runtime query와 reload를 확인합니다. 기존 migra 파일과 checksum은 수정하지 않습니다. `prisma db push`는 격리 fixture의 일시적 schema 준비에만 사용합니다. +### 랭킹·고빈도 저장 ID 확장 회귀 + +격리된 개발 DB URL을 환경으로 주입해 `RANK_DATA_MIGRATION_TEST_DATABASE_URL=...` +`pnpm --filter @sammo-ts/infra verify:migration:rank-data`를 실행합니다. URL은 출력하지 +않습니다. 검증기는 소유한 UUID schema에서 INT 시퀀스 소진을 재현하고 10개 대상의 기존 데이터· +인덱스·접속 통계 FK 보존, BIGINT 전환, 전체 migration과 두 번째 deploy의 no-op을 검사합니다. +실제 턴 저장 SQL은 조건부 runner의 `IMMEDIATE_ACTION_DATABASE_URL`과 +`rankDataPersistence.integration.test.ts`가 batch 경계, ID 재사용, 32비트 초과 ID의 +Prisma reload, 유일성 및 transaction rollback을 검증합니다. `log_entry`·`inheritance_log`의 API 숫자 cursor는 +`Number.MAX_SAFE_INTEGER`까지 정확성을 보장하며 그 이상은 명시적으로 거부합니다. + ## 결과 기록 Report에는 실행 명령, commit, 서비스와 fixture, pass/fail/skip 수, baseline diff --git a/packages/common/src/util/parse.ts b/packages/common/src/util/parse.ts index bdbc1b0c..7dabee53 100644 --- a/packages/common/src/util/parse.ts +++ b/packages/common/src/util/parse.ts @@ -86,3 +86,17 @@ export const parseJson = (raw: string | null): T | null => { return null; } }; + +/** Preserve numeric API cursors beyond int4 without silently rounding a bigint. */ +export const toSafeInteger = (value: bigint | number): number => { + const result = Number(value); + if (!Number.isSafeInteger(result)) { + throw new RangeError('Integer exceeds the exact numeric transport range'); + } + return result; +}; + +export const withSafeIntegerId = (row: Row): Omit & { id: number } => ({ + ...row, + id: toSafeInteger(row.id), +}); diff --git a/packages/common/test/safeInteger.test.ts b/packages/common/test/safeInteger.test.ts new file mode 100644 index 00000000..19a280b6 --- /dev/null +++ b/packages/common/test/safeInteger.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { toSafeInteger, withSafeIntegerId } from '../src/util/parse.js'; + +describe('exact numeric ID transport', () => { + it('preserves IDs above int4 through the maximum exact JS integer', () => { + expect(toSafeInteger(2_147_483_648n)).toBe(2_147_483_648); + expect(toSafeInteger(9_007_199_254_740_991n)).toBe(Number.MAX_SAFE_INTEGER); + expect(withSafeIntegerId({ id: 2_147_483_648n, text: 'record' })).toEqual({ + id: 2_147_483_648, + text: 'record', + }); + }); + + it('rejects silent precision loss, fractional and nonfinite IDs', () => { + for (const value of [9_007_199_254_740_992n, 9_007_199_254_740_993n, 1.5, NaN, Infinity]) { + expect(() => toSafeInteger(value)).toThrow(RangeError); + } + }); +}); diff --git a/packages/infra/package.json b/packages/infra/package.json index ff8dc5fa..e924b8ef 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: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", "coverage:activate:game": "node scripts/activate-read-model-coverage.mjs", diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index dc829a0c..6e4db81f 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -372,7 +372,7 @@ model NpcSelectionToken { } model GeneralAccessLog { - id Int @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) generalId Int @unique @map("general_id") userId String? @map("user_id") lastRefresh DateTime? @map("last_refresh") @@ -396,7 +396,7 @@ model GeneralAccessBatch { } model TrafficPeriod { - id Int @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) worldStateId Int @map("world_state_id") year Int month Int @@ -414,7 +414,7 @@ model TrafficPeriod { } model TrafficPeriodGeneral { - periodId Int @map("period_id") + periodId BigInt @map("period_id") generalId Int @map("general_id") userId String? @map("user_id") refresh Int @default(0) @@ -482,7 +482,7 @@ model MessageAction { } model RankData { - id Int @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) nationId Int @default(0) @map("nation_id") generalId Int @map("general_id") type String @db.VarChar(20) @@ -679,7 +679,7 @@ model Troop { } model GeneralTurn { - id Int @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) generalId Int @map("general_id") turnIdx Int @map("turn_idx") actionCode String @map("action_code") @@ -701,7 +701,7 @@ model GeneralTurnRevision { } model NationTurn { - id Int @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) nationId Int @map("nation_id") officerLevel Int @map("officer_level") turnIdx Int @map("turn_idx") @@ -726,7 +726,7 @@ model NationTurnRevision { } model Diplomacy { - id Int @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) srcNationId Int @map("src_nation_id") destNationId Int @map("dest_nation_id") stateCode Int @map("state_code") @@ -790,7 +790,7 @@ model Event { } model LogEntry { - id Int @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) serverId String? @map("server_id") scope LogScope category LogCategory @@ -812,7 +812,7 @@ model LogEntry { } model ErrorLog { - id Int @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) category String source String? @map("source") message String @@ -825,7 +825,7 @@ model ErrorLog { } model InheritancePoint { - id Int @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) userId String @map("user_id") key String value Float @default(0) @@ -878,7 +878,7 @@ model NationBet { } model InheritanceLog { - id Int @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) legacyId Int? @unique @map("legacy_id") userId String @map("user_id") serverId String? @map("server_id") diff --git a/packages/infra/prisma/migrations/20261114000000_rank_data_bigint_id/migration.sql b/packages/infra/prisma/migrations/20261114000000_rank_data_bigint_id/migration.sql new file mode 100644 index 00000000..c29f0790 --- /dev/null +++ b/packages/infra/prisma/migrations/20261114000000_rank_data_bigint_id/migration.sql @@ -0,0 +1,7 @@ +-- Keep all existing row IDs and the current sequence position, including an +-- exhausted int4 sequence. Widen both ends; changing the sequence alone leaves +-- INSERTs failing with integer overflow. Never rewind or cycle production IDs. +BEGIN; +ALTER TABLE "rank_data" ALTER COLUMN "id" TYPE BIGINT; +ALTER SEQUENCE "rank_data_id_seq" AS BIGINT NO MAXVALUE NO CYCLE; +COMMIT; diff --git a/packages/infra/prisma/migrations/20261114010000_high_churn_bigint_ids/migration.sql b/packages/infra/prisma/migrations/20261114010000_high_churn_bigint_ids/migration.sql new file mode 100644 index 00000000..018718bc --- /dev/null +++ b/packages/infra/prisma/migrations/20261114010000_high_churn_bigint_ids/migration.sql @@ -0,0 +1,23 @@ +-- High churn queues/upserts and append-only logs need lifetime IDs, independent +-- of live row count. Keep logical keys, values and sequence positions intact. +BEGIN; +ALTER TABLE "general_turn" ALTER COLUMN "id" TYPE BIGINT; +ALTER SEQUENCE "general_turn_id_seq" AS BIGINT NO MAXVALUE NO CYCLE; +ALTER TABLE "nation_turn" ALTER COLUMN "id" TYPE BIGINT; +ALTER SEQUENCE "nation_turn_id_seq" AS BIGINT NO MAXVALUE NO CYCLE; +ALTER TABLE "general_access_log" ALTER COLUMN "id" TYPE BIGINT; +ALTER SEQUENCE "general_access_log_id_seq" AS BIGINT NO MAXVALUE NO CYCLE; +ALTER TABLE "diplomacy" ALTER COLUMN "id" TYPE BIGINT; +ALTER SEQUENCE "diplomacy_id_seq" AS BIGINT NO MAXVALUE NO CYCLE; +ALTER TABLE "inheritance_point" ALTER COLUMN "id" TYPE BIGINT; +ALTER SEQUENCE "inheritance_point_id_seq" AS BIGINT NO MAXVALUE NO CYCLE; +ALTER TABLE "log_entry" ALTER COLUMN "id" TYPE BIGINT; +ALTER SEQUENCE "log_entry_id_seq" AS BIGINT NO MAXVALUE NO CYCLE; +ALTER TABLE "error_log" ALTER COLUMN "id" TYPE BIGINT; +ALTER SEQUENCE "error_log_id_seq" AS BIGINT NO MAXVALUE NO CYCLE; +ALTER TABLE "inheritance_log" ALTER COLUMN "id" TYPE BIGINT; +ALTER SEQUENCE "inheritance_log_id_seq" AS BIGINT NO MAXVALUE NO CYCLE; +ALTER TABLE "traffic_period" ALTER COLUMN "id" TYPE BIGINT; +ALTER SEQUENCE "traffic_period_id_seq" AS BIGINT NO MAXVALUE NO CYCLE; +ALTER TABLE "traffic_period_general" ALTER COLUMN "period_id" TYPE BIGINT; +COMMIT; diff --git a/packages/infra/scripts/verify-rank-data-bigint-migration.mjs b/packages/infra/scripts/verify-rank-data-bigint-migration.mjs new file mode 100644 index 00000000..b8d89667 --- /dev/null +++ b/packages/infra/scripts/verify-rank-data-bigint-migration.mjs @@ -0,0 +1,168 @@ +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.RANK_DATA_MIGRATION_TEST_DATABASE_URL; +if (!sourceUrl) throw new Error('RANK_DATA_MIGRATION_TEST_DATABASE_URL is required (isolated development DB only)'); +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targets = ['20261114000000_rank_data_bigint_id', '20261114010000_high_churn_bigint_ids']; +const fixtures = [ + ['rank_data', 'general_id, nation_id, type, value', "7, 3, 'warnum', 19"], + ['general_turn', 'general_id, turn_idx, action_code', "7, 0, 'fixture'"], + ['nation_turn', 'nation_id, officer_level, turn_idx, action_code', "3, 12, 0, 'fixture'"], + [ + 'traffic_period', + 'world_state_id, year, month, started_at, last_refresh', + "1, 200, 1, '2026-11-14', '2026-11-14'", + ], + ['general_access_log', 'general_id', '7'], + ['diplomacy', 'src_nation_id, dest_nation_id, state_code', '3, 4, 0'], + ['inheritance_point', 'user_id, key, value', "'fixture-user', 'previous', 123"], + ['log_entry', 'scope, category, year, month, text', "'SYSTEM', 'HISTORY', 200, 1, 'fixture'"], + ['error_log', 'category, message', "'fixture', 'fixture'"], + ['inheritance_log', 'user_id, year, month, text', "'fixture-user', 200, 1, 'fixture'"], +]; +const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'rank-bigint-migration-')); +const schema = `rank_bigint_upgrade_${randomUUID().replaceAll('-', '')}`; +const freshSchema = `${schema}_fresh`; +const client = new pg.Client({ connectionString: sourceUrl }); +const config = path.join(packageRoot, `.rank-bigint-${process.pid}.config.mjs`); +const url = new URL(sourceUrl); +const deploy = (schemaName) => { + url.searchParams.set('schema', schemaName); + 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 snapshots = new Map(); +const readIndexes = async (table) => + ( + await client.query( + 'SELECT indexname, indexdef FROM pg_indexes WHERE schemaname=$1 AND tablename=$2 ORDER BY indexname', + [schema, table] + ) + ).rows; +try { + await fs.cp(path.join(packageRoot, 'prisma'), path.join(temp, 'prisma'), { recursive: true }); + for (const target of targets) 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(); + // UUID schemas belong only to this invocation; caller's schema is never changed. + await client.query(`CREATE SCHEMA "${schema}"`); + await client.query(`CREATE SCHEMA "${freshSchema}"`); + deploy(schema); + await client.query(`SET search_path TO "${schema}", public`); + await client.query(`INSERT INTO world_state(id, scenario_code, current_year, current_month, tick_seconds, updated_at) + VALUES(1, 'fixture', 200, 1, 600, CURRENT_TIMESTAMP)`); + for (const [table, columns, values] of fixtures) { + await client.query(`INSERT INTO "${table}" (id, ${columns}) VALUES(2147483647, ${values})`); + await client.query(`SELECT setval('${table}_id_seq', 2147483647)`); + await assert.rejects(client.query(`SELECT nextval('${table}_id_seq')`), { code: '2200H' }); + snapshots.set(table, { + rows: (await client.query(`SELECT * FROM "${table}" ORDER BY id`)).rows, + indexes: await readIndexes(table), + }); + } + await client.query(`INSERT INTO traffic_period_general(period_id, general_id, refresh, last_refresh) + VALUES(2147483647, 7, 2, CURRENT_TIMESTAMP)`); + const trafficMembers = (await client.query('SELECT * FROM traffic_period_general')).rows; + for (const target of targets) + await fs.cp( + path.join(packageRoot, 'prisma', 'migrations', target), + path.join(temp, 'prisma', 'migrations', target), + { recursive: true } + ); + deploy(schema); + for (const [table] of fixtures) { + const before = snapshots.get(table); + const after = (await client.query(`SELECT * FROM "${table}" ORDER BY id`)).rows; + assert.deepEqual( + after.map((row) => ({ ...row, id: Number(row.id) })), + before.rows + ); + assert.deepEqual(await readIndexes(table), before.indexes); + assert.equal((await client.query(`SELECT last_value FROM "${table}_id_seq"`)).rows[0].last_value, '2147483647'); + assert.equal( + ( + await client.query( + "SELECT data_type FROM information_schema.columns WHERE table_schema=$1 AND table_name=$2 AND column_name='id'", + [schema, table] + ) + ).rows[0].data_type, + 'bigint' + ); + assert.deepEqual( + ( + await client.query( + 'SELECT data_type, max_value::text, cycle FROM pg_sequences WHERE schemaname=$1 AND sequencename=$2', + [schema, `${table}_id_seq`] + ) + ).rows[0], + { data_type: 'bigint', max_value: '9223372036854775807', cycle: false } + ); + assert.equal((await client.query(`SELECT nextval('${table}_id_seq')`)).rows[0].nextval, '2147483648'); + } + assert.equal( + (await client.query("INSERT INTO rank_data(general_id, type) VALUES(8, 'warnum') RETURNING id")).rows[0].id, + '2147483649' + ); + await assert.rejects(client.query("INSERT INTO rank_data(general_id, type) VALUES(8, 'warnum')"), { + code: '23505', + }); + assert.deepEqual( + (await client.query('SELECT * FROM traffic_period_general')).rows.map((row) => ({ + ...row, + period_id: Number(row.period_id), + })), + trafficMembers + ); + await assert.rejects( + client.query(`INSERT INTO traffic_period_general(period_id, general_id, last_refresh) + VALUES(999, 8, CURRENT_TIMESTAMP)`), + { code: '23503' } + ); + await client.query('BEGIN'); + await client.query('DELETE FROM traffic_period WHERE id=2147483647'); + assert.equal((await client.query('SELECT * FROM traffic_period_general')).rowCount, 0); + await client.query('ROLLBACK'); + 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(freshSchema); + await client.query(`SET search_path TO "${freshSchema}", public`); + await client.query(`INSERT INTO world_state(id, scenario_code, current_year, current_month, tick_seconds, updated_at) + VALUES(1, 'fixture', 200, 1, 600, CURRENT_TIMESTAMP)`); + for (const [table, columns, values] of fixtures) { + assert.equal( + (await client.query(`INSERT INTO "${table}" (${columns}) VALUES(${values}) RETURNING id`)).rows[0].id, + '1' + ); + } + console.log( + `PASS: ${fixtures.length} exhausted int4 tables upgraded; rows/IDs/indexes preserved, bigint allocation, unique constraint, deploy no-op, fresh full migration` + ); +} 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 "${freshSchema}" CASCADE`).catch(() => {}); + await client.end(); + await fs.rm(config, { force: true }); + await fs.rm(temp, { recursive: true }); +} diff --git a/release-manifest.json b/release-manifest.json index 4b173fc7..d1dea23e 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20261107000000_kakao_identity_link_history", - "gameSchemaHead": "20261109000000_compact_play_audit_storage", + "gameSchemaHead": "20261114010000_high_churn_bigint_ids", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }