갱신 행의 ID를 보존하고 예약턴의 불필요한 시퀀스를 제거한다
This commit is contained in:
1 parent
bc54b157c1
commit
81e101ffef
43 files changed
+587
-113
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -223,7 +224,7 @@ export const bettingRouter = router({
|
|||||||
world.currentMonth,
|
world.currentMonth,
|
||||||
`${input.amount} 포인트를 베팅에 사용`
|
`${input.amount} 포인트를 베팅에 사용`
|
||||||
);
|
);
|
||||||
await ctx.db.rankData.upsert({
|
await upsertWithoutSequence(ctx.db.rankData, {
|
||||||
where: {
|
where: {
|
||||||
generalId_type: {
|
generalId_type: {
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
@@ -245,7 +246,7 @@ export const bettingRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.nationBet.upsert({
|
await upsertWithoutSequence(ctx.db.nationBet, {
|
||||||
where: {
|
where: {
|
||||||
bettingId_userId_selectionKey: {
|
bettingId_userId_selectionKey: {
|
||||||
bettingId: input.bettingId,
|
bettingId: input.bettingId,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existingRowIdOrNext } from '@sammo-ts/infra';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common';
|
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common';
|
||||||
@@ -94,7 +95,13 @@ export const enqueueDeferredGeneralAccess = async (
|
|||||||
}
|
}
|
||||||
await redis.eval(ENQUEUE_SCRIPT, {
|
await redis.eval(ENQUEUE_SCRIPT, {
|
||||||
keys: [activeKey(profileName)],
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -258,9 +265,10 @@ export const flushDeferredGeneralAccessBatch = async (
|
|||||||
),
|
),
|
||||||
missing_periods AS (
|
missing_periods AS (
|
||||||
INSERT INTO "traffic_period" (
|
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
|
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},
|
${worldState.id},
|
||||||
(missing_key / 12)::INTEGER,
|
(missing_key / 12)::INTEGER,
|
||||||
(missing_key % 12 + 1)::INTEGER,
|
(missing_key % 12 + 1)::INTEGER,
|
||||||
@@ -281,9 +289,10 @@ export const flushDeferredGeneralAccessBatch = async (
|
|||||||
),
|
),
|
||||||
period_upsert AS (
|
period_upsert AS (
|
||||||
INSERT INTO "traffic_period" (
|
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
|
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.id},
|
||||||
${worldState.currentYear},
|
${worldState.currentYear},
|
||||||
${worldState.currentMonth},
|
${worldState.currentMonth},
|
||||||
@@ -343,10 +352,11 @@ export const flushDeferredGeneralAccessBatch = async (
|
|||||||
),
|
),
|
||||||
access_updates AS (
|
access_updates AS (
|
||||||
INSERT INTO "general_access_log" (
|
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"
|
"refresh_score", "refresh_score_total"
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
|
${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = resolved."general_id"`)},
|
||||||
resolved."general_id",
|
resolved."general_id",
|
||||||
resolved."user_id",
|
resolved."user_id",
|
||||||
resolved."last_refresh",
|
resolved."last_refresh",
|
||||||
@@ -471,11 +481,9 @@ export class DeferredGeneralAccessWorker {
|
|||||||
await this.redis.del(key);
|
await this.redis.del(key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.redis.set(
|
await this.redis.set(key, JSON.stringify({ nextAccessAt: state.nextAccessAt.toISOString() }), {
|
||||||
key,
|
PX: Math.max(1, state.nextAccessAt.getTime() - now.getTime()),
|
||||||
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 { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
|
||||||
import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
|
import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
@@ -269,6 +270,7 @@ export const upsertGeneralAccess = async (
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
INSERT INTO traffic_period (
|
INSERT INTO traffic_period (
|
||||||
|
id,
|
||||||
world_state_id,
|
world_state_id,
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
@@ -277,6 +279,7 @@ export const upsertGeneralAccess = async (
|
|||||||
refresh
|
refresh
|
||||||
)
|
)
|
||||||
VALUES (
|
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.worldStateId},
|
||||||
${input.year},
|
${input.year},
|
||||||
${input.month},
|
${input.month},
|
||||||
@@ -341,6 +344,7 @@ export const upsertGeneralAccess = async (
|
|||||||
await transaction.$executeRaw(
|
await transaction.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO general_access_log (
|
INSERT INTO general_access_log (
|
||||||
|
id,
|
||||||
general_id,
|
general_id,
|
||||||
user_id,
|
user_id,
|
||||||
last_refresh,
|
last_refresh,
|
||||||
@@ -350,6 +354,7 @@ export const upsertGeneralAccess = async (
|
|||||||
refresh_score_total
|
refresh_score_total
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
|
${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = ${input.generalId}`)},
|
||||||
${input.generalId},
|
${input.generalId},
|
||||||
${input.userId},
|
${input.userId},
|
||||||
${input.now},
|
${input.now},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existingRowIdOrNext } from '@sammo-ts/infra';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { GameApiContext } from '../context.js';
|
import type { GameApiContext } from '../context.js';
|
||||||
@@ -20,11 +21,13 @@ export const recordGeneralActivity = async (
|
|||||||
const written = await ctx.db.$executeRaw(
|
const written = await ctx.db.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO general_access_log (
|
INSERT INTO general_access_log (
|
||||||
|
id,
|
||||||
general_id,
|
general_id,
|
||||||
user_id,
|
user_id,
|
||||||
last_action_at
|
last_action_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
|
${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = "general".id`)},
|
||||||
id,
|
id,
|
||||||
${user.id},
|
${user.id},
|
||||||
${now}
|
${now}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
import {
|
import {
|
||||||
@@ -132,7 +133,7 @@ export const setInheritancePoint = async (
|
|||||||
key: InheritPointKey,
|
key: InheritPointKey,
|
||||||
value: number
|
value: number
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await db.inheritancePoint.upsert({
|
await upsertWithoutSequence(db.inheritancePoint, {
|
||||||
where: {
|
where: {
|
||||||
userId_key: {
|
userId_key: {
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DeferredGeneralAccessWorker,
|
DeferredGeneralAccessWorker,
|
||||||
|
flushDeferredGeneralAccessBatch,
|
||||||
enqueueDeferredGeneralAccess,
|
enqueueDeferredGeneralAccess,
|
||||||
getDeferredGeneralAccessLimit,
|
getDeferredGeneralAccessLimit,
|
||||||
} from '../src/services/deferredGeneralAccess.js';
|
} from '../src/services/deferredGeneralAccess.js';
|
||||||
@@ -91,7 +92,7 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => {
|
|||||||
await redis.disconnect();
|
await redis.disconnect();
|
||||||
}
|
}
|
||||||
if (db) {
|
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.generalAccessLog.deleteMany({ where: { generalId } });
|
||||||
await db.general.deleteMany({ where: { id: generalId } });
|
await db.general.deleteMany({ where: { id: generalId } });
|
||||||
await db.worldState.deleteMany({ where: { id: worldStateId } });
|
await db.worldState.deleteMany({ where: { id: worldStateId } });
|
||||||
@@ -153,9 +154,19 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => {
|
|||||||
nextAccessAt,
|
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 worker.flushOnce();
|
||||||
await expect(db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).resolves.toMatchObject({
|
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 => ({
|
const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
profile: `${yearbookProfile}:default`,
|
profile: yearbookProfile,
|
||||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||||
sessionId: `access-session-${endpointGeneralId}`,
|
sessionId: `access-session-${endpointGeneralId}`,
|
||||||
@@ -167,6 +167,9 @@ integration('general access tracking persistence', () => {
|
|||||||
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||||
};
|
};
|
||||||
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
|
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(
|
await Promise.all(
|
||||||
Array.from({ length: 20 }, (_, index) =>
|
Array.from({ length: 20 }, (_, index) =>
|
||||||
upsertGeneralAccess(db, {
|
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({
|
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||||
userId: 'access-user-a',
|
userId: 'access-user-a',
|
||||||
refresh: 22,
|
refresh: 22,
|
||||||
|
|||||||
@@ -69,8 +69,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) => ({
|
||||||
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,
|
||||||
@@ -136,8 +135,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) => ({
|
||||||
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,
|
||||||
@@ -207,7 +205,6 @@ describe('reservedTurns', () => {
|
|||||||
async () =>
|
async () =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: 1n,
|
|
||||||
nationId: 4,
|
nationId: 4,
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
turnIdx: 1,
|
turnIdx: 1,
|
||||||
@@ -216,7 +213,6 @@ describe('reservedTurns', () => {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2n,
|
|
||||||
nationId: 4,
|
nationId: 4,
|
||||||
officerLevel: 10,
|
officerLevel: 10,
|
||||||
turnIdx: 0,
|
turnIdx: 0,
|
||||||
@@ -414,7 +410,6 @@ describe('reservedTurns', () => {
|
|||||||
nationRevisions.set('6:12', 2);
|
nationRevisions.set('6:12', 2);
|
||||||
nationTurns.set('6:12', [
|
nationTurns.set('6:12', [
|
||||||
{
|
{
|
||||||
id: 1n,
|
|
||||||
nationId: 6,
|
nationId: 6,
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
turnIdx: 0,
|
turnIdx: 0,
|
||||||
@@ -423,7 +418,6 @@ describe('reservedTurns', () => {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2n,
|
|
||||||
nationId: 6,
|
nationId: 6,
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
turnIdx: 1,
|
turnIdx: 1,
|
||||||
|
|||||||
@@ -901,7 +901,6 @@ 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: 1n,
|
|
||||||
generalId: 11,
|
generalId: 11,
|
||||||
turnIdx: 0,
|
turnIdx: 0,
|
||||||
actionCode: 'che_화계',
|
actionCode: 'che_화계',
|
||||||
@@ -922,7 +921,6 @@ 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: 1n,
|
|
||||||
nationId: 3,
|
nationId: 3,
|
||||||
officerLevel: 5,
|
officerLevel: 5,
|
||||||
turnIdx: 0,
|
turnIdx: 0,
|
||||||
@@ -1008,7 +1006,6 @@ describe('appRouter', () => {
|
|||||||
general,
|
general,
|
||||||
generalTurns: [
|
generalTurns: [
|
||||||
{
|
{
|
||||||
id: 1n,
|
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
turnIdx: 0,
|
turnIdx: 0,
|
||||||
actionCode: 'che_훈련',
|
actionCode: 'che_훈련',
|
||||||
@@ -1016,7 +1013,6 @@ describe('appRouter', () => {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2n,
|
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
turnIdx: 1,
|
turnIdx: 1,
|
||||||
actionCode: 'che_사기진작',
|
actionCode: 'che_사기진작',
|
||||||
|
|||||||
@@ -969,7 +969,7 @@ integration('game API security over HTTP transport', () => {
|
|||||||
const telemetryAfter = await readAccessTelemetryState();
|
const telemetryAfter = await readAccessTelemetryState();
|
||||||
expect(telemetryAfter.periods).toEqual([
|
expect(telemetryAfter.periods).toEqual([
|
||||||
{
|
{
|
||||||
id: expect.any(Number),
|
id: expect.any(BigInt),
|
||||||
worldStateId: fixtureWorldId,
|
worldStateId: fixtureWorldId,
|
||||||
year: 190,
|
year: 190,
|
||||||
month: 1,
|
month: 1,
|
||||||
@@ -992,7 +992,7 @@ integration('game API security over HTTP transport', () => {
|
|||||||
]);
|
]);
|
||||||
expect(telemetryAfter.accessLogs).toEqual([
|
expect(telemetryAfter.accessLogs).toEqual([
|
||||||
{
|
{
|
||||||
id: expect.any(Number),
|
id: expect.any(BigInt),
|
||||||
generalId,
|
generalId,
|
||||||
userId,
|
userId,
|
||||||
lastRefresh: trafficPeriod.lastRefresh,
|
lastRefresh: trafficPeriod.lastRefresh,
|
||||||
@@ -1548,8 +1548,8 @@ integration('game API security over HTTP transport', () => {
|
|||||||
arg,
|
arg,
|
||||||
}))
|
}))
|
||||||
).toEqual(databaseAfter.generalTurns);
|
).toEqual(databaseAfter.generalTurns);
|
||||||
expect(new Set(committedGeneralTurns.map(({ id }) => id)).size).toBe(30);
|
expect(new Set(committedGeneralTurns.map(({ generalId, turnIdx }) => `${generalId}:${turnIdx}`)).size).toBe(30);
|
||||||
expect(committedGeneralTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
|
expect(committedGeneralTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true);
|
||||||
expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId !== generalId)).toEqual(
|
expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId !== generalId)).toEqual(
|
||||||
allowedTablesBefore.generalTurnRevisions.filter((row) => row.generalId !== generalId)
|
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([
|
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([
|
||||||
{
|
{
|
||||||
id: expect.any(Number),
|
id: expect.any(BigInt),
|
||||||
generalId,
|
generalId,
|
||||||
userId,
|
userId,
|
||||||
lastRefresh: null,
|
lastRefresh: null,
|
||||||
@@ -1733,8 +1733,14 @@ integration('game API security over HTTP transport', () => {
|
|||||||
arg,
|
arg,
|
||||||
}))
|
}))
|
||||||
).toEqual(expectedTurns);
|
).toEqual(expectedTurns);
|
||||||
expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12);
|
expect(
|
||||||
expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
|
new Set(
|
||||||
|
committedNationTurns.map(
|
||||||
|
({ nationId, officerLevel, turnIdx }) => `${nationId}:${officerLevel}:${turnIdx}`
|
||||||
|
)
|
||||||
|
).size
|
||||||
|
).toBe(12);
|
||||||
|
expect(committedNationTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
allowedTablesAfter.nationTurnRevisions.filter(
|
allowedTablesAfter.nationTurnRevisions.filter(
|
||||||
(row) => row.nationId !== ownerNationId || row.officerLevel !== 5
|
(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([
|
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === sameNationGeneralId)).toEqual([
|
||||||
{
|
{
|
||||||
id: expect.any(Number),
|
id: expect.any(BigInt),
|
||||||
generalId: sameNationGeneralId,
|
generalId: sameNationGeneralId,
|
||||||
userId: sameNationUserId,
|
userId: sameNationUserId,
|
||||||
lastRefresh: null,
|
lastRefresh: null,
|
||||||
@@ -1880,8 +1886,14 @@ integration('game API security over HTTP transport', () => {
|
|||||||
arg,
|
arg,
|
||||||
}))
|
}))
|
||||||
).toEqual(committed.nationTurns);
|
).toEqual(committed.nationTurns);
|
||||||
expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12);
|
expect(
|
||||||
expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
|
new Set(
|
||||||
|
committedNationTurns.map(
|
||||||
|
({ nationId, officerLevel, turnIdx }) => `${nationId}:${officerLevel}:${turnIdx}`
|
||||||
|
)
|
||||||
|
).size
|
||||||
|
).toBe(12);
|
||||||
|
expect(committedNationTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
allowedTablesAfter.nationTurnRevisions.filter(
|
allowedTablesAfter.nationTurnRevisions.filter(
|
||||||
(row) => row.nationId !== ownerNationId || row.officerLevel !== 12
|
(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([
|
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([
|
||||||
{
|
{
|
||||||
id: expect.any(Number),
|
id: expect.any(BigInt),
|
||||||
generalId,
|
generalId,
|
||||||
userId,
|
userId,
|
||||||
lastRefresh: null,
|
lastRefresh: null,
|
||||||
@@ -2868,7 +2880,7 @@ integration('game API security over HTTP transport', () => {
|
|||||||
result: {
|
result: {
|
||||||
data: {
|
data: {
|
||||||
coverage: 'IDENTIFIED_LOGS_ONLY',
|
coverage: 'IDENTIFIED_LOGS_ONLY',
|
||||||
items: [{ id: ownLogs[index]!.id }],
|
items: [{ id: Number(ownLogs[index]!.id) }],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2877,17 +2889,19 @@ integration('game API security over HTTP transport', () => {
|
|||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
(await get('generalLogs', admin, { generalId: logGeneralId, type: 'generalHistory', limit: 1 })).body
|
(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(
|
expect(
|
||||||
(
|
(
|
||||||
await get('generalLogs', admin, {
|
await get('generalLogs', admin, {
|
||||||
generalId: logGeneralId,
|
generalId: logGeneralId,
|
||||||
type: 'generalHistory',
|
type: 'generalHistory',
|
||||||
limit: 1,
|
limit: 1,
|
||||||
cursor: latest.id,
|
cursor: Number(latest.id),
|
||||||
})
|
})
|
||||||
).body
|
).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 [
|
for (const invalid of [
|
||||||
{ limit: 201 },
|
{ limit: 201 },
|
||||||
{ cursor: 0 },
|
{ cursor: 0 },
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existingRowIdOrNext } from '@sammo-ts/infra';
|
||||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
@@ -580,8 +581,8 @@ export const createAuctionBidder = async (options: {
|
|||||||
}
|
}
|
||||||
await tx.$executeRaw(
|
await tx.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO rank_data (nation_id, general_id, type, value)
|
INSERT INTO rank_data (id, nation_id, general_id, type, value)
|
||||||
SELECT nation_id, id, 'inherit_spent_dyn', ${morePoint}
|
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
|
FROM general
|
||||||
WHERE id = ${command.generalId}
|
WHERE id = ${command.generalId}
|
||||||
ON CONFLICT (general_id, type)
|
ON CONFLICT (general_id, type)
|
||||||
@@ -598,8 +599,9 @@ export const createAuctionBidder = async (options: {
|
|||||||
}
|
}
|
||||||
await tx.$executeRaw(
|
await tx.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO inheritance_point (user_id, key, value, updated_at)
|
INSERT INTO inheritance_point (id, user_id, key, value, updated_at)
|
||||||
VALUES (
|
VALUES (
|
||||||
|
${existingRowIdOrNext('inheritance_point', GamePrisma.sql`stable_id.user_id = ${prevUserId} AND stable_id.key = 'previous'`)},
|
||||||
${prevUserId},
|
${prevUserId},
|
||||||
'previous',
|
'previous',
|
||||||
${highestBid.amount},
|
${highestBid.amount},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existingRowIdOrNext } from '@sammo-ts/infra';
|
||||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||||
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
|
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
|
||||||
@@ -212,8 +213,8 @@ const refundInheritancePoint = async (options: {
|
|||||||
}
|
}
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO inheritance_point (user_id, key, value, updated_at)
|
INSERT INTO inheritance_point (id, user_id, key, value, updated_at)
|
||||||
VALUES (${userId}, 'previous', ${amount}, ${new Date()})
|
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)
|
ON CONFLICT (user_id, key)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
value = inheritance_point.value + EXCLUDED.value,
|
value = inheritance_point.value + EXCLUDED.value,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existingRowIdOrNext } from '@sammo-ts/infra';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||||
@@ -289,8 +290,8 @@ const openUniqueAuction = async (
|
|||||||
});
|
});
|
||||||
await db.$executeRaw(
|
await db.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO rank_data (nation_id, general_id, type, value)
|
INSERT INTO rank_data (id, nation_id, general_id, type, value)
|
||||||
VALUES (${general.nationId}, ${general.id}, 'inherit_spent_dyn', ${command.amount})
|
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)
|
ON CONFLICT (general_id, type)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
nation_id = EXCLUDED.nation_id,
|
nation_id = EXCLUDED.nation_id,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { createGamePostgresConnector, type GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
|
||||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
@@ -424,7 +425,7 @@ const cancelGameInTransaction = async (
|
|||||||
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
||||||
});
|
});
|
||||||
const currentPoint = pointsByUser.get(userId)?.get('previous') ?? 0;
|
const currentPoint = pointsByUser.get(userId)?.get('previous') ?? 0;
|
||||||
await prisma.inheritancePoint.upsert({
|
await upsertWithoutSequence(prisma.inheritancePoint, {
|
||||||
where: { userId_key: { userId, key: 'previous' } },
|
where: { userId_key: { userId, key: 'previous' } },
|
||||||
update: { value: calculated.finalPoint },
|
update: { value: calculated.finalPoint },
|
||||||
create: { userId, key: 'previous', value: calculated.finalPoint },
|
create: { userId, key: 'previous', value: calculated.finalPoint },
|
||||||
@@ -470,7 +471,7 @@ const cancelGameInTransaction = async (
|
|||||||
}
|
}
|
||||||
for (const general of activeGenerals) {
|
for (const general of activeGenerals) {
|
||||||
if (!general.userId || general.npcState >= 2) continue;
|
if (!general.userId || general.npcState >= 2) continue;
|
||||||
await prisma.oldGeneral.upsert({
|
await upsertWithoutSequence(prisma.oldGeneral, {
|
||||||
where: { by_no: { serverId, generalNo: general.id } },
|
where: { by_no: { serverId, generalNo: general.id } },
|
||||||
update: {
|
update: {
|
||||||
owner: general.userId,
|
owner: general.userId,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { prunePreviousAuditBatch } from '../playAudit/retention.js';
|
import { prunePreviousAuditBatch } from '../playAudit/retention.js';
|
||||||
import { randomBytes } from 'node:crypto';
|
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 },
|
where: { serverId: worldMeta.serverId },
|
||||||
create: {
|
create: {
|
||||||
serverId: worldMeta.serverId,
|
serverId: worldMeta.serverId,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js';
|
import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js';
|
||||||
import { JosaUtil, asRecord } from '@sammo-ts/common';
|
import { JosaUtil, asRecord } from '@sammo-ts/common';
|
||||||
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
|
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);
|
const recordsFinalized = await areSeasonRecordsFinalized(db, world.getState().meta.serverId);
|
||||||
for (const entry of recordsFinalized ? [] : pointUpdates) {
|
for (const entry of recordsFinalized ? [] : pointUpdates) {
|
||||||
await db.inheritancePoint.upsert({
|
await upsertWithoutSequence(db.inheritancePoint, {
|
||||||
where: {
|
where: {
|
||||||
userId_key: { userId: entry.userId!, key: 'tournament' },
|
userId_key: { userId: entry.userId!, key: 'tournament' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
|
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
|
||||||
import { collectPlayAudit, markAuditGap } from '../playAudit/bestEffort.js';
|
import { collectPlayAudit, markAuditGap } from '../playAudit/bestEffort.js';
|
||||||
import { persistAuditDiplomacyEvents, withPlayAuditSavepoint } from '@sammo-ts/infra';
|
import { persistAuditDiplomacyEvents, withPlayAuditSavepoint } from '@sammo-ts/infra';
|
||||||
@@ -697,12 +698,12 @@ const persistNationBettingFinish = async (
|
|||||||
});
|
});
|
||||||
const previousPoint = existing?.value ?? 0;
|
const previousPoint = existing?.value ?? 0;
|
||||||
const nextPoint = previousPoint + reward.amount;
|
const nextPoint = previousPoint + reward.amount;
|
||||||
await prisma.inheritancePoint.upsert({
|
await upsertWithoutSequence(prisma.inheritancePoint, {
|
||||||
where: { userId_key: { userId: reward.userId, key: 'previous' } },
|
where: { userId_key: { userId: reward.userId, key: 'previous' } },
|
||||||
update: { value: nextPoint },
|
update: { value: nextPoint },
|
||||||
create: { userId: reward.userId, key: 'previous', value: nextPoint },
|
create: { userId: reward.userId, key: 'previous', value: nextPoint },
|
||||||
});
|
});
|
||||||
await prisma.rankData.upsert({
|
await upsertWithoutSequence(prisma.rankData, {
|
||||||
where: {
|
where: {
|
||||||
generalId_type: {
|
generalId_type: {
|
||||||
generalId: reward.generalId,
|
generalId: reward.generalId,
|
||||||
@@ -1574,7 +1575,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of grouped.values()) {
|
for (const entry of grouped.values()) {
|
||||||
await prisma.inheritancePoint.upsert({
|
await upsertWithoutSequence(prisma.inheritancePoint, {
|
||||||
where: { userId_key: { userId: entry.userId, key: entry.key } },
|
where: { userId_key: { userId: entry.userId, key: entry.key } },
|
||||||
update: { value: { increment: entry.amount } },
|
update: { value: { increment: entry.amount } },
|
||||||
create: { userId: entry.userId, key: entry.key, value: entry.amount },
|
create: { userId: entry.userId, key: entry.key, value: entry.amount },
|
||||||
@@ -1642,7 +1643,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
}
|
}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
deletedNationSnapshots.map((snapshot) =>
|
deletedNationSnapshots.map((snapshot) =>
|
||||||
prisma.oldNation.upsert({
|
upsertWithoutSequence(prisma.oldNation, {
|
||||||
where: {
|
where: {
|
||||||
serverId_nation_sourceId: {
|
serverId_nation_sourceId: {
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existingRowIdOrNext } from '@sammo-ts/infra';
|
||||||
import { GamePrisma, type TurnEngineGeneralUpdateInput } from '@sammo-ts/infra';
|
import { GamePrisma, type TurnEngineGeneralUpdateInput } from '@sammo-ts/infra';
|
||||||
|
|
||||||
// Fixed schema identifiers only; all row values remain bound parameters.
|
// 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) {
|
for (let offset = 0; offset < updates.length; offset += GENERAL_UPDATE_BATCH_SIZE) {
|
||||||
const batch = updates.slice(offset, offset + GENERAL_UPDATE_BATCH_SIZE);
|
const batch = updates.slice(offset, offset + GENERAL_UPDATE_BATCH_SIZE);
|
||||||
await database.$executeRaw(GamePrisma.sql`
|
await database.$executeRaw(GamePrisma.sql`
|
||||||
INSERT INTO "general_access_log" ("general_id", "user_id", "refresh_score_total")
|
INSERT INTO "general_access_log" ("id", "general_id", "user_id", "refresh_score_total")
|
||||||
VALUES ${GamePrisma.join(batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.userId}, ${row.refreshScoreTotal})`))}
|
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
|
ON CONFLICT ("general_id") DO UPDATE
|
||||||
SET "refresh_score_total" = EXCLUDED."refresh_score_total"
|
SET "refresh_score_total" = EXCLUDED."refresh_score_total"
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
||||||
import {
|
import {
|
||||||
asRecord,
|
asRecord,
|
||||||
@@ -77,7 +78,7 @@ const persistPostRetirementRankValues = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const row of buildPersistedRankRows(event.after)) {
|
for (const row of buildPersistedRankRows(event.after)) {
|
||||||
await prisma.rankData.upsert({
|
await upsertWithoutSequence(prisma.rankData, {
|
||||||
where: { generalId_type: { generalId: row.generalId, type: row.type } },
|
where: { generalId_type: { generalId: row.generalId, type: row.type } },
|
||||||
update: { nationId: row.nationId, value: row.value },
|
update: { nationId: row.nationId, value: row.value },
|
||||||
create: row,
|
create: row,
|
||||||
@@ -142,7 +143,7 @@ const settleInheritance = async (
|
|||||||
);
|
);
|
||||||
const total = Math.trunc(previous + refund + settlement.totalEarned);
|
const total = Math.trunc(previous + refund + settlement.totalEarned);
|
||||||
|
|
||||||
await prisma.inheritancePoint.upsert({
|
await upsertWithoutSequence(prisma.inheritancePoint, {
|
||||||
where: { userId_key: { userId, key: 'previous' } },
|
where: { userId_key: { userId, key: 'previous' } },
|
||||||
update: { value: total },
|
update: { value: total },
|
||||||
create: { userId, key: 'previous', 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
|
([key, value]) => key === 'max_belong' || points.has(key) || value !== 0
|
||||||
);
|
);
|
||||||
for (const [key, value] of retainedEntries) {
|
for (const [key, value] of retainedEntries) {
|
||||||
await prisma.inheritancePoint.upsert({
|
await upsertWithoutSequence(prisma.inheritancePoint, {
|
||||||
where: { userId_key: { userId, key } },
|
where: { userId_key: { userId, key } },
|
||||||
update: { value },
|
update: { value },
|
||||||
create: { userId, key, value },
|
create: { userId, key, value },
|
||||||
@@ -367,7 +368,7 @@ const archiveGeneral = async (
|
|||||||
records: { battleResult: battleResults },
|
records: { battleResult: battleResults },
|
||||||
availability: { battleResultLogs: true },
|
availability: { battleResultLogs: true },
|
||||||
};
|
};
|
||||||
await prisma.oldGeneral.upsert({
|
await upsertWithoutSequence(prisma.oldGeneral, {
|
||||||
where: { by_no: { serverId, generalNo: event.generalId } },
|
where: { by_no: { serverId, generalNo: event.generalId } },
|
||||||
update: {
|
update: {
|
||||||
owner: event.before.userId ?? null,
|
owner: event.before.userId ?? null,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
||||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
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> => {
|
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' } },
|
where: { userId_key: { userId, key: 'previous' } },
|
||||||
update: { value },
|
update: { value },
|
||||||
create: { userId, key: 'previous', value },
|
create: { userId, key: 'previous', value },
|
||||||
@@ -860,7 +861,7 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
await db.generalTurnRevision.create({
|
await db.generalTurnRevision.create({
|
||||||
data: { generalId, revision: 0 },
|
data: { generalId, revision: 0 },
|
||||||
});
|
});
|
||||||
await db.generalAccessLog.upsert({
|
await upsertWithoutSequence(db.generalAccessLog, {
|
||||||
where: { generalId },
|
where: { generalId },
|
||||||
update: { userId: input.userId, lastRefresh: operationalAcceptedAt },
|
update: { userId: input.userId, lastRefresh: operationalAcceptedAt },
|
||||||
create: {
|
create: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { randomInt } from 'node:crypto';
|
import { randomInt } from 'node:crypto';
|
||||||
|
|
||||||
import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
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);
|
return toReservation(inFlightToken, options.createdGameTick, ticksPerSecond);
|
||||||
}
|
}
|
||||||
if (
|
if (existing && (existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick)) {
|
||||||
existing &&
|
|
||||||
(existing.validUntilTick === null || Number(existing.validUntilTick) < options.createdGameTick)
|
|
||||||
) {
|
|
||||||
await db.npcSelectionToken.deleteMany({
|
await db.npcSelectionToken.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
nonce: existing.nonce,
|
nonce: existing.nonce,
|
||||||
OR: [
|
OR: [{ validUntilTick: null }, { validUntilTick: { lt: BigInt(options.createdGameTick) } }],
|
||||||
{ validUntilTick: null },
|
|
||||||
{ validUntilTick: { lt: BigInt(options.createdGameTick) } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
existing = null;
|
existing = null;
|
||||||
@@ -425,9 +420,7 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
const validUntilTick = options.createdGameTick + Math.round(validSeconds * ticksPerSecond);
|
const validUntilTick = options.createdGameTick + Math.round(validSeconds * ticksPerSecond);
|
||||||
const pickMoreFromTick = options.createdGameTick + Math.round(pickMoreSeconds * ticksPerSecond);
|
const pickMoreFromTick = options.createdGameTick + Math.round(pickMoreSeconds * ticksPerSecond);
|
||||||
const validUntil = new Date(now.getTime() + validSeconds * 1000);
|
const validUntil = new Date(now.getTime() + validSeconds * 1000);
|
||||||
const refreshedPickMoreFrom = new Date(
|
const refreshedPickMoreFrom = new Date(now.getTime() + pickMoreSeconds * 1000);
|
||||||
now.getTime() + pickMoreSeconds * 1000
|
|
||||||
);
|
|
||||||
const nonce = randomInt(0, 0x10000000);
|
const nonce = randomInt(0, 0x10000000);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -566,7 +559,7 @@ export const possessNpcGeneral = async (options: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.generalAccessLog.upsert({
|
await upsertWithoutSequence(db.generalAccessLog, {
|
||||||
where: { generalId },
|
where: { generalId },
|
||||||
update: {
|
update: {
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
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 (
|
const clearUnusedReservations = async (db: DatabaseClient, userId: string, nowTick: number): Promise<void> => {
|
||||||
db: DatabaseClient,
|
|
||||||
userId: string,
|
|
||||||
nowTick: number
|
|
||||||
): Promise<void> => {
|
|
||||||
await db.selectPoolEntry.updateMany({
|
await db.selectPoolEntry.updateMany({
|
||||||
where: {
|
where: {
|
||||||
generalId: null,
|
generalId: null,
|
||||||
@@ -914,7 +911,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
if (occupied.count === 0) {
|
if (occupied.count === 0) {
|
||||||
throw new Error('장수 등록 중 선택 후보 점유에 실패했습니다.');
|
throw new Error('장수 등록 중 선택 후보 점유에 실패했습니다.');
|
||||||
}
|
}
|
||||||
await db.generalAccessLog.upsert({
|
await upsertWithoutSequence(db.generalAccessLog, {
|
||||||
where: { generalId },
|
where: { generalId },
|
||||||
update: { userId, lastRefresh: options.operationalAcceptedAt },
|
update: { userId, lastRefresh: options.operationalAcceptedAt },
|
||||||
create: { generalId, 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 { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
||||||
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
||||||
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
|
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) {
|
if (!bidder?.userId || bidder.userId !== dbBidder?.userId) {
|
||||||
throw new Error(`Unification auction refund owner is unavailable: ${planned.auctionId}.`);
|
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' } },
|
where: { userId_key: { userId: bidder.userId, key: 'previous' } },
|
||||||
update: { value: { increment: highest.amount } },
|
update: { value: { increment: highest.amount } },
|
||||||
create: { userId: bidder.userId, key: 'previous', value: highest.amount },
|
create: { userId: bidder.userId, key: 'previous', value: highest.amount },
|
||||||
});
|
});
|
||||||
await transaction.rankData.upsert({
|
await upsertWithoutSequence(transaction.rankData, {
|
||||||
where: {
|
where: {
|
||||||
generalId_type: { generalId: bidder.id, type: 'inherit_spent_dyn' },
|
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>;
|
) as Record<(typeof ALL_MERGED_INHERITANCE_KEYS)[number], number>;
|
||||||
const total = Math.floor(previous + Object.values(mergedPoints).reduce((sum, value) => sum + value, 0));
|
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' } },
|
where: { userId_key: { userId, key: 'previous' } },
|
||||||
update: { value: total },
|
update: { value: total },
|
||||||
create: { userId, key: 'previous', value: total },
|
create: { userId, key: 'previous', value: total },
|
||||||
@@ -508,7 +509,7 @@ export const persistUnificationFinalization = async (
|
|||||||
}),
|
}),
|
||||||
generationKey: input.generationKey,
|
generationKey: input.generationKey,
|
||||||
};
|
};
|
||||||
await transaction.oldNation.upsert({
|
await upsertWithoutSequence(transaction.oldNation, {
|
||||||
where: { serverId_nation_sourceId: { serverId, nation: input.winnerNationId, sourceId: 0 } },
|
where: { serverId_nation_sourceId: { serverId, nation: input.winnerNationId, sourceId: 0 } },
|
||||||
update: { data: asJson(winnerData), date: input.completedAt },
|
update: { data: asJson(winnerData), date: input.completedAt },
|
||||||
create: {
|
create: {
|
||||||
@@ -525,7 +526,7 @@ export const persistUnificationFinalization = async (
|
|||||||
generals: neutralGenerals.map((general) => general.id),
|
generals: neutralGenerals.map((general) => general.id),
|
||||||
generationKey: input.generationKey,
|
generationKey: input.generationKey,
|
||||||
};
|
};
|
||||||
await transaction.oldNation.upsert({
|
await upsertWithoutSequence(transaction.oldNation, {
|
||||||
where: { serverId_nation_sourceId: { serverId, nation: 0, sourceId: 0 } },
|
where: { serverId_nation_sourceId: { serverId, nation: 0, sourceId: 0 } },
|
||||||
update: { data: neutralData, date: input.completedAt },
|
update: { data: neutralData, date: input.completedAt },
|
||||||
create: { serverId, nation: 0, sourceId: 0, 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 },
|
availability: { battleResultLogs: true },
|
||||||
generationKey: input.generationKey,
|
generationKey: input.generationKey,
|
||||||
};
|
};
|
||||||
await transaction.oldGeneral.upsert({
|
await upsertWithoutSequence(transaction.oldGeneral, {
|
||||||
where: { by_no: { serverId, generalNo: general.id } },
|
where: { by_no: { serverId, generalNo: general.id } },
|
||||||
update: {
|
update: {
|
||||||
owner: general.userId ?? null,
|
owner: general.userId ?? null,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existingRowIdOrNext } from '@sammo-ts/infra';
|
||||||
import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js';
|
import { handleCityOfficeRequest, settleCityOfficeRequests } from './cityOfficeRequests.js';
|
||||||
import type {
|
import type {
|
||||||
TurnDaemonCommandHandler,
|
TurnDaemonCommandHandler,
|
||||||
@@ -2864,8 +2865,8 @@ const insertVoteSelection = async (
|
|||||||
}
|
}
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
const rows = await ctx.commandDb.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
const rows = await ctx.commandDb.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||||
INSERT INTO vote (vote_id, general_id, nation_id, selection, created_at)
|
INSERT INTO vote (id, vote_id, general_id, nation_id, selection, created_at)
|
||||||
SELECT poll.id,
|
SELECT ${existingRowIdOrNext('vote', GamePrisma.sql`stable_id.vote_id = poll.id AND stable_id.general_id = ${general.id}`)}, poll.id,
|
||||||
${general.id},
|
${general.id},
|
||||||
${general.nationId},
|
${general.nationId},
|
||||||
CAST(${JSON.stringify(selection)} AS jsonb),
|
CAST(${JSON.stringify(selection)} AS jsonb),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||||
@@ -44,7 +45,7 @@ export const persistYearbookSnapshot = async (
|
|||||||
globalAction,
|
globalAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await transaction.yearbookHistory.upsert({
|
await upsertWithoutSequence(transaction.yearbookHistory, {
|
||||||
where: {
|
where: {
|
||||||
profileName_year_month_sourceId: {
|
profileName_year_month_sourceId: {
|
||||||
profileName: snapshot.serverId,
|
profileName: snapshot.serverId,
|
||||||
|
|||||||
@@ -198,6 +198,23 @@ integration('general batch persistence', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(writes).toBe(2);
|
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({
|
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: firstId } })).toMatchObject({
|
||||||
userId: 'original-owner',
|
userId: 'original-owner',
|
||||||
lastRefresh: at,
|
lastRefresh: at,
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
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 { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
||||||
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
||||||
import type { TurnGeneral } from '../src/turn/types.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 => ({
|
const archivedGeneral = (): TurnGeneral => ({
|
||||||
id: 91,
|
id: 91,
|
||||||
userId: null,
|
userId: null,
|
||||||
@@ -65,12 +73,13 @@ describe('general lifecycle archive history', () => {
|
|||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
rankData: {
|
rankData: {
|
||||||
|
update: missingRecordUpdate,
|
||||||
findMany: vi.fn(async () => [
|
findMany: vi.fn(async () => [
|
||||||
{ type: 'warnum', value: 2 },
|
{ type: 'warnum', value: 2 },
|
||||||
{ type: 'killnum', value: 1 },
|
{ type: 'killnum', value: 1 },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
oldGeneral: { upsert },
|
oldGeneral: { update: missingRecordUpdate, upsert },
|
||||||
} as unknown as GamePrisma.TransactionClient;
|
} as unknown as GamePrisma.TransactionClient;
|
||||||
const event: GeneralLifecycleEvent = {
|
const event: GeneralLifecycleEvent = {
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
@@ -137,6 +146,7 @@ describe('general lifecycle archive history', () => {
|
|||||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||||
},
|
},
|
||||||
rankData: {
|
rankData: {
|
||||||
|
update: missingRecordUpdate,
|
||||||
findMany: vi.fn(async () => [
|
findMany: vi.fn(async () => [
|
||||||
{ type: 'warnum', value: 10 },
|
{ type: 'warnum', value: 10 },
|
||||||
{ type: 'inherit_earned', value: 123 },
|
{ type: 'inherit_earned', value: 123 },
|
||||||
@@ -156,6 +166,7 @@ describe('general lifecycle archive history', () => {
|
|||||||
update: vi.fn(async () => undefined),
|
update: vi.fn(async () => undefined),
|
||||||
},
|
},
|
||||||
inheritancePoint: {
|
inheritancePoint: {
|
||||||
|
update: missingRecordUpdate,
|
||||||
findMany: vi.fn(async () => [{ key: 'previous', value: 0 }]),
|
findMany: vi.fn(async () => [{ key: 'previous', value: 0 }]),
|
||||||
upsert: vi.fn(async () => undefined),
|
upsert: vi.fn(async () => undefined),
|
||||||
deleteMany: vi.fn(async () => ({ count: 0 })),
|
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 () => {
|
it('reloads bigint IDs for high churn queues, logs, upserts and the traffic foreign key', async () => {
|
||||||
const tables = [
|
const tables = [
|
||||||
'general_turn',
|
|
||||||
'nation_turn',
|
|
||||||
'general_access_log',
|
'general_access_log',
|
||||||
'diplomacy',
|
'diplomacy',
|
||||||
'inheritance_point',
|
'inheritance_point',
|
||||||
@@ -93,10 +91,6 @@ describe.skipIf(!databaseUrl)('rank data bigint PostgreSQL persistence', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const rows = await Promise.all([
|
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.generalAccessLog.create({ data: { generalId } }),
|
||||||
tx.diplomacy.create({ data: { srcNationId: 3, destNationId: 4, stateCode: 0 } }),
|
tx.diplomacy.create({ data: { srcNationId: 3, destNationId: 4, stateCode: 0 } }),
|
||||||
tx.inheritancePoint.create({ data: { userId: 'bigint-fixture', key: 'previous', value: 123 } }),
|
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);
|
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({
|
const period = await tx.trafficPeriod.create({
|
||||||
data: {
|
data: {
|
||||||
worldStateId: world.id,
|
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.id).toBeGreaterThan(2_147_483_647n);
|
||||||
expect(period.generals[0]?.periodId).toBe(period.id);
|
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');
|
throw new Error('high churn fixture rollback');
|
||||||
})
|
})
|
||||||
).rejects.toThrow('high churn fixture rollback');
|
).rejects.toThrow('high churn fixture rollback');
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
|
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 { LogCategory, LogScope, type City, type Nation } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import { persistUnificationFinalization, resolveStoredInheritancePoint } from '../src/turn/unificationPersistence.js';
|
import { persistUnificationFinalization, resolveStoredInheritancePoint } from '../src/turn/unificationPersistence.js';
|
||||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.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 buildWorld = (): InMemoryTurnWorld => {
|
||||||
const turnTime = new Date('0190-07-01T00:00:00.000Z');
|
const turnTime = new Date('0190-07-01T00:00:00.000Z');
|
||||||
const general: TurnGeneral = {
|
const general: TurnGeneral = {
|
||||||
@@ -206,6 +214,7 @@ describe('persistUnificationFinalization', () => {
|
|||||||
create: vi.fn().mockResolvedValue({}),
|
create: vi.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
inheritancePoint: {
|
inheritancePoint: {
|
||||||
|
update: missingRecordUpdate,
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{ userId: 'user-1', key: 'previous', value: 100 },
|
{ userId: 'user-1', key: 'previous', value: 100 },
|
||||||
{ userId: 'user-1', key: 'unifier', value: 7 },
|
{ userId: 'user-1', key: 'unifier', value: 7 },
|
||||||
@@ -216,6 +225,7 @@ describe('persistUnificationFinalization', () => {
|
|||||||
inheritanceResult: { create: inheritanceResultCreate },
|
inheritanceResult: { create: inheritanceResultCreate },
|
||||||
inheritanceLog: { create: inheritanceLogCreate },
|
inheritanceLog: { create: inheritanceLogCreate },
|
||||||
rankData: {
|
rankData: {
|
||||||
|
update: missingRecordUpdate,
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{ generalId: 1, type: 'warnum', value: 15 },
|
{ generalId: 1, type: 'warnum', value: 15 },
|
||||||
{ generalId: 1, type: 'killnum', value: 9 },
|
{ generalId: 1, type: 'killnum', value: 9 },
|
||||||
@@ -251,10 +261,11 @@ describe('persistUnificationFinalization', () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
oldNation: {
|
oldNation: {
|
||||||
|
update: missingRecordUpdate,
|
||||||
upsert: oldNationUpsert,
|
upsert: oldNationUpsert,
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
oldGeneral: { upsert: oldGeneralUpsert },
|
oldGeneral: { update: missingRecordUpdate, upsert: oldGeneralUpsert },
|
||||||
emperor: { create: emperorCreate },
|
emperor: { create: emperorCreate },
|
||||||
});
|
});
|
||||||
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
|
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
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 { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { persistYearbookSnapshot } from '../src/turn/yearbookPersistence.js';
|
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', () => {
|
describe('persistYearbookSnapshot', () => {
|
||||||
it('archives canonical summary logs and compatible action logs together in descending ID order', async () => {
|
it('archives canonical summary logs and compatible action logs together in descending ID order', async () => {
|
||||||
const findMany = vi
|
const findMany = vi
|
||||||
@@ -14,7 +22,7 @@ describe('persistYearbookSnapshot', () => {
|
|||||||
const upsert = vi.fn().mockResolvedValue(undefined);
|
const upsert = vi.fn().mockResolvedValue(undefined);
|
||||||
const transaction = {
|
const transaction = {
|
||||||
logEntry: { findMany },
|
logEntry: { findMany },
|
||||||
yearbookHistory: { upsert },
|
yearbookHistory: { update: missingRecordUpdate, upsert },
|
||||||
} as unknown as GamePrisma.TransactionClient;
|
} as unknown as GamePrisma.TransactionClient;
|
||||||
|
|
||||||
await persistYearbookSnapshot(transaction, {
|
await persistYearbookSnapshot(transaction, {
|
||||||
|
|||||||
@@ -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: '20261114010000_high_churn_bigint_ids',
|
gameSchemaHead: '20261114020000_reserved_turn_slot_primary_keys',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,24 @@ Lease를 잃은 process는 fencing 검사에서 commit하지 못합니다.
|
|||||||
`InMemoryTurnWorld`에서 읽습니다. 따라서 flush 실패 뒤 다음 run도 복원된
|
`InMemoryTurnWorld`에서 읽습니다. 따라서 flush 실패 뒤 다음 run도 복원된
|
||||||
checkpoint에서 시작합니다.
|
checkpoint에서 시작합니다.
|
||||||
|
|
||||||
|
## 내부 ID와 처리 순서
|
||||||
|
|
||||||
|
예약턴의 순서는 `turnIdx`, 변경 감지는 revision, 실행 권한은 lease/fencing으로
|
||||||
|
판정합니다. `GeneralTurn`은 `(generalId, turnIdx)`, `NationTurn`은
|
||||||
|
`(nationId, officerLevel, turnIdx)` 기본키를 사용합니다. 기존 transaction 안의
|
||||||
|
삭제·재삽입 순서를 유지하면서 사용하지 않는 자동 ID/sequence를 제거했습니다.
|
||||||
|
|
||||||
|
접속 기록·현재 월 통계·랭킹·유산 포인트·베팅·보존 기록/연감은 갱신 시 기존 ID를
|
||||||
|
유지하고 신규 업무 키에만 ID를 발급합니다. Prisma 갱신은
|
||||||
|
`packages/infra/src/stableRowId.ts`의 `upsertWithoutSequence()`가 먼저 UPDATE하고,
|
||||||
|
미존재 결과인 P2025에서만 원래 upsert로 생성합니다. 제약 위반·다른 오류는 그대로
|
||||||
|
전파하고, 새 키의 동시 생성 경합은 ON CONFLICT가 처리합니다. raw SQL은 같은
|
||||||
|
module의 `existingRowIdOrNext()`로 기존 ID를 재사용합니다.
|
||||||
|
|
||||||
|
`input_event.sequence`, 외교 감사 sequence, outbox/log ID는 실행 순서·cursor·
|
||||||
|
claim/완료 식별자이므로 자동 증가를 유지합니다. 새 행 생성의 rollback이나 동시
|
||||||
|
생성 경합에서 생기는 sequence gap은 정상이며 번호를 되감지 않습니다.
|
||||||
|
|
||||||
## 저장 위치
|
## 저장 위치
|
||||||
|
|
||||||
| 상태 | 기준 |
|
| 상태 | 기준 |
|
||||||
|
|||||||
@@ -116,8 +116,11 @@ server build는 기존 Turbo cache를 재사용할 수 있지만, 자원 보호
|
|||||||
profile frontend release task의 `NODE_OPTIONS`만 덮어쓰며 server package Turbo
|
profile frontend release task의 `NODE_OPTIONS`만 덮어쓰며 server package Turbo
|
||||||
build와 배포 후 PM2 process의 heap은 바꾸지 않습니다. 전용 heap을 늘릴 때는
|
build와 배포 후 PM2 process의 heap은 바꾸지 않습니다. 전용 heap을 늘릴 때는
|
||||||
runtime container hard limit과 전체 process RSS를 먼저 확인합니다.
|
runtime container hard limit과 전체 process RSS를 먼저 확인합니다.
|
||||||
- migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이
|
- migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성을
|
||||||
유지됩니다.
|
버전별로 확인합니다. `20261114020000_reserved_turn_slot_primary_keys`는 미사용
|
||||||
|
예약턴 ID/sequence를 제거하므로 구형 Prisma client로 단독 rollback할 수 없습니다.
|
||||||
|
Profile의 모든 구형 game process를 정지한 뒤 matching client로 전환하며,
|
||||||
|
복구는 forward fix 또는 검증한 사전 DB backup의 restore 경로를 사용합니다.
|
||||||
|
|
||||||
## Commit worktree 자동 정리
|
## Commit worktree 자동 정리
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,21 @@ no-op, unique/FK/index, runtime query와 reload를 확인합니다. 기존 migra
|
|||||||
Prisma reload, 유일성 및 transaction rollback을 검증합니다. `log_entry`·`inheritance_log`의 API 숫자 cursor는
|
Prisma reload, 유일성 및 transaction rollback을 검증합니다. `log_entry`·`inheritance_log`의 API 숫자 cursor는
|
||||||
`Number.MAX_SAFE_INTEGER`까지 정확성을 보장하며 그 이상은 명시적으로 거부합니다.
|
`Number.MAX_SAFE_INTEGER`까지 정확성을 보장하며 그 이상은 명시적으로 거부합니다.
|
||||||
|
|
||||||
|
### 예약턴 복합키와 일반 갱신 ID 회귀
|
||||||
|
|
||||||
|
`RESERVED_TURN_MIGRATION_TEST_DATABASE_URL`을 격리 개발 DB에 주입해
|
||||||
|
`pnpm --filter @sammo-ts/infra verify:migration:reserved-turn-keys`를 실행합니다.
|
||||||
|
기존 기수의 명령·인자·시간·revision·lease 보존, 자연키 유일성, queue 교체 rollback,
|
||||||
|
sequence 제거, 새 schema 전체 migration과 deploy 재실행을 확인합니다.
|
||||||
|
이전 `verify:migration:rank-data`는 64비트 확장 당시의 migration head까지만
|
||||||
|
검사합니다. 이후 예약턴 ID를 제거한 현재 전체 chain은 새 검증기가 소유합니다.
|
||||||
|
|
||||||
|
`stableRowId.integration.test.ts`는 `IMMEDIATE_ACTION_DATABASE_URL` 전용 schema에서
|
||||||
|
실제 Prisma의 기존 ID 보존, 원자적 increment, 신규 키 동시 생성, 미존재 fallback의
|
||||||
|
transaction 생존, 제약 위반 전파와 rollback을 검사합니다. 접속량/지연 접속량 및
|
||||||
|
batch 저장 test는 같은 행을 갱신할 때 sequence가 증가하지 않는지도 확인합니다.
|
||||||
|
예약 API의 실제 HTTP 권한 행렬과 daemon/API lease 통합 검증도 함께 실행합니다.
|
||||||
|
|
||||||
## 결과 기록
|
## 결과 기록
|
||||||
|
|
||||||
Report에는 실행 명령, commit, 서비스와 fixture, pass/fail/skip 수, baseline
|
Report에는 실행 명령, commit, 서비스와 fixture, pass/fail/skip 수, baseline
|
||||||
|
|||||||
@@ -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:reserved-turn-keys": "node scripts/verify-reserved-turn-key-migration.mjs",
|
||||||
"verify:migration:rank-data": "node scripts/verify-rank-data-bigint-migration.mjs",
|
"verify:migration: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",
|
||||||
|
|||||||
@@ -679,14 +679,13 @@ model Troop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model GeneralTurn {
|
model GeneralTurn {
|
||||||
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")
|
||||||
arg Json @default(dbgenerated("'{}'::jsonb"))
|
arg Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@unique([generalId, turnIdx])
|
@@id([generalId, turnIdx])
|
||||||
@@map("general_turn")
|
@@map("general_turn")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +700,6 @@ model GeneralTurnRevision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model NationTurn {
|
model NationTurn {
|
||||||
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")
|
||||||
@@ -709,7 +707,7 @@ model NationTurn {
|
|||||||
arg Json @default(dbgenerated("'{}'::jsonb"))
|
arg Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@unique([nationId, officerLevel, turnIdx])
|
@@id([nationId, officerLevel, turnIdx])
|
||||||
@@map("nation_turn")
|
@@map("nation_turn")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
-- Queue position and revision/lease are the domain identity. The unused ID was
|
||||||
|
-- regenerated on every transactional delete/create, consuming 30/12 numbers.
|
||||||
|
-- Reuse the existing proven unique indexes; keep every command and timestamp.
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE "general_turn" DROP CONSTRAINT "general_turn_pkey";
|
||||||
|
ALTER TABLE "general_turn" ADD CONSTRAINT "general_turn_pkey"
|
||||||
|
PRIMARY KEY USING INDEX "general_turn_general_id_turn_idx_key";
|
||||||
|
ALTER TABLE "general_turn" DROP COLUMN "id";
|
||||||
|
ALTER TABLE "nation_turn" DROP CONSTRAINT "nation_turn_pkey";
|
||||||
|
ALTER TABLE "nation_turn" ADD CONSTRAINT "nation_turn_pkey"
|
||||||
|
PRIMARY KEY USING INDEX "nation_turn_nation_id_officer_level_turn_idx_key";
|
||||||
|
ALTER TABLE "nation_turn" DROP COLUMN "id";
|
||||||
|
COMMIT;
|
||||||
@@ -53,6 +53,11 @@ const readIndexes = async (table) =>
|
|||||||
).rows;
|
).rows;
|
||||||
try {
|
try {
|
||||||
await fs.cp(path.join(packageRoot, 'prisma'), path.join(temp, 'prisma'), { recursive: true });
|
await fs.cp(path.join(packageRoot, 'prisma'), path.join(temp, 'prisma'), { recursive: true });
|
||||||
|
for (const name of await fs.readdir(path.join(temp, 'prisma', 'migrations'))) {
|
||||||
|
if (/^\d/.test(name) && name > targets.at(-1)) {
|
||||||
|
await fs.rm(path.join(temp, 'prisma', 'migrations', name), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const target of targets) await fs.rm(path.join(temp, 'prisma', 'migrations', target), { recursive: true });
|
for (const target of targets) await fs.rm(path.join(temp, 'prisma', 'migrations', target), { recursive: true });
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const sourceUrl = process.env.RESERVED_TURN_MIGRATION_TEST_DATABASE_URL;
|
||||||
|
if (!sourceUrl) throw new Error('RESERVED_TURN_MIGRATION_TEST_DATABASE_URL is required (isolated development DB only)');
|
||||||
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const target = '20261114020000_reserved_turn_slot_primary_keys';
|
||||||
|
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'reserved-slot-migration-'));
|
||||||
|
const schema = `reserved_slot_${randomUUID().replaceAll('-', '')}`;
|
||||||
|
const fresh = `${schema}_fresh`;
|
||||||
|
const config = path.join(packageRoot, `.reserved-slot-${process.pid}.config.mjs`);
|
||||||
|
const client = new pg.Client({ connectionString: sourceUrl });
|
||||||
|
const url = new URL(sourceUrl);
|
||||||
|
const deploy = (name) => {
|
||||||
|
url.searchParams.set('schema', name);
|
||||||
|
const result = spawnSync('pnpm', ['exec', 'prisma', 'migrate', 'deploy', '--config', config], {
|
||||||
|
cwd: packageRoot,
|
||||||
|
env: { ...process.env, DATABASE_URL: url.href },
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
if (result.status !== 0) throw new Error(`Migration failed (exit ${result.status}); datasource output suppressed`);
|
||||||
|
};
|
||||||
|
const snapshot = async () => ({
|
||||||
|
general: (
|
||||||
|
await client.query(
|
||||||
|
'SELECT general_id,turn_idx,action_code,arg,created_at FROM general_turn ORDER BY general_id,turn_idx'
|
||||||
|
)
|
||||||
|
).rows,
|
||||||
|
nation: (
|
||||||
|
await client.query(
|
||||||
|
'SELECT nation_id,officer_level,turn_idx,action_code,arg,created_at FROM nation_turn ORDER BY nation_id,officer_level,turn_idx'
|
||||||
|
)
|
||||||
|
).rows,
|
||||||
|
generalRevision: (await client.query('SELECT * FROM general_turn_revision ORDER BY general_id')).rows,
|
||||||
|
nationRevision: (await client.query('SELECT * FROM nation_turn_revision ORDER BY nation_id,officer_level')).rows,
|
||||||
|
});
|
||||||
|
const assertKeys = async (name) => {
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await client.query(
|
||||||
|
"SELECT count(*)::int AS n FROM information_schema.columns WHERE table_schema=$1 AND table_name IN ('general_turn','nation_turn') AND column_name='id'",
|
||||||
|
[name]
|
||||||
|
)
|
||||||
|
).rows[0].n,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await client.query(
|
||||||
|
"SELECT count(*)::int AS n FROM pg_sequences WHERE schemaname=$1 AND sequencename IN ('general_turn_id_seq','nation_turn_id_seq')",
|
||||||
|
[name]
|
||||||
|
)
|
||||||
|
).rows[0].n,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
(
|
||||||
|
await client.query(
|
||||||
|
"SELECT t.relname,pg_get_constraintdef(c.oid) AS definition FROM pg_constraint c JOIN pg_class t ON c.conrelid=t.oid JOIN pg_namespace n ON t.relnamespace=n.oid WHERE n.nspname=$1 AND c.contype='p' AND t.relname IN ('general_turn','nation_turn') ORDER BY t.relname",
|
||||||
|
[name]
|
||||||
|
)
|
||||||
|
).rows,
|
||||||
|
[
|
||||||
|
{ relname: 'general_turn', definition: 'PRIMARY KEY (general_id, turn_idx)' },
|
||||||
|
{ relname: 'nation_turn', definition: 'PRIMARY KEY (nation_id, officer_level, turn_idx)' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await fs.cp(path.join(packageRoot, 'prisma'), path.join(temp, 'prisma'), { recursive: true });
|
||||||
|
await fs.rm(path.join(temp, 'prisma', 'migrations', target), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
config,
|
||||||
|
`export default { schema: ${JSON.stringify(path.join(temp, 'prisma/game.prisma'))}, datasource: { url: process.env.DATABASE_URL } };\n`
|
||||||
|
);
|
||||||
|
await client.connect();
|
||||||
|
await client.query(`CREATE SCHEMA "${schema}"`);
|
||||||
|
await client.query(`CREATE SCHEMA "${fresh}"`);
|
||||||
|
deploy(schema);
|
||||||
|
await client.query(`SET search_path TO "${schema}", public`);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO general_turn(id,general_id,turn_idx,action_code,arg,created_at) VALUES(3000000001,7,0,'che_훈련','{"x":1}','2026-11-14'),(3000000002,7,1,'휴식','{}','2026-11-14')`
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO nation_turn(id,nation_id,officer_level,turn_idx,action_code,arg,created_at) VALUES(3000000003,3,12,0,'che_포상','{"amount":10}','2026-11-14'),(3000000004,3,10,0,'휴식','{}','2026-11-14')`
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO general_turn_revision(general_id,revision,lease_owner,lease_expires_at,updated_at) VALUES(7,29,'fixture-daemon','2026-11-15','2026-11-14')`
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO nation_turn_revision(nation_id,officer_level,revision,lease_owner,lease_expires_at,updated_at) VALUES(3,12,41,'fixture-daemon','2026-11-15','2026-11-14')`
|
||||||
|
);
|
||||||
|
const before = await snapshot();
|
||||||
|
await fs.cp(
|
||||||
|
path.join(packageRoot, 'prisma', 'migrations', target),
|
||||||
|
path.join(temp, 'prisma', 'migrations', target),
|
||||||
|
{ recursive: true }
|
||||||
|
);
|
||||||
|
deploy(schema);
|
||||||
|
assert.deepEqual(await snapshot(), before);
|
||||||
|
await assertKeys(schema);
|
||||||
|
await assert.rejects(
|
||||||
|
client.query("INSERT INTO general_turn(general_id,turn_idx,action_code) VALUES(7,0,'duplicate')"),
|
||||||
|
{ code: '23505' }
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
client.query(
|
||||||
|
"INSERT INTO nation_turn(nation_id,officer_level,turn_idx,action_code) VALUES(3,12,0,'duplicate')"
|
||||||
|
),
|
||||||
|
{ code: '23505' }
|
||||||
|
);
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query('DELETE FROM general_turn WHERE general_id=7');
|
||||||
|
await client.query("INSERT INTO general_turn(general_id,turn_idx,action_code) VALUES(7,0,'replacement')");
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
assert.deepEqual(await snapshot(), before);
|
||||||
|
const migrations = (
|
||||||
|
await client.query('SELECT migration_name,checksum FROM _prisma_migrations ORDER BY migration_name')
|
||||||
|
).rows;
|
||||||
|
deploy(schema);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await client.query('SELECT migration_name,checksum FROM _prisma_migrations ORDER BY migration_name')).rows,
|
||||||
|
migrations
|
||||||
|
);
|
||||||
|
deploy(fresh);
|
||||||
|
await client.query(`SET search_path TO "${fresh}", public`);
|
||||||
|
await assertKeys(fresh);
|
||||||
|
await client.query("INSERT INTO general_turn(general_id,turn_idx,action_code) VALUES(7,0,'휴식')");
|
||||||
|
await client.query("INSERT INTO nation_turn(nation_id,officer_level,turn_idx,action_code) VALUES(3,12,0,'휴식')");
|
||||||
|
console.log(
|
||||||
|
'PASS: populated predecessor and fresh migration, command/arg/timestamp/revision/lease preservation, natural PK uniqueness, queue replacement rollback, removed sequences, deploy no-op'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.query('SET search_path TO public').catch(() => {});
|
||||||
|
await client.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`).catch(() => {});
|
||||||
|
await client.query(`DROP SCHEMA IF EXISTS "${fresh}" CASCADE`).catch(() => {});
|
||||||
|
await client.end();
|
||||||
|
await fs.rm(config, { force: true });
|
||||||
|
await fs.rm(temp, { recursive: true });
|
||||||
|
}
|
||||||
@@ -16,3 +16,4 @@ export * from './messageEnvelope.js';
|
|||||||
export * from './webPushOutbox.js';
|
export * from './webPushOutbox.js';
|
||||||
export * from './playAuditBestEffort.js';
|
export * from './playAuditBestEffort.js';
|
||||||
export * from './playAuditDecisionCodec.js';
|
export * from './playAuditDecisionCodec.js';
|
||||||
|
export * from './stableRowId.js';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { GamePrisma } from './gamePrisma.js';
|
||||||
|
|
||||||
|
/** Internal natural-key records retain their identity across ordinary updates. */
|
||||||
|
export const upsertWithoutSequence = async <Where, Update, Create>(
|
||||||
|
delegate: {
|
||||||
|
update(args: { where: Where; data: Update }): PromiseLike<object>;
|
||||||
|
upsert(args: { where: Where; update: Update; create: Create }): PromiseLike<object>;
|
||||||
|
},
|
||||||
|
args: { where: Where; update: Update; create: Create }
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await delegate.update({ where: args.where, data: args.update });
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof GamePrisma.PrismaClientKnownRequestError) || error.code !== 'P2025') throw error;
|
||||||
|
// A missing row is an application-level Prisma result, not a failed SQL
|
||||||
|
// statement. The surrounding transaction remains usable. A competing
|
||||||
|
// creator is still handled atomically by the original native upsert.
|
||||||
|
await delegate.upsert(args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StableIdTable = 'general_access_log' | 'traffic_period' | 'rank_data' | 'inheritance_point' | 'vote';
|
||||||
|
|
||||||
|
/** Only new natural keys allocate an ID; values and identifiers stay separate. */
|
||||||
|
export const existingRowIdOrNext = (table: StableIdTable, predicate: GamePrisma.Sql): GamePrisma.Sql =>
|
||||||
|
GamePrisma.sql`COALESCE(
|
||||||
|
(SELECT stable_id."id" FROM ${GamePrisma.raw(`"${table}"`)} AS stable_id WHERE ${predicate}),
|
||||||
|
nextval(pg_get_serial_sequence(${table}, 'id'))
|
||||||
|
)`;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '../src/gamePrisma.js';
|
||||||
|
import { existingRowIdOrNext, upsertWithoutSequence } from '../src/stableRowId.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
|
||||||
|
const generalId = 7_329;
|
||||||
|
const userId = 'stable-id-fixture';
|
||||||
|
|
||||||
|
describe.skipIf(!databaseUrl)('natural-key ID persistence on PostgreSQL', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let close: () => Promise<void>;
|
||||||
|
const sequence = async (table: 'rank_data' | 'inheritance_point'): Promise<bigint> =>
|
||||||
|
(
|
||||||
|
await db.$queryRaw<
|
||||||
|
Array<{ value: bigint }>
|
||||||
|
>`SELECT pg_sequence_last_value(pg_get_serial_sequence(${table}, 'id')::regclass) AS value`
|
||||||
|
)[0]!.value;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!new URL(databaseUrl!).searchParams.get('schema')?.endsWith('immediate_action_integration')) {
|
||||||
|
throw new Error('Dedicated immediate action schema is required');
|
||||||
|
}
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
close = () => connector.disconnect();
|
||||||
|
await db.rankData.deleteMany({ where: { generalId } });
|
||||||
|
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
if (db) {
|
||||||
|
await db.rankData.deleteMany({ where: { generalId } });
|
||||||
|
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||||
|
}
|
||||||
|
await close?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates missing rows inside the transaction and retains identity on replace and increment', async () => {
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await upsertWithoutSequence(tx.rankData, {
|
||||||
|
where: { generalId_type: { generalId, type: 'stable_rank' } },
|
||||||
|
update: { value: 11 },
|
||||||
|
create: { generalId, type: 'stable_rank', value: 11 },
|
||||||
|
});
|
||||||
|
await upsertWithoutSequence(tx.inheritancePoint, {
|
||||||
|
where: { userId_key: { userId, key: 'previous' } },
|
||||||
|
update: { value: 100 },
|
||||||
|
create: { userId, key: 'previous', value: 100 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const original = await db.rankData.findUniqueOrThrow({
|
||||||
|
where: { generalId_type: { generalId, type: 'stable_rank' } },
|
||||||
|
});
|
||||||
|
const before = await sequence('rank_data');
|
||||||
|
for (let index = 0; index < 5; index++)
|
||||||
|
await db.$transaction((tx) =>
|
||||||
|
upsertWithoutSequence(tx.rankData, {
|
||||||
|
where: { generalId_type: { generalId, type: 'stable_rank' } },
|
||||||
|
update: { value: { increment: 1 }, nationId: 3 },
|
||||||
|
create: { generalId, type: 'stable_rank', value: 1, nationId: 3 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(await db.rankData.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
|
||||||
|
id: original.id,
|
||||||
|
value: 16,
|
||||||
|
nationId: 3,
|
||||||
|
});
|
||||||
|
expect(await sequence('rank_data')).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent updates without losing increments or allocating IDs', async () => {
|
||||||
|
const before = await sequence('rank_data');
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 12 }, () =>
|
||||||
|
db.$transaction((tx) =>
|
||||||
|
upsertWithoutSequence(tx.rankData, {
|
||||||
|
where: { generalId_type: { generalId, type: 'stable_rank' } },
|
||||||
|
update: { value: { increment: 1 } },
|
||||||
|
create: { generalId, type: 'stable_rank', value: 1 },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await db.rankData.findUniqueOrThrow({ where: { generalId_type: { generalId, type: 'stable_rank' } } }))
|
||||||
|
.value
|
||||||
|
).toBe(28);
|
||||||
|
expect(await sequence('rank_data')).toBe(before);
|
||||||
|
// New-key races still converge through the original ON CONFLICT upsert.
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 6 }, () =>
|
||||||
|
db.$transaction((tx) =>
|
||||||
|
upsertWithoutSequence(tx.rankData, {
|
||||||
|
where: { generalId_type: { generalId, type: 'stable_race' } },
|
||||||
|
update: { value: { increment: 1 } },
|
||||||
|
create: { generalId, type: 'stable_race', value: 1 },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await db.rankData.findUniqueOrThrow({ where: { generalId_type: { generalId, type: 'stable_race' } } }))
|
||||||
|
.value
|
||||||
|
).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps raw refund increments atomic and does not consume an ID for an existing point row', async () => {
|
||||||
|
const point = await db.inheritancePoint.findUniqueOrThrow({
|
||||||
|
where: { userId_key: { userId, key: 'previous' } },
|
||||||
|
});
|
||||||
|
const before = await sequence('inheritance_point');
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 8 }, () =>
|
||||||
|
db.$executeRaw(GamePrisma.sql`
|
||||||
|
INSERT INTO inheritance_point(id,user_id,key,value,updated_at)
|
||||||
|
VALUES(${existingRowIdOrNext('inheritance_point', GamePrisma.sql`stable_id.user_id=${userId} AND stable_id.key='previous'`)},${userId},'previous',1,CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(user_id,key) DO UPDATE SET value=inheritance_point.value+EXCLUDED.value
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(await db.inheritancePoint.findUniqueOrThrow({ where: { id: point.id } })).toMatchObject({ value: 108 });
|
||||||
|
expect(await sequence('inheritance_point')).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves rollback and propagates validation/constraint errors without creating a replacement row', async () => {
|
||||||
|
const where = { generalId_type: { generalId, type: 'stable_rank' } };
|
||||||
|
const before = await db.rankData.findUniqueOrThrow({ where });
|
||||||
|
const counter = await sequence('rank_data');
|
||||||
|
await expect(
|
||||||
|
db.$transaction(async (tx) => {
|
||||||
|
await upsertWithoutSequence(tx.rankData, {
|
||||||
|
where,
|
||||||
|
update: { value: 99 },
|
||||||
|
create: { generalId, type: 'stable_rank', value: 99 },
|
||||||
|
});
|
||||||
|
throw new Error('fixture abort');
|
||||||
|
})
|
||||||
|
).rejects.toThrow('fixture abort');
|
||||||
|
expect(await db.rankData.findUniqueOrThrow({ where })).toEqual(before);
|
||||||
|
await expect(
|
||||||
|
upsertWithoutSequence(db.rankData, {
|
||||||
|
where,
|
||||||
|
update: { type: 'stable_race' },
|
||||||
|
create: { generalId, type: 'stable_rank' },
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'P2002' });
|
||||||
|
expect(await sequence('rank_data')).toBe(counter);
|
||||||
|
expect(await db.rankData.count({ where: { generalId } })).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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": "20261114010000_high_churn_bigint_ids",
|
"gameSchemaHead": "20261114020000_reserved_turn_slot_primary_keys",
|
||||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user