고빈도 저장 ID를 64비트로 확장하고 랭킹 갱신의 시퀀스 낭비를 막는다
This commit is contained in:
1 parent
087517821e
commit
bc54b157c1
25 files changed
+497
-65
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSafeInteger } from '@sammo-ts/common';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -281,7 +282,7 @@ export const auctionRouter = router({
|
|||||||
callerAlias,
|
callerAlias,
|
||||||
remainPoint: point?.value ?? 0,
|
remainPoint: point?.value ?? 0,
|
||||||
recentLogs: recentLogs.map((log) => ({
|
recentLogs: recentLogs.map((log) => ({
|
||||||
id: log.id,
|
id: toSafeInteger(log.id),
|
||||||
text: log.text,
|
text: log.text,
|
||||||
createdAt: log.createdAt.toISOString(),
|
createdAt: log.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
@@ -444,7 +445,10 @@ export const auctionRouter = router({
|
|||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
const nextCloseAt = new Date(result.closeAt);
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
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 };
|
return { ok: true };
|
||||||
@@ -521,7 +525,10 @@ export const auctionRouter = router({
|
|||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
const nextCloseAt = new Date(result.closeAt);
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
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 };
|
return { ok: true };
|
||||||
@@ -659,7 +666,10 @@ export const auctionRouter = router({
|
|||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
const nextCloseAt = new Date(result.closeAt);
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
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 };
|
return { ok: true };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic';
|
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 type { GameApiContext } from '../../context.js';
|
||||||
import {
|
import {
|
||||||
@@ -588,10 +588,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
recentWar: general.recentWarTime?.toISOString() ?? null,
|
recentWar: general.recentWarTime?.toISOString() ?? null,
|
||||||
defenceTrain: settings.defence_train,
|
defenceTrain: settings.defence_train,
|
||||||
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
|
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
|
||||||
remainingMinutes: resolveRemainingMinutes(
|
remainingMinutes: resolveRemainingMinutes(general.turnTime, gameTime.now),
|
||||||
general.turnTime,
|
|
||||||
gameTime.now
|
|
||||||
),
|
|
||||||
crewTypeId: general.crewTypeId,
|
crewTypeId: general.crewTypeId,
|
||||||
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
|
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
|
||||||
crewTypeInfo: crewTypeDetails.get(general.crewTypeId) ?? null,
|
crewTypeInfo: crewTypeDetails.get(general.crewTypeId) ?? null,
|
||||||
@@ -916,7 +913,7 @@ export const generalRouter = router({
|
|||||||
return {
|
return {
|
||||||
type: input.type,
|
type: input.type,
|
||||||
logs: logs.map((entry) => ({
|
logs: logs.map((entry) => ({
|
||||||
id: entry.id,
|
id: toSafeInteger(entry.id),
|
||||||
text: entry.text,
|
text: entry.text,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -966,9 +963,9 @@ export const generalRouter = router({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
global: trimRecentRecords(global, input.lastGeneralRecordId),
|
global: trimRecentRecords(global.map(withSafeIntegerId), input.lastGeneralRecordId),
|
||||||
general: trimRecentRecords(general, input.lastGeneralRecordId),
|
general: trimRecentRecords(general.map(withSafeIntegerId), input.lastGeneralRecordId),
|
||||||
history: trimRecentRecords(history, input.lastWorldHistoryId),
|
history: trimRecentRecords(history.map(withSafeIntegerId), input.lastWorldHistoryId),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
// 메인 화면은 SSE invalidation, 탭 복귀와 직접 갱신이 같은 read model을
|
// 메인 화면은 SSE invalidation, 탭 복귀와 직접 갱신이 같은 read model을
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { withSafeIntegerId } from '@sammo-ts/common';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -38,7 +39,6 @@ const BUFF_KEYS: InheritBuffType[] = [
|
|||||||
|
|
||||||
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
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 MIN_INHERIT_BUFF_LEVEL = 1;
|
||||||
const MAX_INHERIT_BUFF_LEVEL = 5;
|
const MAX_INHERIT_BUFF_LEVEL = 5;
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ export const inheritRouter = router({
|
|||||||
getLogs: authedProcedure
|
getLogs: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
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 }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -300,7 +300,7 @@ export const inheritRouter = router({
|
|||||||
take: 30,
|
take: 30,
|
||||||
select: { id: true, year: true, month: true, text: true, createdAt: true },
|
select: { id: true, year: true, month: true, text: true, createdAt: true },
|
||||||
});
|
});
|
||||||
return logs;
|
return logs.map(withSafeIntegerId);
|
||||||
}),
|
}),
|
||||||
buyHiddenBuff: engineAuthedProcedure
|
buyHiddenBuff: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSafeInteger } from '@sammo-ts/common';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export const getGeneralLog = accessLimitAuthedInputProcedure(
|
|||||||
type: input.type,
|
type: input.type,
|
||||||
generalId: target.id,
|
generalId: target.id,
|
||||||
logs: logs.map((entry) => ({
|
logs: logs.map((entry) => ({
|
||||||
id: entry.id,
|
id: toSafeInteger(entry.id),
|
||||||
text: entry.text,
|
text: entry.text,
|
||||||
year: entry.year,
|
year: entry.year,
|
||||||
month: entry.month,
|
month: entry.month,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { withSafeIntegerId } from '@sammo-ts/common';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
@@ -129,6 +130,6 @@ export const getNationInfo = authedProcedure.query(async ({ ctx }) => {
|
|||||||
name: city.name,
|
name: city.name,
|
||||||
capital: city.id === nation.capitalCityId,
|
capital: city.id === nation.capitalCityId,
|
||||||
})),
|
})),
|
||||||
history,
|
history: history.map(withSafeIntegerId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSafeInteger, withSafeIntegerId } from '@sammo-ts/common';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld } from './shared.js';
|
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld } from './shared.js';
|
||||||
@@ -53,8 +54,8 @@ export const generalLogs = auditProcedure
|
|||||||
...world,
|
...world,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
coverage: world.serverId ? ('IDENTIFIED_LOGS_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
coverage: world.serverId ? ('IDENTIFIED_LOGS_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||||
items: rows.slice(0, input.limit),
|
items: rows.slice(0, input.limit).map(withSafeIntegerId),
|
||||||
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.id : null,
|
nextCursor: rows.length > input.limit ? toSafeInteger(rows[input.limit - 1]!.id) : null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
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 { LEGACY_DEFAULT_MAX_LEVEL, LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -306,7 +306,7 @@ export const publicRouter = router({
|
|||||||
}
|
}
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
...map,
|
...map,
|
||||||
history,
|
history: history.map(withSafeIntegerId),
|
||||||
};
|
};
|
||||||
await ctx.redis.set(cacheKey, JSON.stringify(snapshot), { EX: PUBLIC_CACHE_TTL_SECONDS });
|
await ctx.redis.set(cacheKey, JSON.stringify(snapshot), { EX: PUBLIC_CACHE_TTL_SECONDS });
|
||||||
return snapshot;
|
return snapshot;
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export const upsertGeneralAccess = async (
|
|||||||
await db.$transaction(async (transaction) => {
|
await db.$transaction(async (transaction) => {
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
||||||
const periodKey = input.year * 12 + input.month - 1;
|
const periodKey = input.year * 12 + input.month - 1;
|
||||||
const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
|
const periodRows = await transaction.$queryRaw<Array<{ id: bigint }>>(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
WITH latest_period AS (
|
WITH latest_period AS (
|
||||||
SELECT MAX(year * 12 + month - 1)::INTEGER AS period_key
|
SELECT MAX(year * 12 + month - 1)::INTEGER AS period_key
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ const buildContext = (options: {
|
|||||||
inheritancePoint?: number;
|
inheritancePoint?: number;
|
||||||
inheritanceRows?: Array<{ key: string; value: number }>;
|
inheritanceRows?: Array<{ key: string; value: number }>;
|
||||||
rankRows?: Array<{ type: 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<string, unknown>;
|
configConst?: Record<string, unknown>;
|
||||||
scenarioCode?: string;
|
scenarioCode?: string;
|
||||||
configMap?: Record<string, unknown>;
|
configMap?: Record<string, unknown>;
|
||||||
@@ -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 () => {
|
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 createdAt = new Date('2026-07-26T00:00:00Z');
|
||||||
const fixture = buildContext({
|
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([
|
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({
|
expect(fixture.inheritanceLogFindMany).toHaveBeenCalledWith({
|
||||||
where: { userId: 'user-1' },
|
where: { userId: 'user-1' },
|
||||||
@@ -478,23 +478,26 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
const fixture = buildContext({ inheritanceLogs: [] });
|
const fixture = buildContext({ inheritanceLogs: [] });
|
||||||
const caller = appRouter.createCaller(fixture.context);
|
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({
|
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' },
|
orderBy: { id: 'desc' },
|
||||||
take: 30,
|
take: 30,
|
||||||
select: { id: true, year: true, month: true, text: true, createdAt: true },
|
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) => {
|
it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])(
|
||||||
|
'rejects an invalid inheritance-log cursor: %s',
|
||||||
|
async (lastId) => {
|
||||||
const fixture = buildContext({});
|
const fixture = buildContext({});
|
||||||
|
|
||||||
await expect(appRouter.createCaller(fixture.context).inherit.getLogs({ lastId })).rejects.toMatchObject({
|
await expect(appRouter.createCaller(fixture.context).inherit.getLogs({ lastId })).rejects.toMatchObject({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
});
|
});
|
||||||
expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled();
|
expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('delegates the authenticated actor and maps a missing-general daemon rejection', async () => {
|
it('delegates the authenticated actor and maps a missing-general daemon rejection', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type LogQuery = {
|
|||||||
select: { id: true; text: true; createdAt: true };
|
select: { id: true; text: true; createdAt: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number | bigint; text: string }>>) =>
|
||||||
({
|
({
|
||||||
auth,
|
auth,
|
||||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
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 () => {
|
it('caps an initial bucket at the legacy 15-row limit', async () => {
|
||||||
const rows = Array.from({ length: 16 }, (_, index) => ({
|
const rows = Array.from({ length: 16 }, (_, index) => ({
|
||||||
id: 100 - index,
|
id: 100 - index,
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ const buildContext = () => {
|
|||||||
},
|
},
|
||||||
logEntry: {
|
logEntry: {
|
||||||
findMany: vi.fn(async () => [
|
findMany: vi.fn(async () => [
|
||||||
{ id: 9, text: '<Y>최근 정세</>' },
|
{ id: 9n, text: '<Y>최근 정세</>' },
|
||||||
{ id: 8, text: '이전 정세' },
|
{ id: 8n, text: '이전 정세' },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
$queryRaw: vi
|
$queryRaw: vi
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const buildDb = (autorunLimit: number | null = null) => {
|
|||||||
createMany: async ({ data }: GeneralTurnCreateManyArgs) => {
|
createMany: async ({ data }: GeneralTurnCreateManyArgs) => {
|
||||||
const dataList = (Array.isArray(data) ? data : [data]) as Record<string, unknown>[];
|
const dataList = (Array.isArray(data) ? data : [data]) as Record<string, unknown>[];
|
||||||
const rows: GeneralTurnRow[] = dataList.map((row, index: number) => ({
|
const rows: GeneralTurnRow[] = dataList.map((row, index: number) => ({
|
||||||
id: index + 1,
|
id: BigInt(index + 1),
|
||||||
generalId: row.generalId as number,
|
generalId: row.generalId as number,
|
||||||
turnIdx: row.turnIdx as number,
|
turnIdx: row.turnIdx as number,
|
||||||
actionCode: row.actionCode as string,
|
actionCode: row.actionCode as string,
|
||||||
@@ -137,7 +137,7 @@ const buildDb = (autorunLimit: number | null = null) => {
|
|||||||
createMany: async ({ data }: NationTurnCreateManyArgs) => {
|
createMany: async ({ data }: NationTurnCreateManyArgs) => {
|
||||||
const dataList = (Array.isArray(data) ? data : [data]) as Record<string, unknown>[];
|
const dataList = (Array.isArray(data) ? data : [data]) as Record<string, unknown>[];
|
||||||
const rows: NationTurnRow[] = dataList.map((row, index: number) => ({
|
const rows: NationTurnRow[] = dataList.map((row, index: number) => ({
|
||||||
id: index + 1,
|
id: BigInt(index + 1),
|
||||||
nationId: row.nationId as number,
|
nationId: row.nationId as number,
|
||||||
officerLevel: row.officerLevel as number,
|
officerLevel: row.officerLevel as number,
|
||||||
turnIdx: row.turnIdx as number,
|
turnIdx: row.turnIdx as number,
|
||||||
@@ -203,9 +203,11 @@ const buildDb = (autorunLimit: number | null = null) => {
|
|||||||
|
|
||||||
describe('reservedTurns', () => {
|
describe('reservedTurns', () => {
|
||||||
it('loads multiple nation officer queues with two batched queries and preserves defaults', async () => {
|
it('loads multiple nation officer queues with two batched queries and preserves defaults', async () => {
|
||||||
const findTurns = vi.fn(async () => [
|
const findTurns = vi.fn(
|
||||||
|
async () =>
|
||||||
|
[
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1n,
|
||||||
nationId: 4,
|
nationId: 4,
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
turnIdx: 1,
|
turnIdx: 1,
|
||||||
@@ -214,7 +216,7 @@ describe('reservedTurns', () => {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2n,
|
||||||
nationId: 4,
|
nationId: 4,
|
||||||
officerLevel: 10,
|
officerLevel: 10,
|
||||||
turnIdx: 0,
|
turnIdx: 0,
|
||||||
@@ -222,7 +224,8 @@ describe('reservedTurns', () => {
|
|||||||
arg: {},
|
arg: {},
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
] satisfies NationTurnRow[]);
|
] satisfies NationTurnRow[]
|
||||||
|
);
|
||||||
const findRevisions = vi.fn(async () => [
|
const findRevisions = vi.fn(async () => [
|
||||||
{ nationId: 4, officerLevel: 12, revision: 7, updatedAt: new Date() },
|
{ nationId: 4, officerLevel: 12, revision: 7, updatedAt: new Date() },
|
||||||
]);
|
]);
|
||||||
@@ -411,7 +414,7 @@ describe('reservedTurns', () => {
|
|||||||
nationRevisions.set('6:12', 2);
|
nationRevisions.set('6:12', 2);
|
||||||
nationTurns.set('6:12', [
|
nationTurns.set('6:12', [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1n,
|
||||||
nationId: 6,
|
nationId: 6,
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
turnIdx: 0,
|
turnIdx: 0,
|
||||||
@@ -420,7 +423,7 @@ describe('reservedTurns', () => {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2n,
|
||||||
nationId: 6,
|
nationId: 6,
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
turnIdx: 1,
|
turnIdx: 1,
|
||||||
|
|||||||
@@ -901,7 +901,7 @@ describe('appRouter', () => {
|
|||||||
const general = buildGeneralRow({ id: 11, meta: { autorun_limit: 2408 } });
|
const general = buildGeneralRow({ id: 11, meta: { autorun_limit: 2408 } });
|
||||||
const generalTurns: GeneralTurnRow[] = [
|
const generalTurns: GeneralTurnRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1n,
|
||||||
generalId: 11,
|
generalId: 11,
|
||||||
turnIdx: 0,
|
turnIdx: 0,
|
||||||
actionCode: 'che_화계',
|
actionCode: 'che_화계',
|
||||||
@@ -922,7 +922,7 @@ describe('appRouter', () => {
|
|||||||
const general = buildGeneralRow({ id: 12, nationId: 3, officerLevel: 5 });
|
const general = buildGeneralRow({ id: 12, nationId: 3, officerLevel: 5 });
|
||||||
const nationTurns: NationTurnRow[] = [
|
const nationTurns: NationTurnRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1n,
|
||||||
nationId: 3,
|
nationId: 3,
|
||||||
officerLevel: 5,
|
officerLevel: 5,
|
||||||
turnIdx: 0,
|
turnIdx: 0,
|
||||||
@@ -1008,7 +1008,7 @@ describe('appRouter', () => {
|
|||||||
general,
|
general,
|
||||||
generalTurns: [
|
generalTurns: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1n,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
turnIdx: 0,
|
turnIdx: 0,
|
||||||
actionCode: 'che_훈련',
|
actionCode: 'che_훈련',
|
||||||
@@ -1016,7 +1016,7 @@ describe('appRouter', () => {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2n,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
turnIdx: 1,
|
turnIdx: 1,
|
||||||
actionCode: 'che_사기진작',
|
actionCode: 'che_사기진작',
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export interface RealtimeReadModelBaseline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PersistedVisibleLogRow = {
|
export type PersistedVisibleLogRow = {
|
||||||
id: number;
|
id: number | bigint;
|
||||||
scope: LogScope;
|
scope: LogScope;
|
||||||
category: LogCategory;
|
category: LogCategory;
|
||||||
generalId: number | null;
|
generalId: number | null;
|
||||||
@@ -831,17 +831,28 @@ const buildPersistedGeneralMeta = (
|
|||||||
|
|
||||||
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
||||||
|
|
||||||
const upsertRankRows = async (
|
export const upsertRankRows = async (
|
||||||
prisma: GamePrisma.TransactionClient,
|
prisma: GamePrisma.TransactionClient,
|
||||||
rows: Array<{ generalId: number; nationId: number; type: string; value: number }>
|
rows: Array<{ generalId: number; nationId: number; type: string; value: number }>
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
for (let offset = 0; offset < rows.length; offset += RANK_DATA_UPSERT_BATCH_SIZE) {
|
for (let offset = 0; offset < rows.length; offset += RANK_DATA_UPSERT_BATCH_SIZE) {
|
||||||
const batch = rows.slice(offset, 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`
|
await prisma.$executeRaw(GamePrisma.sql`
|
||||||
INSERT INTO "rank_data" ("general_id", "nation_id", "type", "value")
|
INSERT INTO "rank_data" ("id", "general_id", "nation_id", "type", "value")
|
||||||
VALUES ${GamePrisma.join(
|
SELECT
|
||||||
batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.nationId}, ${row.type}, ${row.value})`)
|
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
|
ON CONFLICT ("general_id", "type") DO UPDATE
|
||||||
SET
|
SET
|
||||||
"nation_id" = EXCLUDED."nation_id",
|
"nation_id" = EXCLUDED."nation_id",
|
||||||
@@ -1129,7 +1140,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
const persistChanges = async (
|
const persistChanges = async (
|
||||||
transaction?: GamePrisma.TransactionClient,
|
transaction?: GamePrisma.TransactionClient,
|
||||||
commandCompletion?: { requestId: string; result: TurnDaemonCommandResult },
|
commandCompletion?: { requestId: string; result: TurnDaemonCommandResult },
|
||||||
directLogFloor?: number
|
directLogFloor?: number | bigint
|
||||||
): Promise<{
|
): Promise<{
|
||||||
acknowledge: () => void;
|
acknowledge: () => void;
|
||||||
readModelChanges: RealtimeReadModelChanges;
|
readModelChanges: RealtimeReadModelChanges;
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
|
|||||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20261107000000_kakao_identity_link_history',
|
gatewaySchemaHead: '20261107000000_kakao_identity_link_history',
|
||||||
gameSchemaHead: '20260916080000_index_play_audit_decision_month',
|
gameSchemaHead: '20261114010000_high_churn_bigint_ids',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,17 @@ no-op, unique/FK/index, runtime query와 reload를 확인합니다. 기존 migra
|
|||||||
파일과 checksum은 수정하지 않습니다. `prisma db push`는 격리 fixture의
|
파일과 checksum은 수정하지 않습니다. `prisma db push`는 격리 fixture의
|
||||||
일시적 schema 준비에만 사용합니다.
|
일시적 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
|
Report에는 실행 명령, commit, 서비스와 fixture, pass/fail/skip 수, baseline
|
||||||
|
|||||||
@@ -86,3 +86,17 @@ export const parseJson = <T>(raw: string | null): T | null => {
|
|||||||
return 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 extends { id: bigint | number }>(row: Row): Omit<Row, 'id'> & { id: number } => ({
|
||||||
|
...row,
|
||||||
|
id: toSafeInteger(row.id),
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
|
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
|
||||||
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-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: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:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh",
|
||||||
"verify:migration:time-domains": "sh scripts/verify-time-domain-migration.sh",
|
"verify:migration:time-domains": "sh scripts/verify-time-domain-migration.sh",
|
||||||
"coverage:activate:game": "node scripts/activate-read-model-coverage.mjs",
|
"coverage:activate:game": "node scripts/activate-read-model-coverage.mjs",
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ model NpcSelectionToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model GeneralAccessLog {
|
model GeneralAccessLog {
|
||||||
id Int @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
generalId Int @unique @map("general_id")
|
generalId Int @unique @map("general_id")
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
lastRefresh DateTime? @map("last_refresh")
|
lastRefresh DateTime? @map("last_refresh")
|
||||||
@@ -396,7 +396,7 @@ model GeneralAccessBatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TrafficPeriod {
|
model TrafficPeriod {
|
||||||
id Int @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
worldStateId Int @map("world_state_id")
|
worldStateId Int @map("world_state_id")
|
||||||
year Int
|
year Int
|
||||||
month Int
|
month Int
|
||||||
@@ -414,7 +414,7 @@ model TrafficPeriod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TrafficPeriodGeneral {
|
model TrafficPeriodGeneral {
|
||||||
periodId Int @map("period_id")
|
periodId BigInt @map("period_id")
|
||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
refresh Int @default(0)
|
refresh Int @default(0)
|
||||||
@@ -482,7 +482,7 @@ model MessageAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model RankData {
|
model RankData {
|
||||||
id Int @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
nationId Int @default(0) @map("nation_id")
|
nationId Int @default(0) @map("nation_id")
|
||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
type String @db.VarChar(20)
|
type String @db.VarChar(20)
|
||||||
@@ -679,7 +679,7 @@ model Troop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model GeneralTurn {
|
model GeneralTurn {
|
||||||
id Int @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
turnIdx Int @map("turn_idx")
|
turnIdx Int @map("turn_idx")
|
||||||
actionCode String @map("action_code")
|
actionCode String @map("action_code")
|
||||||
@@ -701,7 +701,7 @@ model GeneralTurnRevision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model NationTurn {
|
model NationTurn {
|
||||||
id Int @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
nationId Int @map("nation_id")
|
nationId Int @map("nation_id")
|
||||||
officerLevel Int @map("officer_level")
|
officerLevel Int @map("officer_level")
|
||||||
turnIdx Int @map("turn_idx")
|
turnIdx Int @map("turn_idx")
|
||||||
@@ -726,7 +726,7 @@ model NationTurnRevision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Diplomacy {
|
model Diplomacy {
|
||||||
id Int @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
srcNationId Int @map("src_nation_id")
|
srcNationId Int @map("src_nation_id")
|
||||||
destNationId Int @map("dest_nation_id")
|
destNationId Int @map("dest_nation_id")
|
||||||
stateCode Int @map("state_code")
|
stateCode Int @map("state_code")
|
||||||
@@ -790,7 +790,7 @@ model Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model LogEntry {
|
model LogEntry {
|
||||||
id Int @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
serverId String? @map("server_id")
|
serverId String? @map("server_id")
|
||||||
scope LogScope
|
scope LogScope
|
||||||
category LogCategory
|
category LogCategory
|
||||||
@@ -812,7 +812,7 @@ model LogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ErrorLog {
|
model ErrorLog {
|
||||||
id Int @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
category String
|
category String
|
||||||
source String? @map("source")
|
source String? @map("source")
|
||||||
message String
|
message String
|
||||||
@@ -825,7 +825,7 @@ model ErrorLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model InheritancePoint {
|
model InheritancePoint {
|
||||||
id Int @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
key String
|
key String
|
||||||
value Float @default(0)
|
value Float @default(0)
|
||||||
@@ -878,7 +878,7 @@ model NationBet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model InheritanceLog {
|
model InheritanceLog {
|
||||||
id Int @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
legacyId Int? @unique @map("legacy_id")
|
legacyId Int? @unique @map("legacy_id")
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
serverId String? @map("server_id")
|
serverId String? @map("server_id")
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20261107000000_kakao_identity_link_history",
|
"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"]
|
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user