고빈도 저장 ID를 64비트로 확장하고 랭킹 갱신의 시퀀스 낭비를 막는다
This commit is contained in:
1 parent
087517821e
commit
bc54b157c1
25 files changed
+518
-86
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -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을
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Array<{ id: number }>>(
|
||||
const periodRows = await transaction.$queryRaw<Array<{ id: bigint }>>(
|
||||
GamePrisma.sql`
|
||||
WITH latest_period AS (
|
||||
SELECT MAX(year * 12 + month - 1)::INTEGER AS period_key
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
scenarioCode?: string;
|
||||
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 () => {
|
||||
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({
|
||||
|
||||
@@ -33,7 +33,7 @@ type LogQuery = {
|
||||
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,
|
||||
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,
|
||||
|
||||
@@ -38,8 +38,8 @@ const buildContext = () => {
|
||||
},
|
||||
logEntry: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 9, text: '<Y>최근 정세</>' },
|
||||
{ id: 8, text: '이전 정세' },
|
||||
{ id: 9n, text: '<Y>최근 정세</>' },
|
||||
{ id: 8n, text: '이전 정세' },
|
||||
]),
|
||||
},
|
||||
$queryRaw: vi
|
||||
|
||||
@@ -70,7 +70,7 @@ const buildDb = (autorunLimit: number | null = null) => {
|
||||
createMany: async ({ data }: GeneralTurnCreateManyArgs) => {
|
||||
const dataList = (Array.isArray(data) ? data : [data]) as Record<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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,
|
||||
|
||||
@@ -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_사기진작',
|
||||
|
||||
Reference in new issue
Block a user