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

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

No files matched your search

+3 -2
View File
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { TRPCError } from '@trpc/server';
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import { z } from 'zod';
@@ -223,7 +224,7 @@ export const bettingRouter = router({
world.currentMonth,
`${input.amount} 포인트를 베팅에 사용`
);
await ctx.db.rankData.upsert({
await upsertWithoutSequence(ctx.db.rankData, {
where: {
generalId_type: {
generalId: general.id,
@@ -245,7 +246,7 @@ export const bettingRouter = router({
});
}
await ctx.db.nationBet.upsert({
await upsertWithoutSequence(ctx.db.nationBet, {
where: {
bettingId_userId_selectionKey: {
bettingId: input.bettingId,
@@ -1,3 +1,4 @@
import { existingRowIdOrNext } from '@sammo-ts/infra';
import { createHash, randomUUID } from 'node:crypto';
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common';
@@ -94,7 +95,13 @@ export const enqueueDeferredGeneralAccess = async (
}
await redis.eval(ENQUEUE_SCRIPT, {
keys: [activeKey(profileName)],
arguments: [String(generalId), auth.user.id, String(weight), String(now.getTime()), String(ACTIVE_BATCH_TTL_MS)],
arguments: [
String(generalId),
auth.user.id,
String(weight),
String(now.getTime()),
String(ACTIVE_BATCH_TTL_MS),
],
});
return true;
};
@@ -258,9 +265,10 @@ export const flushDeferredGeneralAccessBatch = async (
),
missing_periods AS (
INSERT INTO "traffic_period" (
"world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online"
"id", "world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online"
)
SELECT
${existingRowIdOrNext('traffic_period', GamePrisma.sql`stable_id.world_state_id = ${worldState.id} AND stable_id.year = (missing_key / 12)::INTEGER AND stable_id.month = (missing_key % 12 + 1)::INTEGER`)},
${worldState.id},
(missing_key / 12)::INTEGER,
(missing_key % 12 + 1)::INTEGER,
@@ -281,9 +289,10 @@ export const flushDeferredGeneralAccessBatch = async (
),
period_upsert AS (
INSERT INTO "traffic_period" (
"world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online"
"id", "world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online"
)
SELECT
${existingRowIdOrNext('traffic_period', GamePrisma.sql`stable_id.world_state_id = ${worldState.id} AND stable_id.year = ${worldState.currentYear} AND stable_id.month = ${worldState.currentMonth}`)},
${worldState.id},
${worldState.currentYear},
${worldState.currentMonth},
@@ -343,10 +352,11 @@ export const flushDeferredGeneralAccessBatch = async (
),
access_updates AS (
INSERT INTO "general_access_log" (
"general_id", "user_id", "last_refresh", "refresh", "refresh_total",
"id", "general_id", "user_id", "last_refresh", "refresh", "refresh_total",
"refresh_score", "refresh_score_total"
)
SELECT
${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = resolved."general_id"`)},
resolved."general_id",
resolved."user_id",
resolved."last_refresh",
@@ -471,11 +481,9 @@ export class DeferredGeneralAccessWorker {
await this.redis.del(key);
return;
}
await this.redis.set(
key,
JSON.stringify({ nextAccessAt: state.nextAccessAt.toISOString() }),
{ PX: Math.max(1, state.nextAccessAt.getTime() - now.getTime()) }
);
await this.redis.set(key, JSON.stringify({ nextAccessAt: state.nextAccessAt.toISOString() }), {
PX: Math.max(1, state.nextAccessAt.getTime() - now.getTime()),
});
})
);
}
@@ -1,3 +1,4 @@
import { existingRowIdOrNext } from '@sammo-ts/infra';
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
@@ -269,6 +270,7 @@ export const upsertGeneralAccess = async (
RETURNING id
)
INSERT INTO traffic_period (
id,
world_state_id,
year,
month,
@@ -277,6 +279,7 @@ export const upsertGeneralAccess = async (
refresh
)
VALUES (
${existingRowIdOrNext('traffic_period', GamePrisma.sql`stable_id.world_state_id = ${input.worldStateId} AND stable_id.year = ${input.year} AND stable_id.month = ${input.month}`)},
${input.worldStateId},
${input.year},
${input.month},
@@ -341,6 +344,7 @@ export const upsertGeneralAccess = async (
await transaction.$executeRaw(
GamePrisma.sql`
INSERT INTO general_access_log (
id,
general_id,
user_id,
last_refresh,
@@ -350,6 +354,7 @@ export const upsertGeneralAccess = async (
refresh_score_total
)
VALUES (
${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = ${input.generalId}`)},
${input.generalId},
${input.userId},
${input.now},
@@ -1,3 +1,4 @@
import { existingRowIdOrNext } from '@sammo-ts/infra';
import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js';
@@ -20,11 +21,13 @@ export const recordGeneralActivity = async (
const written = await ctx.db.$executeRaw(
GamePrisma.sql`
INSERT INTO general_access_log (
id,
general_id,
user_id,
last_action_at
)
SELECT
${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = "general".id`)},
id,
${user.id},
${now}
+2 -1
View File
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { asNumber, asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import {
@@ -132,7 +133,7 @@ export const setInheritancePoint = async (
key: InheritPointKey,
value: number
): Promise<void> => {
await db.inheritancePoint.upsert({
await upsertWithoutSequence(db.inheritancePoint, {
where: {
userId_key: {
userId,
@@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
DeferredGeneralAccessWorker,
flushDeferredGeneralAccessBatch,
enqueueDeferredGeneralAccess,
getDeferredGeneralAccessLimit,
} from '../src/services/deferredGeneralAccess.js';
@@ -91,7 +92,7 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => {
await redis.disconnect();
}
if (db) {
await db.generalAccessBatch.deleteMany({ where: { id: batchId } });
await db.generalAccessBatch.deleteMany({ where: { id: { startsWith: batchId } } });
await db.generalAccessLog.deleteMany({ where: { generalId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.worldState.deleteMany({ where: { id: worldStateId } });
@@ -153,9 +154,19 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => {
nextAccessAt,
});
const counters =
await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq`;
const accessId = (await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id;
await flushDeferredGeneralAccessBatch(workerDb as never, `${batchId}-second`, [
{ generalId, userId, weight: 1, lastRefresh: now },
]);
expect(
await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq`
).toEqual(counters);
expect((await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id).toBe(accessId);
await worker.flushOnce();
await expect(db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).resolves.toMatchObject({
refreshTotal: 2,
refreshTotal: 3,
});
});
});
@@ -25,7 +25,7 @@ const deferredBatchId = `deferred-access-${endpointGeneralId}`;
const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({
version: 1,
profile: `${yearbookProfile}:default`,
profile: yearbookProfile,
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: `access-session-${endpointGeneralId}`,
@@ -167,6 +167,9 @@ integration('general access tracking persistence', () => {
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
};
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
const counters =
await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq`;
const accessId = (await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id;
await Promise.all(
Array.from({ length: 20 }, (_, index) =>
upsertGeneralAccess(db, {
@@ -177,6 +180,10 @@ integration('general access tracking persistence', () => {
)
);
expect(
await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq`
).toEqual(counters);
expect((await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id).toBe(accessId);
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
userId: 'access-user-a',
refresh: 22,
+2 -8
View File
@@ -69,8 +69,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: BigInt(index + 1),
const rows: GeneralTurnRow[] = dataList.map((row) => ({
generalId: row.generalId as number,
turnIdx: row.turnIdx as number,
actionCode: row.actionCode as string,
@@ -136,8 +135,7 @@ const buildDb = (autorunLimit: number | null = null) => {
},
createMany: async ({ data }: NationTurnCreateManyArgs) => {
const dataList = (Array.isArray(data) ? data : [data]) as Record<string, unknown>[];
const rows: NationTurnRow[] = dataList.map((row, index: number) => ({
id: BigInt(index + 1),
const rows: NationTurnRow[] = dataList.map((row) => ({
nationId: row.nationId as number,
officerLevel: row.officerLevel as number,
turnIdx: row.turnIdx as number,
@@ -207,7 +205,6 @@ describe('reservedTurns', () => {
async () =>
[
{
id: 1n,
nationId: 4,
officerLevel: 12,
turnIdx: 1,
@@ -216,7 +213,6 @@ describe('reservedTurns', () => {
createdAt: new Date(),
},
{
id: 2n,
nationId: 4,
officerLevel: 10,
turnIdx: 0,
@@ -414,7 +410,6 @@ describe('reservedTurns', () => {
nationRevisions.set('6:12', 2);
nationTurns.set('6:12', [
{
id: 1n,
nationId: 6,
officerLevel: 12,
turnIdx: 0,
@@ -423,7 +418,6 @@ describe('reservedTurns', () => {
createdAt: new Date(),
},
{
id: 2n,
nationId: 6,
officerLevel: 12,
turnIdx: 1,
-4
View File
@@ -901,7 +901,6 @@ describe('appRouter', () => {
const general = buildGeneralRow({ id: 11, meta: { autorun_limit: 2408 } });
const generalTurns: GeneralTurnRow[] = [
{
id: 1n,
generalId: 11,
turnIdx: 0,
actionCode: 'che_화계',
@@ -922,7 +921,6 @@ describe('appRouter', () => {
const general = buildGeneralRow({ id: 12, nationId: 3, officerLevel: 5 });
const nationTurns: NationTurnRow[] = [
{
id: 1n,
nationId: 3,
officerLevel: 5,
turnIdx: 0,
@@ -1008,7 +1006,6 @@ describe('appRouter', () => {
general,
generalTurns: [
{
id: 1n,
generalId: general.id,
turnIdx: 0,
actionCode: 'che_훈련',
@@ -1016,7 +1013,6 @@ describe('appRouter', () => {
createdAt: new Date(),
},
{
id: 2n,
generalId: general.id,
turnIdx: 1,
actionCode: 'che_사기진작',
@@ -969,7 +969,7 @@ integration('game API security over HTTP transport', () => {
const telemetryAfter = await readAccessTelemetryState();
expect(telemetryAfter.periods).toEqual([
{
id: expect.any(Number),
id: expect.any(BigInt),
worldStateId: fixtureWorldId,
year: 190,
month: 1,
@@ -992,7 +992,7 @@ integration('game API security over HTTP transport', () => {
]);
expect(telemetryAfter.accessLogs).toEqual([
{
id: expect.any(Number),
id: expect.any(BigInt),
generalId,
userId,
lastRefresh: trafficPeriod.lastRefresh,
@@ -1548,8 +1548,8 @@ integration('game API security over HTTP transport', () => {
arg,
}))
).toEqual(databaseAfter.generalTurns);
expect(new Set(committedGeneralTurns.map(({ id }) => id)).size).toBe(30);
expect(committedGeneralTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
expect(new Set(committedGeneralTurns.map(({ generalId, turnIdx }) => `${generalId}:${turnIdx}`)).size).toBe(30);
expect(committedGeneralTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true);
expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId !== generalId)).toEqual(
allowedTablesBefore.generalTurnRevisions.filter((row) => row.generalId !== generalId)
);
@@ -1567,7 +1567,7 @@ integration('game API security over HTTP transport', () => {
);
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([
{
id: expect.any(Number),
id: expect.any(BigInt),
generalId,
userId,
lastRefresh: null,
@@ -1733,8 +1733,14 @@ integration('game API security over HTTP transport', () => {
arg,
}))
).toEqual(expectedTurns);
expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12);
expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
expect(
new Set(
committedNationTurns.map(
({ nationId, officerLevel, turnIdx }) => `${nationId}:${officerLevel}:${turnIdx}`
)
).size
).toBe(12);
expect(committedNationTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true);
expect(
allowedTablesAfter.nationTurnRevisions.filter(
(row) => row.nationId !== ownerNationId || row.officerLevel !== 5
@@ -1763,7 +1769,7 @@ integration('game API security over HTTP transport', () => {
);
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === sameNationGeneralId)).toEqual([
{
id: expect.any(Number),
id: expect.any(BigInt),
generalId: sameNationGeneralId,
userId: sameNationUserId,
lastRefresh: null,
@@ -1880,8 +1886,14 @@ integration('game API security over HTTP transport', () => {
arg,
}))
).toEqual(committed.nationTurns);
expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12);
expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
expect(
new Set(
committedNationTurns.map(
({ nationId, officerLevel, turnIdx }) => `${nationId}:${officerLevel}:${turnIdx}`
)
).size
).toBe(12);
expect(committedNationTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true);
expect(
allowedTablesAfter.nationTurnRevisions.filter(
(row) => row.nationId !== ownerNationId || row.officerLevel !== 12
@@ -1910,7 +1922,7 @@ integration('game API security over HTTP transport', () => {
);
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([
{
id: expect.any(Number),
id: expect.any(BigInt),
generalId,
userId,
lastRefresh: null,
@@ -2868,7 +2880,7 @@ integration('game API security over HTTP transport', () => {
result: {
data: {
coverage: 'IDENTIFIED_LOGS_ONLY',
items: [{ id: ownLogs[index]!.id }],
items: [{ id: Number(ownLogs[index]!.id) }],
nextCursor: null,
},
},
@@ -2877,17 +2889,19 @@ integration('game API security over HTTP transport', () => {
}
expect(
(await get('generalLogs', admin, { generalId: logGeneralId, type: 'generalHistory', limit: 1 })).body
).toMatchObject({ result: { data: { items: [{ id: latest.id }], nextCursor: latest.id } } });
).toMatchObject({
result: { data: { items: [{ id: Number(latest.id) }], nextCursor: Number(latest.id) } },
});
expect(
(
await get('generalLogs', admin, {
generalId: logGeneralId,
type: 'generalHistory',
limit: 1,
cursor: latest.id,
cursor: Number(latest.id),
})
).body
).toMatchObject({ result: { data: { items: [{ id: ownLogs[0]!.id }], nextCursor: null } } });
).toMatchObject({ result: { data: { items: [{ id: Number(ownLogs[0]!.id) }], nextCursor: null } } });
for (const invalid of [
{ limit: 201 },
{ cursor: 0 },
+5 -3
View File
@@ -1,3 +1,4 @@
import { existingRowIdOrNext } from '@sammo-ts/infra';
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { randomUUID } from 'node:crypto';
@@ -580,8 +581,8 @@ export const createAuctionBidder = async (options: {
}
await tx.$executeRaw(
GamePrisma.sql`
INSERT INTO rank_data (nation_id, general_id, type, value)
SELECT nation_id, id, 'inherit_spent_dyn', ${morePoint}
INSERT INTO rank_data (id, nation_id, general_id, type, value)
SELECT ${existingRowIdOrNext('rank_data', GamePrisma.sql`stable_id.general_id = "general".id AND stable_id.type = 'inherit_spent_dyn'`)}, nation_id, id, 'inherit_spent_dyn', ${morePoint}
FROM general
WHERE id = ${command.generalId}
ON CONFLICT (general_id, type)
@@ -598,8 +599,9 @@ export const createAuctionBidder = async (options: {
}
await tx.$executeRaw(
GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at)
INSERT INTO inheritance_point (id, user_id, key, value, updated_at)
VALUES (
${existingRowIdOrNext('inheritance_point', GamePrisma.sql`stable_id.user_id = ${prevUserId} AND stable_id.key = 'previous'`)},
${prevUserId},
'previous',
${highestBid.amount},
+3 -2
View File
@@ -1,3 +1,4 @@
import { existingRowIdOrNext } from '@sammo-ts/infra';
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
@@ -212,8 +213,8 @@ const refundInheritancePoint = async (options: {
}
await prisma.$executeRaw(
GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at)
VALUES (${userId}, 'previous', ${amount}, ${new Date()})
INSERT INTO inheritance_point (id, user_id, key, value, updated_at)
VALUES (${existingRowIdOrNext('inheritance_point', GamePrisma.sql`stable_id.user_id = ${userId} AND stable_id.key = 'previous'`)}, ${userId}, 'previous', ${amount}, ${new Date()})
ON CONFLICT (user_id, key)
DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value,
+3 -2
View File
@@ -1,3 +1,4 @@
import { existingRowIdOrNext } from '@sammo-ts/infra';
import { randomUUID } from 'node:crypto';
import { asRecord, JosaUtil } from '@sammo-ts/common';
@@ -289,8 +290,8 @@ const openUniqueAuction = async (
});
await db.$executeRaw(
GamePrisma.sql`
INSERT INTO rank_data (nation_id, general_id, type, value)
VALUES (${general.nationId}, ${general.id}, 'inherit_spent_dyn', ${command.amount})
INSERT INTO rank_data (id, nation_id, general_id, type, value)
VALUES (${existingRowIdOrNext('rank_data', GamePrisma.sql`stable_id.general_id = ${general.id} AND stable_id.type = 'inherit_spent_dyn'`)}, ${general.nationId}, ${general.id}, 'inherit_spent_dyn', ${command.amount})
ON CONFLICT (general_id, type)
DO UPDATE SET
nation_id = EXCLUDED.nation_id,
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
@@ -424,7 +425,7 @@ const cancelGameInTransaction = async (
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
});
const currentPoint = pointsByUser.get(userId)?.get('previous') ?? 0;
await prisma.inheritancePoint.upsert({
await upsertWithoutSequence(prisma.inheritancePoint, {
where: { userId_key: { userId, key: 'previous' } },
update: { value: calculated.finalPoint },
create: { userId, key: 'previous', value: calculated.finalPoint },
@@ -470,7 +471,7 @@ const cancelGameInTransaction = async (
}
for (const general of activeGenerals) {
if (!general.userId || general.npcState >= 2) continue;
await prisma.oldGeneral.upsert({
await upsertWithoutSequence(prisma.oldGeneral, {
where: { by_no: { serverId, generalNo: general.id } },
update: {
owner: general.userId,
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { prunePreviousAuditBatch } from '../playAudit/retention.js';
import { randomBytes } from 'node:crypto';
@@ -497,7 +498,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
);
}
}
await prisma.gameHistory.upsert({
await upsertWithoutSequence(prisma.gameHistory, {
where: { serverId: worldMeta.serverId },
create: {
serverId: worldMeta.serverId,
+2 -1
View File
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js';
import { JosaUtil, asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
@@ -256,7 +257,7 @@ export const createTournamentRewardFinalizer = async (options: {
const recordsFinalized = await areSeasonRecordsFinalized(db, world.getState().meta.serverId);
for (const entry of recordsFinalized ? [] : pointUpdates) {
await db.inheritancePoint.upsert({
await upsertWithoutSequence(db.inheritancePoint, {
where: {
userId_key: { userId: entry.userId!, key: 'tournament' },
},
+5 -4
View File
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
import { collectPlayAudit, markAuditGap } from '../playAudit/bestEffort.js';
import { persistAuditDiplomacyEvents, withPlayAuditSavepoint } from '@sammo-ts/infra';
@@ -697,12 +698,12 @@ const persistNationBettingFinish = async (
});
const previousPoint = existing?.value ?? 0;
const nextPoint = previousPoint + reward.amount;
await prisma.inheritancePoint.upsert({
await upsertWithoutSequence(prisma.inheritancePoint, {
where: { userId_key: { userId: reward.userId, key: 'previous' } },
update: { value: nextPoint },
create: { userId: reward.userId, key: 'previous', value: nextPoint },
});
await prisma.rankData.upsert({
await upsertWithoutSequence(prisma.rankData, {
where: {
generalId_type: {
generalId: reward.generalId,
@@ -1574,7 +1575,7 @@ export const createDatabaseTurnHooks = async (
}
}
for (const entry of grouped.values()) {
await prisma.inheritancePoint.upsert({
await upsertWithoutSequence(prisma.inheritancePoint, {
where: { userId_key: { userId: entry.userId, key: entry.key } },
update: { value: { increment: entry.amount } },
create: { userId: entry.userId, key: entry.key, value: entry.amount },
@@ -1642,7 +1643,7 @@ export const createDatabaseTurnHooks = async (
}
await Promise.all(
deletedNationSnapshots.map((snapshot) =>
prisma.oldNation.upsert({
upsertWithoutSequence(prisma.oldNation, {
where: {
serverId_nation_sourceId: {
serverId,
@@ -1,3 +1,4 @@
import { existingRowIdOrNext } from '@sammo-ts/infra';
import { GamePrisma, type TurnEngineGeneralUpdateInput } from '@sammo-ts/infra';
// Fixed schema identifiers only; all row values remain bound parameters.
@@ -91,8 +92,8 @@ export const persistGeneralAccessScores = async (
for (let offset = 0; offset < updates.length; offset += GENERAL_UPDATE_BATCH_SIZE) {
const batch = updates.slice(offset, offset + GENERAL_UPDATE_BATCH_SIZE);
await database.$executeRaw(GamePrisma.sql`
INSERT INTO "general_access_log" ("general_id", "user_id", "refresh_score_total")
VALUES ${GamePrisma.join(batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.userId}, ${row.refreshScoreTotal})`))}
INSERT INTO "general_access_log" ("id", "general_id", "user_id", "refresh_score_total")
VALUES ${GamePrisma.join(batch.map((row) => GamePrisma.sql`(${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = ${row.generalId}`)}, ${row.generalId}, ${row.userId}, ${row.refreshScoreTotal})`))}
ON CONFLICT ("general_id") DO UPDATE
SET "refresh_score_total" = EXCLUDED."refresh_score_total"
`);
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import {
asRecord,
@@ -77,7 +78,7 @@ const persistPostRetirementRankValues = async (
return;
}
for (const row of buildPersistedRankRows(event.after)) {
await prisma.rankData.upsert({
await upsertWithoutSequence(prisma.rankData, {
where: { generalId_type: { generalId: row.generalId, type: row.type } },
update: { nationId: row.nationId, value: row.value },
create: row,
@@ -142,7 +143,7 @@ const settleInheritance = async (
);
const total = Math.trunc(previous + refund + settlement.totalEarned);
await prisma.inheritancePoint.upsert({
await upsertWithoutSequence(prisma.inheritancePoint, {
where: { userId_key: { userId, key: 'previous' } },
update: { value: total },
create: { userId, key: 'previous', value: total },
@@ -152,7 +153,7 @@ const settleInheritance = async (
([key, value]) => key === 'max_belong' || points.has(key) || value !== 0
);
for (const [key, value] of retainedEntries) {
await prisma.inheritancePoint.upsert({
await upsertWithoutSequence(prisma.inheritancePoint, {
where: { userId_key: { userId, key } },
update: { value },
create: { userId, key, value },
@@ -367,7 +368,7 @@ const archiveGeneral = async (
records: { battleResult: battleResults },
availability: { battleResultLogs: true },
};
await prisma.oldGeneral.upsert({
await upsertWithoutSequence(prisma.oldGeneral, {
where: { by_no: { serverId, generalNo: event.generalId } },
update: {
owner: event.before.userId ?? null,
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
@@ -246,7 +247,7 @@ const applyInheritanceUser = async (
};
const setInheritancePoint = async (db: DatabaseClient, userId: string, value: number): Promise<void> => {
await db.inheritancePoint.upsert({
await upsertWithoutSequence(db.inheritancePoint, {
where: { userId_key: { userId, key: 'previous' } },
update: { value },
create: { userId, key: 'previous', value },
@@ -860,7 +861,7 @@ export const createGeneralFromJoin = async (options: {
await db.generalTurnRevision.create({
data: { generalId, revision: 0 },
});
await db.generalAccessLog.upsert({
await upsertWithoutSequence(db.generalAccessLog, {
where: { generalId },
update: { userId: input.userId, lastRefresh: operationalAcceptedAt },
create: {
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { randomInt } from 'node:crypto';
import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
@@ -337,18 +338,12 @@ export const reserveNpcPossessionCandidates = async (options: {
}
return toReservation(inFlightToken, options.createdGameTick, ticksPerSecond);
}
if (
existing &&
(existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick)
) {
if (existing && (existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick)) {
await db.npcSelectionToken.deleteMany({
where: {
ownerUserId: userId,
nonce: existing.nonce,
OR: [
{ validUntilTick: null },
{ validUntilTick: { lt: BigInt(options.createdGameTick) } },
],
OR: [{ validUntilTick: null }, { validUntilTick: { lt: BigInt(options.createdGameTick) } }],
},
});
existing = null;
@@ -425,9 +420,7 @@ export const reserveNpcPossessionCandidates = async (options: {
const validUntilTick = options.createdGameTick + Math.round(validSeconds * ticksPerSecond);
const pickMoreFromTick = options.createdGameTick + Math.round(pickMoreSeconds * ticksPerSecond);
const validUntil = new Date(now.getTime() + validSeconds * 1000);
const refreshedPickMoreFrom = new Date(
now.getTime() + pickMoreSeconds * 1000
);
const refreshedPickMoreFrom = new Date(now.getTime() + pickMoreSeconds * 1000);
const nonce = randomInt(0, 0x10000000);
if (existing) {
@@ -566,7 +559,7 @@ export const possessNpcGeneral = async (options: {
},
});
await db.generalAccessLog.upsert({
await upsertWithoutSequence(db.generalAccessLog, {
where: { generalId },
update: {
userId,
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { z } from 'zod';
import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
@@ -567,11 +568,7 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor
}
};
const clearUnusedReservations = async (
db: DatabaseClient,
userId: string,
nowTick: number
): Promise<void> => {
const clearUnusedReservations = async (db: DatabaseClient, userId: string, nowTick: number): Promise<void> => {
await db.selectPoolEntry.updateMany({
where: {
generalId: null,
@@ -914,7 +911,7 @@ export const createGeneralFromSelectionPool = async (options: {
if (occupied.count === 0) {
throw new Error('장수 등록 중 선택 후보 점유에 실패했습니다.');
}
await db.generalAccessLog.upsert({
await upsertWithoutSequence(db.generalAccessLog, {
where: { generalId },
update: { userId, lastRefresh: options.operationalAcceptedAt },
create: { generalId, userId, lastRefresh: options.operationalAcceptedAt },
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
@@ -209,12 +210,12 @@ const cancelPendingUniqueAuctions = async (
if (!bidder?.userId || bidder.userId !== dbBidder?.userId) {
throw new Error(`Unification auction refund owner is unavailable: ${planned.auctionId}.`);
}
await transaction.inheritancePoint.upsert({
await upsertWithoutSequence(transaction.inheritancePoint, {
where: { userId_key: { userId: bidder.userId, key: 'previous' } },
update: { value: { increment: highest.amount } },
create: { userId: bidder.userId, key: 'previous', value: highest.amount },
});
await transaction.rankData.upsert({
await upsertWithoutSequence(transaction.rankData, {
where: {
generalId_type: { generalId: bidder.id, type: 'inherit_spent_dyn' },
},
@@ -345,7 +346,7 @@ export const persistUnificationFinalization = async (
) as Record<(typeof ALL_MERGED_INHERITANCE_KEYS)[number], number>;
const total = Math.floor(previous + Object.values(mergedPoints).reduce((sum, value) => sum + value, 0));
await transaction.inheritancePoint.upsert({
await upsertWithoutSequence(transaction.inheritancePoint, {
where: { userId_key: { userId, key: 'previous' } },
update: { value: total },
create: { userId, key: 'previous', value: total },
@@ -508,7 +509,7 @@ export const persistUnificationFinalization = async (
}),
generationKey: input.generationKey,
};
await transaction.oldNation.upsert({
await upsertWithoutSequence(transaction.oldNation, {
where: { serverId_nation_sourceId: { serverId, nation: input.winnerNationId, sourceId: 0 } },
update: { data: asJson(winnerData), date: input.completedAt },
create: {
@@ -525,7 +526,7 @@ export const persistUnificationFinalization = async (
generals: neutralGenerals.map((general) => general.id),
generationKey: input.generationKey,
};
await transaction.oldNation.upsert({
await upsertWithoutSequence(transaction.oldNation, {
where: { serverId_nation_sourceId: { serverId, nation: 0, sourceId: 0 } },
update: { data: neutralData, date: input.completedAt },
create: { serverId, nation: 0, sourceId: 0, data: neutralData, date: input.completedAt },
@@ -572,7 +573,7 @@ export const persistUnificationFinalization = async (
availability: { battleResultLogs: true },
generationKey: input.generationKey,
};
await transaction.oldGeneral.upsert({
await upsertWithoutSequence(transaction.oldGeneral, {
where: { by_no: { serverId, generalNo: general.id } },
update: {
owner: general.userId ?? null,
@@ -1,3 +1,4 @@
import { existingRowIdOrNext } from '@sammo-ts/infra';
import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js';
import type {
TurnDaemonCommandHandler,
@@ -2864,8 +2865,8 @@ const insertVoteSelection = async (
}
const createdAt = new Date();
const rows = await ctx.commandDb.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
INSERT INTO vote (vote_id, general_id, nation_id, selection, created_at)
SELECT poll.id,
INSERT INTO vote (id, vote_id, general_id, nation_id, selection, created_at)
SELECT ${existingRowIdOrNext('vote', GamePrisma.sql`stable_id.vote_id = poll.id AND stable_id.general_id = ${general.id}`)}, poll.id,
${general.id},
${general.nationId},
CAST(${JSON.stringify(selection)} AS jsonb),
@@ -1,3 +1,4 @@
import { upsertWithoutSequence } from '@sammo-ts/infra';
import { createHash } from 'node:crypto';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
@@ -44,7 +45,7 @@ export const persistYearbookSnapshot = async (
globalAction,
});
await transaction.yearbookHistory.upsert({
await upsertWithoutSequence(transaction.yearbookHistory, {
where: {
profileName_year_month_sourceId: {
profileName: snapshot.serverId,
@@ -198,6 +198,23 @@ integration('general batch persistence', () => {
);
});
expect(writes).toBe(2);
const originalIds = (
await db.generalAccessLog.findMany({
where: { generalId: { gte: firstId, lte: firstId + GENERAL_UPDATE_BATCH_SIZE } },
orderBy: { generalId: 'asc' },
})
).map((row) => row.id);
const sequence = await db.$queryRaw`SELECT last_value FROM general_access_log_id_seq`;
await db.$transaction((tx) => persistGeneralAccessScores(tx, updates));
expect(
(
await db.generalAccessLog.findMany({
where: { generalId: { gte: firstId, lte: firstId + GENERAL_UPDATE_BATCH_SIZE } },
orderBy: { generalId: 'asc' },
})
).map((row) => row.id)
).toEqual(originalIds);
expect(await db.$queryRaw`SELECT last_value FROM general_access_log_id_seq`).toEqual(sequence);
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: firstId } })).toMatchObject({
userId: 'original-owner',
lastRefresh: at,
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrisma } from '@sammo-ts/infra';
import { GamePrisma } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral } from '../src/turn/types.js';
// This harness begins without the durable archive/point row.
const missingRecordUpdate = async (): Promise<never> => {
throw new GamePrisma.PrismaClientKnownRequestError('fixture record missing', {
code: 'P2025',
clientVersion: 'fixture',
});
};
const archivedGeneral = (): TurnGeneral => ({
id: 91,
userId: null,
@@ -65,12 +73,13 @@ describe('general lifecycle archive history', () => {
]),
},
rankData: {
update: missingRecordUpdate,
findMany: vi.fn(async () => [
{ type: 'warnum', value: 2 },
{ type: 'killnum', value: 1 },
]),
},
oldGeneral: { upsert },
oldGeneral: { update: missingRecordUpdate, upsert },
} as unknown as GamePrisma.TransactionClient;
const event: GeneralLifecycleEvent = {
generalId: general.id,
@@ -137,6 +146,7 @@ describe('general lifecycle archive history', () => {
updateMany: vi.fn(async () => ({ count: 1 })),
},
rankData: {
update: missingRecordUpdate,
findMany: vi.fn(async () => [
{ type: 'warnum', value: 10 },
{ type: 'inherit_earned', value: 123 },
@@ -156,6 +166,7 @@ describe('general lifecycle archive history', () => {
update: vi.fn(async () => undefined),
},
inheritancePoint: {
update: missingRecordUpdate,
findMany: vi.fn(async () => [{ key: 'previous', value: 0 }]),
upsert: vi.fn(async () => undefined),
deleteMany: vi.fn(async () => ({ count: 0 })),
@@ -68,8 +68,6 @@ describe.skipIf(!databaseUrl)('rank data bigint PostgreSQL persistence', () => {
it('reloads bigint IDs for high churn queues, logs, upserts and the traffic foreign key', async () => {
const tables = [
'general_turn',
'nation_turn',
'general_access_log',
'diplomacy',
'inheritance_point',
@@ -93,10 +91,6 @@ describe.skipIf(!databaseUrl)('rank data bigint PostgreSQL persistence', () => {
},
});
const rows = await Promise.all([
tx.generalTurn.create({ data: { generalId, turnIdx: 0, actionCode: 'fixture' } }),
tx.nationTurn.create({
data: { nationId: 3, officerLevel: 12, turnIdx: 0, actionCode: 'fixture' },
}),
tx.generalAccessLog.create({ data: { generalId } }),
tx.diplomacy.create({ data: { srcNationId: 3, destNationId: 4, stateCode: 0 } }),
tx.inheritancePoint.create({ data: { userId: 'bigint-fixture', key: 'previous', value: 123 } }),
@@ -109,6 +103,14 @@ describe.skipIf(!databaseUrl)('rank data bigint PostgreSQL persistence', () => {
}),
]);
expect(rows.every((row) => row.id > 2_147_483_647n)).toBe(true);
const generalQueue = await tx.generalTurn.create({
data: { generalId, turnIdx: 0, actionCode: 'fixture' },
});
const nationQueue = await tx.nationTurn.create({
data: { nationId: 3, officerLevel: 12, turnIdx: 0, actionCode: 'fixture' },
});
expect(generalQueue).not.toHaveProperty('id');
expect(nationQueue).not.toHaveProperty('id');
const period = await tx.trafficPeriod.create({
data: {
worldStateId: world.id,
@@ -122,7 +124,7 @@ describe.skipIf(!databaseUrl)('rank data bigint PostgreSQL persistence', () => {
});
expect(period.id).toBeGreaterThan(2_147_483_647n);
expect(period.generals[0]?.periodId).toBe(period.id);
expect((await tx.logEntry.findUniqueOrThrow({ where: { id: rows[5]!.id } })).text).toBe('fixture');
expect((await tx.logEntry.findUniqueOrThrow({ where: { id: rows[3]!.id } })).text).toBe('fixture');
throw new Error('high churn fixture rollback');
})
).rejects.toThrow('high churn fixture rollback');
@@ -1,13 +1,21 @@
import { describe, expect, it, vi } from 'vitest';
import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { GamePrisma } from '@sammo-ts/infra';
import { LogCategory, LogScope, type City, type Nation } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { persistUnificationFinalization, resolveStoredInheritancePoint } from '../src/turn/unificationPersistence.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
// This harness begins without the durable archive/point row.
const missingRecordUpdate = async (): Promise<never> => {
throw new GamePrisma.PrismaClientKnownRequestError('fixture record missing', {
code: 'P2025',
clientVersion: 'fixture',
});
};
const buildWorld = (): InMemoryTurnWorld => {
const turnTime = new Date('0190-07-01T00:00:00.000Z');
const general: TurnGeneral = {
@@ -206,6 +214,7 @@ describe('persistUnificationFinalization', () => {
create: vi.fn().mockResolvedValue({}),
},
inheritancePoint: {
update: missingRecordUpdate,
findMany: vi.fn().mockResolvedValue([
{ userId: 'user-1', key: 'previous', value: 100 },
{ userId: 'user-1', key: 'unifier', value: 7 },
@@ -216,6 +225,7 @@ describe('persistUnificationFinalization', () => {
inheritanceResult: { create: inheritanceResultCreate },
inheritanceLog: { create: inheritanceLogCreate },
rankData: {
update: missingRecordUpdate,
findMany: vi.fn().mockResolvedValue([
{ generalId: 1, type: 'warnum', value: 15 },
{ generalId: 1, type: 'killnum', value: 9 },
@@ -251,10 +261,11 @@ describe('persistUnificationFinalization', () => {
),
},
oldNation: {
update: missingRecordUpdate,
upsert: oldNationUpsert,
findMany: vi.fn().mockResolvedValue([]),
},
oldGeneral: { upsert: oldGeneralUpsert },
oldGeneral: { update: missingRecordUpdate, upsert: oldGeneralUpsert },
emperor: { create: emperorCreate },
});
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
@@ -1,10 +1,18 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrisma } from '@sammo-ts/infra';
import { GamePrisma } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { persistYearbookSnapshot } from '../src/turn/yearbookPersistence.js';
// This harness begins without the durable archive/point row.
const missingRecordUpdate = async (): Promise<never> => {
throw new GamePrisma.PrismaClientKnownRequestError('fixture record missing', {
code: 'P2025',
clientVersion: 'fixture',
});
};
describe('persistYearbookSnapshot', () => {
it('archives canonical summary logs and compatible action logs together in descending ID order', async () => {
const findMany = vi
@@ -14,7 +22,7 @@ describe('persistYearbookSnapshot', () => {
const upsert = vi.fn().mockResolvedValue(undefined);
const transaction = {
logEntry: { findMany },
yearbookHistory: { upsert },
yearbookHistory: { update: missingRecordUpdate, upsert },
} as unknown as GamePrisma.TransactionClient;
await persistYearbookSnapshot(transaction, {
+1 -1
View File
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20261107000000_kakao_identity_link_history',
gameSchemaHead: '20261114010000_high_churn_bigint_ids',
gameSchemaHead: '20261114020000_reserved_turn_slot_primary_keys',
});
});