갱신 행의 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 { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
||||
import { z } from 'zod';
|
||||
@@ -223,7 +224,7 @@ export const bettingRouter = router({
|
||||
world.currentMonth,
|
||||
`${input.amount} 포인트를 베팅에 사용`
|
||||
);
|
||||
await ctx.db.rankData.upsert({
|
||||
await upsertWithoutSequence(ctx.db.rankData, {
|
||||
where: {
|
||||
generalId_type: {
|
||||
generalId: general.id,
|
||||
@@ -245,7 +246,7 @@ export const bettingRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.nationBet.upsert({
|
||||
await upsertWithoutSequence(ctx.db.nationBet, {
|
||||
where: {
|
||||
bettingId_userId_selectionKey: {
|
||||
bettingId: input.bettingId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existingRowIdOrNext } from '@sammo-ts/infra';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit } from '@sammo-ts/common';
|
||||
@@ -94,7 +95,13 @@ export const enqueueDeferredGeneralAccess = async (
|
||||
}
|
||||
await redis.eval(ENQUEUE_SCRIPT, {
|
||||
keys: [activeKey(profileName)],
|
||||
arguments: [String(generalId), auth.user.id, String(weight), String(now.getTime()), String(ACTIVE_BATCH_TTL_MS)],
|
||||
arguments: [
|
||||
String(generalId),
|
||||
auth.user.id,
|
||||
String(weight),
|
||||
String(now.getTime()),
|
||||
String(ACTIVE_BATCH_TTL_MS),
|
||||
],
|
||||
});
|
||||
return true;
|
||||
};
|
||||
@@ -258,9 +265,10 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
),
|
||||
missing_periods AS (
|
||||
INSERT INTO "traffic_period" (
|
||||
"world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online"
|
||||
"id", "world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online"
|
||||
)
|
||||
SELECT
|
||||
${existingRowIdOrNext('traffic_period', GamePrisma.sql`stable_id.world_state_id = ${worldState.id} AND stable_id.year = (missing_key / 12)::INTEGER AND stable_id.month = (missing_key % 12 + 1)::INTEGER`)},
|
||||
${worldState.id},
|
||||
(missing_key / 12)::INTEGER,
|
||||
(missing_key % 12 + 1)::INTEGER,
|
||||
@@ -281,9 +289,10 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
),
|
||||
period_upsert AS (
|
||||
INSERT INTO "traffic_period" (
|
||||
"world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online"
|
||||
"id", "world_state_id", "year", "month", "started_at", "last_refresh", "refresh", "online"
|
||||
)
|
||||
SELECT
|
||||
${existingRowIdOrNext('traffic_period', GamePrisma.sql`stable_id.world_state_id = ${worldState.id} AND stable_id.year = ${worldState.currentYear} AND stable_id.month = ${worldState.currentMonth}`)},
|
||||
${worldState.id},
|
||||
${worldState.currentYear},
|
||||
${worldState.currentMonth},
|
||||
@@ -343,10 +352,11 @@ export const flushDeferredGeneralAccessBatch = async (
|
||||
),
|
||||
access_updates AS (
|
||||
INSERT INTO "general_access_log" (
|
||||
"general_id", "user_id", "last_refresh", "refresh", "refresh_total",
|
||||
"id", "general_id", "user_id", "last_refresh", "refresh", "refresh_total",
|
||||
"refresh_score", "refresh_score_total"
|
||||
)
|
||||
SELECT
|
||||
${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = resolved."general_id"`)},
|
||||
resolved."general_id",
|
||||
resolved."user_id",
|
||||
resolved."last_refresh",
|
||||
@@ -471,11 +481,9 @@ export class DeferredGeneralAccessWorker {
|
||||
await this.redis.del(key);
|
||||
return;
|
||||
}
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({ nextAccessAt: state.nextAccessAt.toISOString() }),
|
||||
{ PX: Math.max(1, state.nextAccessAt.getTime() - now.getTime()) }
|
||||
);
|
||||
await this.redis.set(key, JSON.stringify({ nextAccessAt: state.nextAccessAt.toISOString() }), {
|
||||
PX: Math.max(1, state.nextAccessAt.getTime() - now.getTime()),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existingRowIdOrNext } from '@sammo-ts/infra';
|
||||
import { asRecord, resolveAccessLimitLevel, resolveAccessRefreshLimit, type AccessLimitLevel } from '@sammo-ts/common';
|
||||
import { acquireGameSchemaAdvisoryXactLock, GENERAL_ACCESS_PERSISTENCE_LOCK, GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
@@ -269,6 +270,7 @@ export const upsertGeneralAccess = async (
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO traffic_period (
|
||||
id,
|
||||
world_state_id,
|
||||
year,
|
||||
month,
|
||||
@@ -277,6 +279,7 @@ export const upsertGeneralAccess = async (
|
||||
refresh
|
||||
)
|
||||
VALUES (
|
||||
${existingRowIdOrNext('traffic_period', GamePrisma.sql`stable_id.world_state_id = ${input.worldStateId} AND stable_id.year = ${input.year} AND stable_id.month = ${input.month}`)},
|
||||
${input.worldStateId},
|
||||
${input.year},
|
||||
${input.month},
|
||||
@@ -341,6 +344,7 @@ export const upsertGeneralAccess = async (
|
||||
await transaction.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO general_access_log (
|
||||
id,
|
||||
general_id,
|
||||
user_id,
|
||||
last_refresh,
|
||||
@@ -350,6 +354,7 @@ export const upsertGeneralAccess = async (
|
||||
refresh_score_total
|
||||
)
|
||||
VALUES (
|
||||
${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = ${input.generalId}`)},
|
||||
${input.generalId},
|
||||
${input.userId},
|
||||
${input.now},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existingRowIdOrNext } from '@sammo-ts/infra';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
@@ -20,11 +21,13 @@ export const recordGeneralActivity = async (
|
||||
const written = await ctx.db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO general_access_log (
|
||||
id,
|
||||
general_id,
|
||||
user_id,
|
||||
last_action_at
|
||||
)
|
||||
SELECT
|
||||
${existingRowIdOrNext('general_access_log', GamePrisma.sql`stable_id.general_id = "general".id`)},
|
||||
id,
|
||||
${user.id},
|
||||
${now}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { upsertWithoutSequence } from '@sammo-ts/infra';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
@@ -132,7 +133,7 @@ export const setInheritancePoint = async (
|
||||
key: InheritPointKey,
|
||||
value: number
|
||||
): Promise<void> => {
|
||||
await db.inheritancePoint.upsert({
|
||||
await upsertWithoutSequence(db.inheritancePoint, {
|
||||
where: {
|
||||
userId_key: {
|
||||
userId,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DeferredGeneralAccessWorker,
|
||||
flushDeferredGeneralAccessBatch,
|
||||
enqueueDeferredGeneralAccess,
|
||||
getDeferredGeneralAccessLimit,
|
||||
} from '../src/services/deferredGeneralAccess.js';
|
||||
@@ -91,7 +92,7 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => {
|
||||
await redis.disconnect();
|
||||
}
|
||||
if (db) {
|
||||
await db.generalAccessBatch.deleteMany({ where: { id: batchId } });
|
||||
await db.generalAccessBatch.deleteMany({ where: { id: { startsWith: batchId } } });
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.worldState.deleteMany({ where: { id: worldStateId } });
|
||||
@@ -153,9 +154,19 @@ liveIntegration('deferred general access with PostgreSQL and Redis', () => {
|
||||
nextAccessAt,
|
||||
});
|
||||
|
||||
const counters =
|
||||
await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq`;
|
||||
const accessId = (await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id;
|
||||
await flushDeferredGeneralAccessBatch(workerDb as never, `${batchId}-second`, [
|
||||
{ generalId, userId, weight: 1, lastRefresh: now },
|
||||
]);
|
||||
expect(
|
||||
await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq`
|
||||
).toEqual(counters);
|
||||
expect((await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id).toBe(accessId);
|
||||
await worker.flushOnce();
|
||||
await expect(db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).resolves.toMatchObject({
|
||||
refreshTotal: 2,
|
||||
refreshTotal: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ const deferredBatchId = `deferred-access-${endpointGeneralId}`;
|
||||
|
||||
const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: `${yearbookProfile}:default`,
|
||||
profile: yearbookProfile,
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: `access-session-${endpointGeneralId}`,
|
||||
@@ -167,6 +167,9 @@ integration('general access tracking persistence', () => {
|
||||
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||
};
|
||||
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
|
||||
const counters =
|
||||
await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq`;
|
||||
const accessId = (await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id;
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, (_, index) =>
|
||||
upsertGeneralAccess(db, {
|
||||
@@ -177,6 +180,10 @@ integration('general access tracking persistence', () => {
|
||||
)
|
||||
);
|
||||
|
||||
expect(
|
||||
await db.$queryRaw`SELECT 'access' AS kind,last_value FROM general_access_log_id_seq UNION ALL SELECT 'period',last_value FROM traffic_period_id_seq`
|
||||
).toEqual(counters);
|
||||
expect((await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).id).toBe(accessId);
|
||||
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||
userId: 'access-user-a',
|
||||
refresh: 22,
|
||||
|
||||
@@ -69,8 +69,7 @@ const buildDb = (autorunLimit: number | null = null) => {
|
||||
},
|
||||
createMany: async ({ data }: GeneralTurnCreateManyArgs) => {
|
||||
const dataList = (Array.isArray(data) ? data : [data]) as Record<string, unknown>[];
|
||||
const rows: GeneralTurnRow[] = dataList.map((row, index: number) => ({
|
||||
id: BigInt(index + 1),
|
||||
const rows: GeneralTurnRow[] = dataList.map((row) => ({
|
||||
generalId: row.generalId as number,
|
||||
turnIdx: row.turnIdx as number,
|
||||
actionCode: row.actionCode as string,
|
||||
@@ -136,8 +135,7 @@ const buildDb = (autorunLimit: number | null = null) => {
|
||||
},
|
||||
createMany: async ({ data }: NationTurnCreateManyArgs) => {
|
||||
const dataList = (Array.isArray(data) ? data : [data]) as Record<string, unknown>[];
|
||||
const rows: NationTurnRow[] = dataList.map((row, index: number) => ({
|
||||
id: BigInt(index + 1),
|
||||
const rows: NationTurnRow[] = dataList.map((row) => ({
|
||||
nationId: row.nationId as number,
|
||||
officerLevel: row.officerLevel as number,
|
||||
turnIdx: row.turnIdx as number,
|
||||
@@ -207,7 +205,6 @@ describe('reservedTurns', () => {
|
||||
async () =>
|
||||
[
|
||||
{
|
||||
id: 1n,
|
||||
nationId: 4,
|
||||
officerLevel: 12,
|
||||
turnIdx: 1,
|
||||
@@ -216,7 +213,6 @@ describe('reservedTurns', () => {
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 2n,
|
||||
nationId: 4,
|
||||
officerLevel: 10,
|
||||
turnIdx: 0,
|
||||
@@ -414,7 +410,6 @@ describe('reservedTurns', () => {
|
||||
nationRevisions.set('6:12', 2);
|
||||
nationTurns.set('6:12', [
|
||||
{
|
||||
id: 1n,
|
||||
nationId: 6,
|
||||
officerLevel: 12,
|
||||
turnIdx: 0,
|
||||
@@ -423,7 +418,6 @@ describe('reservedTurns', () => {
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 2n,
|
||||
nationId: 6,
|
||||
officerLevel: 12,
|
||||
turnIdx: 1,
|
||||
|
||||
@@ -901,7 +901,6 @@ describe('appRouter', () => {
|
||||
const general = buildGeneralRow({ id: 11, meta: { autorun_limit: 2408 } });
|
||||
const generalTurns: GeneralTurnRow[] = [
|
||||
{
|
||||
id: 1n,
|
||||
generalId: 11,
|
||||
turnIdx: 0,
|
||||
actionCode: 'che_화계',
|
||||
@@ -922,7 +921,6 @@ describe('appRouter', () => {
|
||||
const general = buildGeneralRow({ id: 12, nationId: 3, officerLevel: 5 });
|
||||
const nationTurns: NationTurnRow[] = [
|
||||
{
|
||||
id: 1n,
|
||||
nationId: 3,
|
||||
officerLevel: 5,
|
||||
turnIdx: 0,
|
||||
@@ -1008,7 +1006,6 @@ describe('appRouter', () => {
|
||||
general,
|
||||
generalTurns: [
|
||||
{
|
||||
id: 1n,
|
||||
generalId: general.id,
|
||||
turnIdx: 0,
|
||||
actionCode: 'che_훈련',
|
||||
@@ -1016,7 +1013,6 @@ describe('appRouter', () => {
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 2n,
|
||||
generalId: general.id,
|
||||
turnIdx: 1,
|
||||
actionCode: 'che_사기진작',
|
||||
|
||||
@@ -969,7 +969,7 @@ integration('game API security over HTTP transport', () => {
|
||||
const telemetryAfter = await readAccessTelemetryState();
|
||||
expect(telemetryAfter.periods).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
id: expect.any(BigInt),
|
||||
worldStateId: fixtureWorldId,
|
||||
year: 190,
|
||||
month: 1,
|
||||
@@ -992,7 +992,7 @@ integration('game API security over HTTP transport', () => {
|
||||
]);
|
||||
expect(telemetryAfter.accessLogs).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
id: expect.any(BigInt),
|
||||
generalId,
|
||||
userId,
|
||||
lastRefresh: trafficPeriod.lastRefresh,
|
||||
@@ -1548,8 +1548,8 @@ integration('game API security over HTTP transport', () => {
|
||||
arg,
|
||||
}))
|
||||
).toEqual(databaseAfter.generalTurns);
|
||||
expect(new Set(committedGeneralTurns.map(({ id }) => id)).size).toBe(30);
|
||||
expect(committedGeneralTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
|
||||
expect(new Set(committedGeneralTurns.map(({ generalId, turnIdx }) => `${generalId}:${turnIdx}`)).size).toBe(30);
|
||||
expect(committedGeneralTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true);
|
||||
expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId !== generalId)).toEqual(
|
||||
allowedTablesBefore.generalTurnRevisions.filter((row) => row.generalId !== generalId)
|
||||
);
|
||||
@@ -1567,7 +1567,7 @@ integration('game API security over HTTP transport', () => {
|
||||
);
|
||||
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
id: expect.any(BigInt),
|
||||
generalId,
|
||||
userId,
|
||||
lastRefresh: null,
|
||||
@@ -1733,8 +1733,14 @@ integration('game API security over HTTP transport', () => {
|
||||
arg,
|
||||
}))
|
||||
).toEqual(expectedTurns);
|
||||
expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12);
|
||||
expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
|
||||
expect(
|
||||
new Set(
|
||||
committedNationTurns.map(
|
||||
({ nationId, officerLevel, turnIdx }) => `${nationId}:${officerLevel}:${turnIdx}`
|
||||
)
|
||||
).size
|
||||
).toBe(12);
|
||||
expect(committedNationTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true);
|
||||
expect(
|
||||
allowedTablesAfter.nationTurnRevisions.filter(
|
||||
(row) => row.nationId !== ownerNationId || row.officerLevel !== 5
|
||||
@@ -1763,7 +1769,7 @@ integration('game API security over HTTP transport', () => {
|
||||
);
|
||||
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === sameNationGeneralId)).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
id: expect.any(BigInt),
|
||||
generalId: sameNationGeneralId,
|
||||
userId: sameNationUserId,
|
||||
lastRefresh: null,
|
||||
@@ -1880,8 +1886,14 @@ integration('game API security over HTTP transport', () => {
|
||||
arg,
|
||||
}))
|
||||
).toEqual(committed.nationTurns);
|
||||
expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12);
|
||||
expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true);
|
||||
expect(
|
||||
new Set(
|
||||
committedNationTurns.map(
|
||||
({ nationId, officerLevel, turnIdx }) => `${nationId}:${officerLevel}:${turnIdx}`
|
||||
)
|
||||
).size
|
||||
).toBe(12);
|
||||
expect(committedNationTurns.every(({ createdAt }) => createdAt instanceof Date)).toBe(true);
|
||||
expect(
|
||||
allowedTablesAfter.nationTurnRevisions.filter(
|
||||
(row) => row.nationId !== ownerNationId || row.officerLevel !== 12
|
||||
@@ -1910,7 +1922,7 @@ integration('game API security over HTTP transport', () => {
|
||||
);
|
||||
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
id: expect.any(BigInt),
|
||||
generalId,
|
||||
userId,
|
||||
lastRefresh: null,
|
||||
@@ -2868,7 +2880,7 @@ integration('game API security over HTTP transport', () => {
|
||||
result: {
|
||||
data: {
|
||||
coverage: 'IDENTIFIED_LOGS_ONLY',
|
||||
items: [{ id: ownLogs[index]!.id }],
|
||||
items: [{ id: Number(ownLogs[index]!.id) }],
|
||||
nextCursor: null,
|
||||
},
|
||||
},
|
||||
@@ -2877,17 +2889,19 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
expect(
|
||||
(await get('generalLogs', admin, { generalId: logGeneralId, type: 'generalHistory', limit: 1 })).body
|
||||
).toMatchObject({ result: { data: { items: [{ id: latest.id }], nextCursor: latest.id } } });
|
||||
).toMatchObject({
|
||||
result: { data: { items: [{ id: Number(latest.id) }], nextCursor: Number(latest.id) } },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await get('generalLogs', admin, {
|
||||
generalId: logGeneralId,
|
||||
type: 'generalHistory',
|
||||
limit: 1,
|
||||
cursor: latest.id,
|
||||
cursor: Number(latest.id),
|
||||
})
|
||||
).body
|
||||
).toMatchObject({ result: { data: { items: [{ id: ownLogs[0]!.id }], nextCursor: null } } });
|
||||
).toMatchObject({ result: { data: { items: [{ id: Number(ownLogs[0]!.id) }], nextCursor: null } } });
|
||||
for (const invalid of [
|
||||
{ limit: 201 },
|
||||
{ cursor: 0 },
|
||||
|
||||
Reference in new issue
Block a user