merge: 실제 transport 권한과 커맨드 내구 행렬을 main에 반영한다
This commit is contained in:
@@ -590,6 +590,12 @@ export const joinRouter = router({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
|
if (gameTime.tick === null) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'Game clock is not initialized.',
|
||||||
|
});
|
||||||
|
}
|
||||||
return await reserveNpcPossessionCandidates({
|
return await reserveNpcPossessionCandidates({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
worldState,
|
worldState,
|
||||||
@@ -598,6 +604,7 @@ export const joinRouter = router({
|
|||||||
refresh: input.refresh,
|
refresh: input.refresh,
|
||||||
keepIds: input.keepIds,
|
keepIds: input.keepIds,
|
||||||
now: gameTime.now,
|
now: gameTime.now,
|
||||||
|
acceptedGameTick: gameTime.tick,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof NpcPossessionError) {
|
if (error instanceof NpcPossessionError) {
|
||||||
|
|||||||
@@ -394,10 +394,27 @@ export const voteRouter = router({
|
|||||||
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
|
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
|
||||||
: null;
|
: null;
|
||||||
const nationName = nation?.name ?? '재야';
|
const nationName = nation?.name ?? '재야';
|
||||||
|
const createdAt = new Date();
|
||||||
|
|
||||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||||
INSERT INTO vote_comment (vote_id, general_id, nation_id, general_name, nation_name, text)
|
INSERT INTO vote_comment (
|
||||||
VALUES (${input.voteId}, ${general.id}, ${general.nationId}, ${general.name}, ${nationName}, ${input.text})
|
vote_id,
|
||||||
|
general_id,
|
||||||
|
nation_id,
|
||||||
|
general_name,
|
||||||
|
nation_name,
|
||||||
|
text,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${input.voteId},
|
||||||
|
${general.id},
|
||||||
|
${general.nationId},
|
||||||
|
${general.name},
|
||||||
|
${nationName},
|
||||||
|
${input.text},
|
||||||
|
${createdAt}
|
||||||
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -429,6 +446,7 @@ export const voteRouter = router({
|
|||||||
if (endAt && endAt < gameTime.now) {
|
if (endAt && endAt < gameTime.now) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||||
}
|
}
|
||||||
|
const operationalAt = new Date();
|
||||||
|
|
||||||
let multipleOptions = input.multipleOptions;
|
let multipleOptions = input.multipleOptions;
|
||||||
if (multipleOptions < 0) {
|
if (multipleOptions < 0) {
|
||||||
@@ -441,7 +459,7 @@ export const voteRouter = router({
|
|||||||
if (input.closePrevious) {
|
if (input.closePrevious) {
|
||||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||||
UPDATE vote_poll
|
UPDATE vote_poll
|
||||||
SET closed_at = ${gameTime.now}, updated_at = NOW()
|
SET closed_at = ${gameTime.now}, updated_at = ${operationalAt}
|
||||||
WHERE closed_at IS NULL
|
WHERE closed_at IS NULL
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -458,7 +476,9 @@ export const voteRouter = router({
|
|||||||
start_at,
|
start_at,
|
||||||
start_tick,
|
start_tick,
|
||||||
end_at,
|
end_at,
|
||||||
end_tick
|
end_tick,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
${input.title},
|
${input.title},
|
||||||
@@ -471,7 +491,9 @@ export const voteRouter = router({
|
|||||||
${gameTime.now},
|
${gameTime.now},
|
||||||
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
||||||
${endAt},
|
${endAt},
|
||||||
${toGameTickOrNull(gameTime, endAt)}
|
${toGameTickOrNull(gameTime, endAt)},
|
||||||
|
${operationalAt},
|
||||||
|
${operationalAt}
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -546,6 +568,7 @@ export const voteRouter = router({
|
|||||||
if (endAt && endAt < gameTime.now) {
|
if (endAt && endAt < gameTime.now) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||||
}
|
}
|
||||||
|
const updatedAt = new Date();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
input.title === undefined &&
|
input.title === undefined &&
|
||||||
@@ -568,7 +591,7 @@ export const voteRouter = router({
|
|||||||
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
|
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
|
||||||
end_at = ${endAt ?? poll.end_at},
|
end_at = ${endAt ?? poll.end_at},
|
||||||
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
|
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
|
||||||
updated_at = NOW()
|
updated_at = ${updatedAt}
|
||||||
WHERE id = ${input.voteId}
|
WHERE id = ${input.voteId}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -580,9 +603,11 @@ export const voteRouter = router({
|
|||||||
closePoll: adminProcedure
|
closePoll: adminProcedure
|
||||||
.input(z.object({ voteId: z.number().int().positive() }))
|
.input(z.object({ voteId: z.number().int().positive() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
|
const updatedAt = new Date();
|
||||||
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||||
UPDATE vote_poll
|
UPDATE vote_poll
|
||||||
SET closed_at = ${(await loadCurrentGameTime(ctx.db)).now}, updated_at = NOW()
|
SET closed_at = ${gameTime.now}, updated_at = ${updatedAt}
|
||||||
WHERE id = ${input.voteId}
|
WHERE id = ${input.voteId}
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -44,8 +44,11 @@ export class WebPushOutboxWorker {
|
|||||||
SELECT "id"
|
SELECT "id"
|
||||||
FROM "web_push_outbox"
|
FROM "web_push_outbox"
|
||||||
WHERE "delivered_at" IS NULL
|
WHERE "delivered_at" IS NULL
|
||||||
AND "available_at" <= CURRENT_TIMESTAMP
|
AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||||
AND ("locked_at" IS NULL OR "locked_at" <= CURRENT_TIMESTAMP - INTERVAL '30 seconds')
|
AND (
|
||||||
|
"locked_at" IS NULL
|
||||||
|
OR "locked_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds'
|
||||||
|
)
|
||||||
ORDER BY "id"
|
ORDER BY "id"
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
@@ -116,7 +119,7 @@ export class WebPushOutboxWorker {
|
|||||||
WITH expired AS (
|
WITH expired AS (
|
||||||
SELECT "id"
|
SELECT "id"
|
||||||
FROM "web_push_outbox"
|
FROM "web_push_outbox"
|
||||||
WHERE "delivered_at" < CURRENT_TIMESTAMP - INTERVAL '1 day'
|
WHERE "delivered_at" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 day'
|
||||||
ORDER BY "id"
|
ORDER BY "id"
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
const createdRequestPrefixes: string[] = [];
|
const createdRequestPrefixes: string[] = [];
|
||||||
const createdWorldIds: number[] = [];
|
const createdWorldIds: number[] = [];
|
||||||
const createdGeneralIds: number[] = [];
|
const createdGeneralIds: number[] = [];
|
||||||
|
const lifecycleUserIds = ['auction-durable-host', 'auction-durable-bidder'];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await connector.connect();
|
await connector.connect();
|
||||||
@@ -56,14 +57,15 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
await connector.prisma.worldState.deleteMany({ where: { id: { in: createdWorldIds } } });
|
await connector.prisma.worldState.deleteMany({ where: { id: { in: createdWorldIds } } });
|
||||||
}
|
}
|
||||||
if (createdGeneralIds.length > 0) {
|
if (createdGeneralIds.length > 0) {
|
||||||
|
await connector.prisma.message.deleteMany({ where: { mailbox: { in: createdGeneralIds } } });
|
||||||
|
await connector.prisma.webPushOutbox.deleteMany({ where: { userIds: { hasSome: lifecycleUserIds } } });
|
||||||
await connector.prisma.logEntry.deleteMany({ where: { generalId: { in: createdGeneralIds } } });
|
await connector.prisma.logEntry.deleteMany({ where: { generalId: { in: createdGeneralIds } } });
|
||||||
await connector.prisma.general.deleteMany({ where: { id: { in: createdGeneralIds } } });
|
await connector.prisma.general.deleteMany({ where: { id: { in: createdGeneralIds } } });
|
||||||
}
|
}
|
||||||
await connector.disconnect();
|
await connector.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
const createAuction = async (status: 'OPEN' | 'FINALIZING') => {
|
const createAuction = async (status: 'OPEN' | 'FINALIZING', closeAt = new Date(Date.now() - 60_000)) => {
|
||||||
const closeAt = new Date(Date.now() - 60_000);
|
|
||||||
const auction = await connector.prisma.auction.create({
|
const auction = await connector.prisma.auction.create({
|
||||||
data: {
|
data: {
|
||||||
type: 'BUY_RICE',
|
type: 'BUY_RICE',
|
||||||
@@ -340,14 +342,14 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
});
|
});
|
||||||
const host = buildGeneral({
|
const host = buildGeneral({
|
||||||
id: 992_032,
|
id: 8_032,
|
||||||
userId: 'auction-durable-host',
|
userId: 'auction-durable-host',
|
||||||
name: '경매주최자',
|
name: '경매주최자',
|
||||||
gold: 1_000,
|
gold: 1_000,
|
||||||
rice: 900,
|
rice: 900,
|
||||||
});
|
});
|
||||||
const bidder = buildGeneral({
|
const bidder = buildGeneral({
|
||||||
id: 992_033,
|
id: 8_033,
|
||||||
userId: 'auction-durable-bidder',
|
userId: 'auction-durable-bidder',
|
||||||
name: '경매입찰자',
|
name: '경매입찰자',
|
||||||
gold: 800,
|
gold: 800,
|
||||||
@@ -366,7 +368,9 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
map,
|
map,
|
||||||
};
|
};
|
||||||
|
|
||||||
const lifecycleGeneralIds = [992_032, 992_033];
|
const lifecycleGeneralIds = [8_032, 8_033];
|
||||||
|
await connector.prisma.message.deleteMany({ where: { mailbox: { in: lifecycleGeneralIds } } });
|
||||||
|
await connector.prisma.webPushOutbox.deleteMany({ where: { userIds: { hasSome: lifecycleUserIds } } });
|
||||||
await connector.prisma.logEntry.deleteMany({ where: { generalId: { in: lifecycleGeneralIds } } });
|
await connector.prisma.logEntry.deleteMany({ where: { generalId: { in: lifecycleGeneralIds } } });
|
||||||
await connector.prisma.general.deleteMany({ where: { id: { in: lifecycleGeneralIds } } });
|
await connector.prisma.general.deleteMany({ where: { id: { in: lifecycleGeneralIds } } });
|
||||||
await connector.prisma.worldState.deleteMany({ where: { id: worldId } });
|
await connector.prisma.worldState.deleteMany({ where: { id: worldId } });
|
||||||
@@ -398,7 +402,8 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
});
|
});
|
||||||
createdGeneralIds.push(general.id);
|
createdGeneralIds.push(general.id);
|
||||||
}
|
}
|
||||||
const auction = await createAuction('OPEN');
|
const logicalPastCloseAt = new Date(state.lastTurnTime.getTime() - 60_000);
|
||||||
|
const auction = await createAuction('OPEN', logicalPastCloseAt);
|
||||||
await connector.prisma.auction.update({
|
await connector.prisma.auction.update({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
data: { hostGeneralId: host.id, hostName: host.name },
|
data: { hostGeneralId: host.id, hostName: host.name },
|
||||||
@@ -487,7 +492,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
hostName: '(상인)',
|
hostName: '(상인)',
|
||||||
detail: { remainCloseDateExtensionCnt: 1 },
|
detail: { remainCloseDateExtensionCnt: 1 },
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt: new Date(Date.now() - 60_000),
|
closeAt: logicalPastCloseAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
extensionAuctionId = extensionAuction.id;
|
extensionAuctionId = extensionAuction.id;
|
||||||
@@ -528,8 +533,8 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
expect(reopened).toMatchObject({ status: 'OPEN' });
|
expect(reopened).toMatchObject({ status: 'OPEN' });
|
||||||
expect(reopened!.closeAt.getTime()).toBeGreaterThan(extensionAuction.closeAt.getTime());
|
expect(reopened!.closeAt.getTime()).toBeGreaterThan(extensionAuction.closeAt.getTime());
|
||||||
|
|
||||||
const secondWallNow = new Date();
|
const secondGameNow = world.getGameNow(new Date());
|
||||||
const secondCloseAt = new Date(secondWallNow.getTime() - 1_000);
|
const secondCloseAt = new Date(secondGameNow.getTime() - 1_000);
|
||||||
const secondCloseTick = world.dateToGameTick(secondCloseAt);
|
const secondCloseTick = world.dateToGameTick(secondCloseAt);
|
||||||
await connector.prisma.auction.update({
|
await connector.prisma.auction.update({
|
||||||
where: { id: extensionAuction.id },
|
where: { id: extensionAuction.id },
|
||||||
@@ -546,8 +551,8 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
timerKey: 'timer',
|
timerKey: 'timer',
|
||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(extensionAuction.id),
|
id: String(extensionAuction.id),
|
||||||
nowMs: world.getGameNow(secondWallNow).getTime(),
|
nowMs: secondGameNow.getTime(),
|
||||||
nowTick: world.dateToGameTick(secondWallNow),
|
nowTick: world.dateToGameTick(secondGameNow),
|
||||||
});
|
});
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
@@ -558,6 +563,16 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
if (event?.status === 'SUCCEEDED' && storedAuction?.status === 'CANCELED') break;
|
if (event?.status === 'SUCCEEDED' && storedAuction?.status === 'CANCELED') break;
|
||||||
await delay(25);
|
await delay(25);
|
||||||
}
|
}
|
||||||
|
const secondExtensionEvent = await connector.prisma.inputEvent.findUniqueOrThrow({
|
||||||
|
where: { requestId: secondExtensionRequestId },
|
||||||
|
});
|
||||||
|
expect(secondExtensionEvent).toMatchObject({ status: 'SUCCEEDED', error: null });
|
||||||
|
expect(secondExtensionEvent.result).toEqual({
|
||||||
|
type: 'auctionFinalize',
|
||||||
|
ok: false,
|
||||||
|
auctionId: extensionAuction.id,
|
||||||
|
reason: '아이템 키가 올바르지 않습니다.',
|
||||||
|
});
|
||||||
await expect(
|
await expect(
|
||||||
connector.prisma.auction.findUniqueOrThrow({ where: { id: extensionAuction.id } })
|
connector.prisma.auction.findUniqueOrThrow({ where: { id: extensionAuction.id } })
|
||||||
).resolves.toMatchObject({ status: 'CANCELED' });
|
).resolves.toMatchObject({ status: 'CANCELED' });
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const profile = 'hwe:2';
|
|||||||
const userId = 'create-general-integration-user';
|
const userId = 'create-general-integration-user';
|
||||||
const failureUserId = 'create-general-integration-failure-user';
|
const failureUserId = 'create-general-integration-failure-user';
|
||||||
const rejectedUserId = 'create-general-integration-rejected-user';
|
const rejectedUserId = 'create-general-integration-rejected-user';
|
||||||
|
const customIconId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||||
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
|
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
|
||||||
|
|
||||||
const assertDedicatedDatabase = (rawUrl: string): void => {
|
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||||
@@ -52,6 +53,14 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
|
|||||||
imageServer: 2,
|
imageServer: 2,
|
||||||
iconUpdatedAt: '2026-07-30T00:00:00.000Z',
|
iconUpdatedAt: '2026-07-30T00:00:00.000Z',
|
||||||
canUseGeneralPicture: true,
|
canUseGeneralPicture: true,
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
id: customIconId,
|
||||||
|
picture: 'custom-owner.webp',
|
||||||
|
imageServer: 2,
|
||||||
|
createdAt: '2026-07-30T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
sanctions: {
|
sanctions: {
|
||||||
legacyPenalty: {
|
legacyPenalty: {
|
||||||
@@ -239,6 +248,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
strength: 55,
|
strength: 55,
|
||||||
intel: 55,
|
intel: 55,
|
||||||
pic: true,
|
pic: true,
|
||||||
|
iconId: customIconId,
|
||||||
character: 'che_안전' as const,
|
character: 'che_안전' as const,
|
||||||
clientRequestId,
|
clientRequestId,
|
||||||
inheritTurntimeZone: 7,
|
inheritTurntimeZone: 7,
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ integration('API input event boundary', () => {
|
|||||||
actorUserId: 'user-7',
|
actorUserId: 'user-7',
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
});
|
});
|
||||||
|
expect(event.processingAt).toBeInstanceOf(Date);
|
||||||
|
expect(Math.abs(event.createdAt.getTime() - (event.processingAt?.getTime() ?? 0))).toBeLessThan(1_000);
|
||||||
expect(marker.status).toBe('PENDING');
|
expect(marker.status).toBe('PENDING');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,7 +256,12 @@ integration('API input event boundary', () => {
|
|||||||
it('reuses the same engine child event but rejects a changed retry payload', async () => {
|
it('reuses the same engine child event but rejects a changed retry payload', async () => {
|
||||||
const transport = new DatabaseTurnDaemonTransport(db, 100);
|
const transport = new DatabaseTurnDaemonTransport(db, 100);
|
||||||
const requestId = 'integration:api:engine-child';
|
const requestId = 'integration:api:engine-child';
|
||||||
|
const acceptedWindowStart = Date.now();
|
||||||
await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 });
|
await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 });
|
||||||
|
const acceptedWindowEnd = Date.now();
|
||||||
|
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||||
|
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
|
||||||
|
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
|
||||||
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 7 })).resolves.toBe(requestId);
|
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 7 })).resolves.toBe(requestId);
|
||||||
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf(
|
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf(
|
||||||
ConflictingTurnDaemonCommandError
|
ConflictingTurnDaemonCommandError
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
import {
|
||||||
|
buildNpcSelectionTokenSeed,
|
||||||
|
createTurnDaemonRuntime,
|
||||||
|
seedScenarioToDatabase,
|
||||||
|
type TurnDaemonRuntime,
|
||||||
|
} from '@sammo-ts/game-engine';
|
||||||
import {
|
import {
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
@@ -199,6 +205,32 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
|
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
|
||||||
expect(config.npcPossession).toEqual({ enabled: true });
|
expect(config.npcPossession).toEqual({ enabled: true });
|
||||||
|
|
||||||
|
const worldState = await db.worldState.findFirstOrThrow();
|
||||||
|
const acceptedGameTick = Number(worldState.clockTick);
|
||||||
|
const hiddenSeed = asRecord(worldState.meta).hiddenSeed;
|
||||||
|
expect(Number.isSafeInteger(acceptedGameTick)).toBe(true);
|
||||||
|
if (typeof hiddenSeed !== 'string' && typeof hiddenSeed !== 'number') {
|
||||||
|
throw new Error('NPC possession integration hidden seed is missing');
|
||||||
|
}
|
||||||
|
const selectable = await db.general.findMany({
|
||||||
|
where: { userId: null, npcState: 2 },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { id: true, leadership: true, strength: true, intel: true },
|
||||||
|
});
|
||||||
|
const weights = Object.fromEntries(
|
||||||
|
selectable.map((candidate) => [
|
||||||
|
String(candidate.id),
|
||||||
|
Math.pow(candidate.leadership + candidate.strength + candidate.intel, 1.5),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const rng = new RandUtil(
|
||||||
|
new LiteHashDRBG(buildNpcSelectionTokenSeed(hiddenSeed, 7_701, acceptedGameTick))
|
||||||
|
);
|
||||||
|
const expectedCandidateIds = new Set<number>();
|
||||||
|
while (expectedCandidateIds.size < Math.min(5, selectable.length)) {
|
||||||
|
expectedCandidateIds.add(Number(rng.choiceUsingWeight(weights)));
|
||||||
|
}
|
||||||
|
|
||||||
const [first, concurrentSameOwner] = await Promise.all([
|
const [first, concurrentSameOwner] = await Promise.all([
|
||||||
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
|
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
|
||||||
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}),
|
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}),
|
||||||
@@ -208,6 +240,9 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
expect(first.candidates.length).toBeGreaterThan(0);
|
expect(first.candidates.length).toBeGreaterThan(0);
|
||||||
expect(first.candidates.length).toBeLessThanOrEqual(5);
|
expect(first.candidates.length).toBeLessThanOrEqual(5);
|
||||||
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
|
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
|
||||||
|
expect(first.candidates.map(({ id }) => id).sort((left, right) => left - right)).toEqual(
|
||||||
|
[...expectedCandidateIds].sort((left, right) => left - right)
|
||||||
|
);
|
||||||
expect(first.pickMoreSeconds).toBe(0);
|
expect(first.pickMoreSeconds).toBe(0);
|
||||||
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
||||||
const rows = await db.general.findMany({
|
const rows = await db.general.findMany({
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { createServer, type Server as HttpServer } from 'node:http';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import {
|
import {
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
|
enqueueWebPushOutboxEvents,
|
||||||
resolveRedisConfigFromEnv,
|
resolveRedisConfigFromEnv,
|
||||||
type GamePrismaClient,
|
type GamePrismaClient,
|
||||||
type RedisConnector,
|
type RedisConnector,
|
||||||
@@ -15,17 +17,34 @@ import {
|
|||||||
|
|
||||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
import { createGameApiServer } from '../src/server.js';
|
import { createGameApiServer } from '../src/server.js';
|
||||||
|
import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
const databaseUrl = process.env.SECURITY_TRANSPORT_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_HOST || !process.env.REDIS_PORT);
|
const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL);
|
||||||
const profileId = process.env.POSTGRES_SCHEMA ?? 'conditional_integration';
|
const dedicatedSuffix = 'security_transport';
|
||||||
|
let profileId = process.env.POSTGRES_SCHEMA ?? 'conditional_integration';
|
||||||
const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? String(process.pid);
|
const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? String(process.pid);
|
||||||
const profileName = `che:security-http-${runId}`;
|
const profileName = `che:security-http-${runId}`;
|
||||||
const userId = `security-http-user-${process.pid}`;
|
const userId = `security-http-user-${process.pid}`;
|
||||||
|
const noGeneralUserId = `security-http-no-general-${process.pid}`;
|
||||||
|
const sameNationUserId = `security-http-same-nation-${process.pid}`;
|
||||||
|
const foreignUserId = `security-http-foreign-${process.pid}`;
|
||||||
|
const ordinaryUserId = `security-http-ordinary-${process.pid}`;
|
||||||
const generalId = 990_001;
|
const generalId = 990_001;
|
||||||
|
const sameNationGeneralId = 990_002;
|
||||||
|
const foreignGeneralId = 990_003;
|
||||||
|
const npcGeneralId = 990_004;
|
||||||
|
const ordinaryGeneralId = 990_005;
|
||||||
|
const fixtureGeneralIds = [generalId, sameNationGeneralId, foreignGeneralId, npcGeneralId, ordinaryGeneralId];
|
||||||
|
const ownerNationId = 99_001;
|
||||||
|
const foreignNationId = 99_002;
|
||||||
|
const fixtureNationIds = [ownerNationId, foreignNationId];
|
||||||
|
const fixtureWorldId = 990_001;
|
||||||
|
const mutationRequestPrefix = `security-http-matrix-${process.pid}-`;
|
||||||
const secret = 'security-http-e2e-secret';
|
const secret = 'security-http-e2e-secret';
|
||||||
const redisPrefix = `sammo:security-http:${process.pid}`;
|
const redisPrefix = `sammo:security-http:${process.pid}`;
|
||||||
const envKeys = [
|
const envKeys = [
|
||||||
|
'DATABASE_URL',
|
||||||
'PROFILE',
|
'PROFILE',
|
||||||
'SCENARIO',
|
'SCENARIO',
|
||||||
'GAME_PROFILE_NAME',
|
'GAME_PROFILE_NAME',
|
||||||
@@ -33,6 +52,7 @@ const envKeys = [
|
|||||||
'GAME_API_PORT',
|
'GAME_API_PORT',
|
||||||
'GAME_TOKEN_SECRET',
|
'GAME_TOKEN_SECRET',
|
||||||
'GATEWAY_REDIS_PREFIX',
|
'GATEWAY_REDIS_PREFIX',
|
||||||
|
'GATEWAY_INTERNAL_API_URL',
|
||||||
'GAME_UPLOAD_DIR',
|
'GAME_UPLOAD_DIR',
|
||||||
] as const;
|
] as const;
|
||||||
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||||
@@ -46,6 +66,27 @@ let db: GamePrismaClient;
|
|||||||
let disconnectDb: (() => Promise<void>) | null = null;
|
let disconnectDb: (() => Promise<void>) | null = null;
|
||||||
let redis: RedisConnector | null = null;
|
let redis: RedisConnector | null = null;
|
||||||
let accessTokenStore: RedisAccessTokenStore;
|
let accessTokenStore: RedisAccessTokenStore;
|
||||||
|
let createdFixtureWorld = false;
|
||||||
|
let gatewayStatusServer: HttpServer | null = null;
|
||||||
|
let receivedGatewayWebPushEvents: Array<{ internalToken: string | null; body: unknown }> = [];
|
||||||
|
|
||||||
|
export const assertDedicatedSecurityTransportDatabase = (rawUrl: string): void => {
|
||||||
|
resolveDedicatedSecurityTransportTarget(rawUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDedicatedSecurityTransportTarget = (rawUrl: string): { databaseUrl: string; schema: string } => {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
const schema = url.searchParams.get('schema');
|
||||||
|
const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
|
||||||
|
if (!schema?.endsWith(dedicatedSuffix) && !databaseName.endsWith(dedicatedSuffix)) {
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to mutate non-dedicated security transport database: schema=${schema ?? '(missing)'}, database=${databaseName || '(missing)'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const effectiveSchema = schema?.trim() || 'public';
|
||||||
|
url.searchParams.set('schema', effectiveSchema);
|
||||||
|
return { databaseUrl: url.href, schema: effectiveSchema };
|
||||||
|
};
|
||||||
|
|
||||||
const restoreEnv = (): void => {
|
const restoreEnv = (): void => {
|
||||||
for (const [key, value] of originalEnv) {
|
for (const [key, value] of originalEnv) {
|
||||||
@@ -57,28 +98,90 @@ const restoreEnv = (): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listenGatewayStatusStub = async (): Promise<string> => {
|
||||||
|
gatewayStatusServer = createServer((request, response) => {
|
||||||
|
if (request.method === 'GET' && request.url === `/internal/profile-status/${encodeURIComponent(profileName)}`) {
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ profileName, status: 'RUNNING' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && request.url === '/internal/account-icon-resets') {
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ resets: [] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && request.url === '/internal/web-push-events') {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
request.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk)));
|
||||||
|
request.on('end', () => {
|
||||||
|
try {
|
||||||
|
receivedGatewayWebPushEvents.push({
|
||||||
|
internalToken:
|
||||||
|
typeof request.headers['x-sammo-internal-token'] === 'string'
|
||||||
|
? request.headers['x-sammo-internal-token']
|
||||||
|
: null,
|
||||||
|
body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown,
|
||||||
|
});
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
response.end('{}');
|
||||||
|
} catch {
|
||||||
|
response.writeHead(400);
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
gatewayStatusServer!.once('error', reject);
|
||||||
|
gatewayStatusServer!.listen(0, '127.0.0.1', () => {
|
||||||
|
gatewayStatusServer!.off('error', reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const address = gatewayStatusServer.address();
|
||||||
|
if (!address || typeof address === 'string') throw new Error('gateway status stub did not bind a TCP port');
|
||||||
|
return `http://127.0.0.1:${address.port}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeGatewayStatusStub = async (): Promise<void> => {
|
||||||
|
if (!gatewayStatusServer) return;
|
||||||
|
const current = gatewayStatusServer;
|
||||||
|
gatewayStatusServer = null;
|
||||||
|
await new Promise<void>((resolve, reject) => current.close((error) => (error ? reject(error) : resolve())));
|
||||||
|
};
|
||||||
|
|
||||||
const deleteProfileRedisKeys = async (): Promise<void> => {
|
const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||||
if (!redis) {
|
if (!redis) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for await (const keys of redis.client.scanIterator({
|
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
||||||
MATCH: `sammo:game:*:${profileName}:*`,
|
for await (const keys of redis.client.scanIterator({
|
||||||
COUNT: 100,
|
MATCH: pattern,
|
||||||
})) {
|
COUNT: 100,
|
||||||
if (keys.length > 0) {
|
})) {
|
||||||
await redis.client.del(keys);
|
if (keys.length > 0) {
|
||||||
|
await redis.client.del(keys);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildPayload = (suffix: string, sanctions: GameSessionTokenPayload['sanctions']): GameSessionTokenPayload => ({
|
const buildPayload = (
|
||||||
|
suffix: string,
|
||||||
|
sanctions: GameSessionTokenPayload['sanctions'],
|
||||||
|
actorUserId = userId,
|
||||||
|
actorProfile = profileName
|
||||||
|
): GameSessionTokenPayload => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
profile: profileName,
|
profile: actorProfile,
|
||||||
issuedAt: new Date(Date.now() - 1_000).toISOString(),
|
issuedAt: new Date(Date.now() - 1_000).toISOString(),
|
||||||
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
|
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
|
||||||
sessionId: `security-http-session-${process.pid}-${suffix}`,
|
sessionId: `security-http-session-${process.pid}-${suffix}`,
|
||||||
user: {
|
user: {
|
||||||
id: userId,
|
id: actorUserId,
|
||||||
username: 'security-http-user',
|
username: 'security-http-user',
|
||||||
displayName: 'Security HTTP User',
|
displayName: 'Security HTTP User',
|
||||||
roles: ['user'],
|
roles: ['user'],
|
||||||
@@ -87,8 +190,12 @@ const buildPayload = (suffix: string, sanctions: GameSessionTokenPayload['sancti
|
|||||||
sanctions,
|
sanctions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createAccessToken = async (suffix: string, sanctions: GameSessionTokenPayload['sanctions']): Promise<string> => {
|
const createAccessToken = async (
|
||||||
const created = await accessTokenStore.create(buildPayload(suffix, sanctions));
|
suffix: string,
|
||||||
|
sanctions: GameSessionTokenPayload['sanctions'],
|
||||||
|
actorUserId = userId
|
||||||
|
): Promise<string> => {
|
||||||
|
const created = await accessTokenStore.create(buildPayload(suffix, sanctions, actorUserId));
|
||||||
if (!created) {
|
if (!created) {
|
||||||
throw new Error('failed to seed the game access token');
|
throw new Error('failed to seed the game access token');
|
||||||
}
|
}
|
||||||
@@ -101,6 +208,7 @@ const requestTrpc = async (
|
|||||||
method?: 'GET' | 'POST';
|
method?: 'GET' | 'POST';
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<{ response: Response; body: unknown }> => {
|
): Promise<{ response: Response; body: unknown }> => {
|
||||||
const method = options.method ?? 'GET';
|
const method = options.method ?? 'GET';
|
||||||
@@ -109,6 +217,7 @@ const requestTrpc = async (
|
|||||||
headers: {
|
headers: {
|
||||||
...(method === 'POST' ? { 'content-type': 'application/json' } : {}),
|
...(method === 'POST' ? { 'content-type': 'application/json' } : {}),
|
||||||
...(options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}),
|
...(options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}),
|
||||||
|
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
|
||||||
},
|
},
|
||||||
...(method === 'POST' ? { body: JSON.stringify(options.input) } : {}),
|
...(method === 'POST' ? { body: JSON.stringify(options.input) } : {}),
|
||||||
});
|
});
|
||||||
@@ -118,31 +227,402 @@ const requestTrpc = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readReservedMutationState = async () => ({
|
||||||
|
generals: await db.general.findMany({
|
||||||
|
where: { id: { in: fixtureGeneralIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
nationId: true,
|
||||||
|
officerLevel: true,
|
||||||
|
lastTurn: true,
|
||||||
|
meta: true,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
generalTurns: await db.generalTurn.findMany({
|
||||||
|
where: { generalId: { in: fixtureGeneralIds } },
|
||||||
|
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
|
||||||
|
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||||
|
}),
|
||||||
|
generalTurnRevisions: await db.generalTurnRevision.findMany({
|
||||||
|
where: { generalId: { in: fixtureGeneralIds } },
|
||||||
|
select: { generalId: true, revision: true, leaseOwner: true, leaseExpiresAt: true },
|
||||||
|
orderBy: { generalId: 'asc' },
|
||||||
|
}),
|
||||||
|
generalAccessLogs: await db.generalAccessLog.findMany({
|
||||||
|
where: { generalId: { in: fixtureGeneralIds } },
|
||||||
|
select: {
|
||||||
|
generalId: true,
|
||||||
|
userId: true,
|
||||||
|
lastRefresh: true,
|
||||||
|
refresh: true,
|
||||||
|
refreshTotal: true,
|
||||||
|
refreshScore: true,
|
||||||
|
refreshScoreTotal: true,
|
||||||
|
lastActionAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { generalId: 'asc' },
|
||||||
|
}),
|
||||||
|
nationTurns: await db.nationTurn.findMany({
|
||||||
|
where: { nationId: { in: fixtureNationIds } },
|
||||||
|
select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true },
|
||||||
|
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }],
|
||||||
|
}),
|
||||||
|
nationTurnRevisions: await db.nationTurnRevision.findMany({
|
||||||
|
where: { nationId: { in: fixtureNationIds } },
|
||||||
|
select: { nationId: true, officerLevel: true, revision: true, leaseOwner: true, leaseExpiresAt: true },
|
||||||
|
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }],
|
||||||
|
}),
|
||||||
|
readModelRevisions: await db.readModelRevision.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
|
||||||
|
{ domain: 'dashboard.global', entityId: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { domain: true, entityId: true, revision: true },
|
||||||
|
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
|
||||||
|
}),
|
||||||
|
readModelOutbox: await db.readModelOutbox.findMany({
|
||||||
|
select: { id: true, payload: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
messages: await db.message.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ src: { in: fixtureGeneralIds } }, { dest: { in: [...fixtureGeneralIds, ...fixtureNationIds] } }],
|
||||||
|
},
|
||||||
|
select: { id: true, mailbox: true, type: true, src: true, dest: true, message: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
logs: await db.logEntry.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ generalId: { in: fixtureGeneralIds } }, { nationId: { in: fixtureNationIds } }],
|
||||||
|
},
|
||||||
|
select: { id: true, scope: true, category: true, generalId: true, nationId: true, text: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
engineInputEvents: await db.inputEvent.findMany({
|
||||||
|
where: { target: 'ENGINE', requestId: { startsWith: mutationRequestPrefix } },
|
||||||
|
select: { requestId: true, eventType: true, status: true, actorUserId: true },
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
}),
|
||||||
|
webPushOutboxCount: await db.webPushOutbox.count(),
|
||||||
|
eventCount: await db.event.count(),
|
||||||
|
auctionCount: await db.auction.count(),
|
||||||
|
auctionBidCount: await db.auctionBid.count(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const quotePostgresIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`;
|
||||||
|
|
||||||
|
const readDurableSchemaStateExcludingMatrixApiJournal = async () => {
|
||||||
|
const tables = await db.$queryRawUnsafe<Array<{ tableName: string }>>(
|
||||||
|
`SELECT table_name AS "tableName"
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name`,
|
||||||
|
profileId
|
||||||
|
);
|
||||||
|
return Promise.all(
|
||||||
|
tables.map(async ({ tableName }) => {
|
||||||
|
const qualifiedTable = `${quotePostgresIdentifier(profileId)}.${quotePostgresIdentifier(tableName)}`;
|
||||||
|
const matrixApiFilter =
|
||||||
|
tableName === 'input_event' ? `WHERE NOT (target = 'API' AND request_id LIKE $1)` : '';
|
||||||
|
const rows = await db.$queryRawUnsafe<Array<{ rowJson: string }>>(
|
||||||
|
`SELECT to_jsonb(snapshot_row)::text AS "rowJson"
|
||||||
|
FROM ${qualifiedTable} AS snapshot_row
|
||||||
|
${matrixApiFilter}
|
||||||
|
ORDER BY to_jsonb(snapshot_row)::text`,
|
||||||
|
...(matrixApiFilter ? [`${mutationRequestPrefix}%`] : [])
|
||||||
|
);
|
||||||
|
return { tableName, rows: rows.map(({ rowJson }) => rowJson) };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DurableSchemaState = Awaited<ReturnType<typeof readDurableSchemaStateExcludingMatrixApiJournal>>;
|
||||||
|
|
||||||
|
const withoutDurableTables = (state: DurableSchemaState, allowedTables: readonly string[]): DurableSchemaState => {
|
||||||
|
const allowed = new Set(allowedTables);
|
||||||
|
return state.filter(({ tableName }) => !allowed.has(tableName));
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSuccessAllowedTableState = async () => ({
|
||||||
|
generalTurns: await db.generalTurn.findMany({ orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }] }),
|
||||||
|
generalTurnRevisions: await db.generalTurnRevision.findMany({ orderBy: { generalId: 'asc' } }),
|
||||||
|
nationTurns: await db.nationTurn.findMany({
|
||||||
|
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }],
|
||||||
|
}),
|
||||||
|
nationTurnRevisions: await db.nationTurnRevision.findMany({
|
||||||
|
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }],
|
||||||
|
}),
|
||||||
|
generalAccessLogs: await db.generalAccessLog.findMany({ orderBy: { generalId: 'asc' } }),
|
||||||
|
readModelRevisions: await db.readModelRevision.findMany({
|
||||||
|
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
|
||||||
|
}),
|
||||||
|
readModelOutbox: await db.readModelOutbox.findMany({ orderBy: { id: 'asc' } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const readAccessTelemetryState = async () => ({
|
||||||
|
periods: await db.trafficPeriod.findMany({ orderBy: { id: 'asc' } }),
|
||||||
|
generals: await db.trafficPeriodGeneral.findMany({
|
||||||
|
orderBy: [{ periodId: 'asc' }, { generalId: 'asc' }],
|
||||||
|
}),
|
||||||
|
accessLogs: await db.generalAccessLog.findMany({ orderBy: { generalId: 'asc' } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
|
||||||
|
if (!redis) return [];
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
||||||
|
for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
|
||||||
|
for (const key of batch) keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.all(
|
||||||
|
[...keys].sort().map(async (key) => [key, await redis!.client.get(key)] as [string, string | null])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectApiInputEvent = async (
|
||||||
|
idempotencyKey: string,
|
||||||
|
procedure: string,
|
||||||
|
expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED' } | null
|
||||||
|
): Promise<void> => {
|
||||||
|
const requestId = `${idempotencyKey}:${procedure}`;
|
||||||
|
const events = await db.inputEvent.findMany({
|
||||||
|
// beforeEach removes the whole matrix prefix. Query that complete
|
||||||
|
// namespace so an extra/rewritten API journal row cannot hide behind
|
||||||
|
// the full-schema snapshot's one explicitly allowed exclusion.
|
||||||
|
where: { target: 'API', requestId: { startsWith: mutationRequestPrefix } },
|
||||||
|
select: {
|
||||||
|
requestId: true,
|
||||||
|
target: true,
|
||||||
|
eventType: true,
|
||||||
|
payload: true,
|
||||||
|
actorUserId: true,
|
||||||
|
status: true,
|
||||||
|
result: true,
|
||||||
|
error: true,
|
||||||
|
attempts: true,
|
||||||
|
lockedBy: true,
|
||||||
|
leaseUntil: true,
|
||||||
|
processingAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
});
|
||||||
|
if (!expected) {
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
target: 'API',
|
||||||
|
eventType: procedure,
|
||||||
|
payload: {},
|
||||||
|
actorUserId: expected.actorUserId,
|
||||||
|
status: expected.status,
|
||||||
|
result: expected.status === 'SUCCEEDED' ? { ok: true } : null,
|
||||||
|
error: expected.status === 'SUCCEEDED' ? null : expect.any(String),
|
||||||
|
attempts: 1,
|
||||||
|
lockedBy: null,
|
||||||
|
leaseUntil: null,
|
||||||
|
processingAt: expect.any(Date),
|
||||||
|
completedAt: expect.any(Date),
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const event = events[0];
|
||||||
|
if (!event?.processingAt || !event.completedAt) {
|
||||||
|
throw new Error('API input event must have processing/completion timestamps');
|
||||||
|
}
|
||||||
|
expect(event.completedAt.getTime()).toBeGreaterThanOrEqual(event.processingAt.getTime());
|
||||||
|
if (expected.status === 'FAILED') {
|
||||||
|
expect(event.error?.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectSingleActorActivity = (
|
||||||
|
rows: Awaited<ReturnType<typeof readReservedMutationState>>['generalAccessLogs']
|
||||||
|
) => {
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{
|
||||||
|
generalId,
|
||||||
|
userId,
|
||||||
|
lastRefresh: null,
|
||||||
|
refresh: 0,
|
||||||
|
refreshTotal: 0,
|
||||||
|
refreshScore: 0,
|
||||||
|
refreshScoreTotal: 0,
|
||||||
|
lastActionAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestReservedGeneral = (accessToken: string | undefined, idempotencyKey: string, targetGeneralId = generalId) =>
|
||||||
|
requestTrpc('turns.reserved.setGeneral', {
|
||||||
|
method: 'POST',
|
||||||
|
input: {
|
||||||
|
generalId: targetGeneralId,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: '휴식',
|
||||||
|
args: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
},
|
||||||
|
accessToken,
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestReservedNation = (accessToken: string, idempotencyKey: string, targetGeneralId: number) =>
|
||||||
|
requestTrpc('turns.reserved.setNation', {
|
||||||
|
method: 'POST',
|
||||||
|
input: {
|
||||||
|
generalId: targetGeneralId,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: '휴식',
|
||||||
|
args: {},
|
||||||
|
expectedRevision: 0,
|
||||||
|
},
|
||||||
|
accessToken,
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownershipDenialCases = [
|
||||||
|
{
|
||||||
|
label: 'authenticated user without a general',
|
||||||
|
actorUserId: noGeneralUserId,
|
||||||
|
targetGeneralId: generalId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'same-nation foreign-owned general',
|
||||||
|
actorUserId: userId,
|
||||||
|
targetGeneralId: sameNationGeneralId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'other-nation foreign-owned general',
|
||||||
|
actorUserId: userId,
|
||||||
|
targetGeneralId: foreignGeneralId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'NPC general',
|
||||||
|
actorUserId: userId,
|
||||||
|
targetGeneralId: npcGeneralId,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe('security transport database guard', () => {
|
||||||
|
it('rejects a shared database and schema before connecting', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertDedicatedSecurityTransportDatabase('postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=public')
|
||||||
|
).toThrow('Refusing to mutate non-dedicated security transport database');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts only an explicitly dedicated schema or database name', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertDedicatedSecurityTransportDatabase(
|
||||||
|
'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=ci_security_transport'
|
||||||
|
)
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
assertDedicatedSecurityTransportDatabase(
|
||||||
|
'postgresql://fixture:fixture@127.0.0.1:5432/ci_security_transport'
|
||||||
|
)
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
integration('game API security over HTTP transport', () => {
|
integration('game API security over HTTP transport', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
const dedicatedTarget = resolveDedicatedSecurityTransportTarget(databaseUrl!);
|
||||||
|
profileId = dedicatedTarget.schema;
|
||||||
uploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-game-security-http-'));
|
uploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-game-security-http-'));
|
||||||
process.env.PROFILE = profileId;
|
process.env.DATABASE_URL = dedicatedTarget.databaseUrl;
|
||||||
|
process.env.PROFILE = dedicatedTarget.schema;
|
||||||
process.env.SCENARIO = 'security-http';
|
process.env.SCENARIO = 'security-http';
|
||||||
process.env.GAME_PROFILE_NAME = profileName;
|
process.env.GAME_PROFILE_NAME = profileName;
|
||||||
process.env.GAME_API_HOST = '127.0.0.1';
|
process.env.GAME_API_HOST = '127.0.0.1';
|
||||||
process.env.GAME_API_PORT = '0';
|
process.env.GAME_API_PORT = '0';
|
||||||
process.env.GAME_TOKEN_SECRET = secret;
|
process.env.GAME_TOKEN_SECRET = secret;
|
||||||
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
|
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
|
||||||
|
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
|
||||||
process.env.GAME_UPLOAD_DIR = uploadDir;
|
process.env.GAME_UPLOAD_DIR = uploadDir;
|
||||||
|
|
||||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
|
||||||
await connector.connect();
|
await connector.connect();
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
disconnectDb = () => connector.disconnect();
|
disconnectDb = () => connector.disconnect();
|
||||||
await db.general.deleteMany({ where: { id: generalId } });
|
await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
await db.general.create({
|
await db.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
data: {
|
await db.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
id: generalId,
|
await db.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||||
userId,
|
await db.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||||
name: '보안HTTP',
|
await db.general.deleteMany({ where: { id: { in: fixtureGeneralIds } } });
|
||||||
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
await db.general.createMany({
|
||||||
},
|
data: [
|
||||||
|
{
|
||||||
|
id: generalId,
|
||||||
|
userId,
|
||||||
|
name: '보안HTTP',
|
||||||
|
nationId: ownerNationId,
|
||||||
|
officerLevel: 12,
|
||||||
|
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: sameNationGeneralId,
|
||||||
|
userId: sameNationUserId,
|
||||||
|
name: '동일국타인',
|
||||||
|
nationId: ownerNationId,
|
||||||
|
officerLevel: 5,
|
||||||
|
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: foreignGeneralId,
|
||||||
|
userId: foreignUserId,
|
||||||
|
name: '타국타인',
|
||||||
|
nationId: foreignNationId,
|
||||||
|
officerLevel: 5,
|
||||||
|
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: npcGeneralId,
|
||||||
|
userId: null,
|
||||||
|
name: 'NPC장수',
|
||||||
|
nationId: ownerNationId,
|
||||||
|
npcState: 2,
|
||||||
|
officerLevel: 5,
|
||||||
|
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ordinaryGeneralId,
|
||||||
|
userId: ordinaryUserId,
|
||||||
|
name: '비수뇌',
|
||||||
|
nationId: ownerNationId,
|
||||||
|
officerLevel: 4,
|
||||||
|
turnTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
if ((await db.worldState.count()) === 0) {
|
||||||
|
await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: fixtureWorldId,
|
||||||
|
scenarioCode: 'security-http',
|
||||||
|
currentYear: 190,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: {},
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdFixtureWorld = true;
|
||||||
|
}
|
||||||
|
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
|
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
|
||||||
|
await db.readModelOutbox.deleteMany();
|
||||||
|
await db.webPushOutbox.deleteMany();
|
||||||
|
|
||||||
redis = createRedisConnector(resolveRedisConfigFromEnv());
|
redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
await redis.connect();
|
await redis.connect();
|
||||||
@@ -157,7 +637,29 @@ integration('game API security over HTTP transport', () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await server?.app.close();
|
await server?.app.close();
|
||||||
await db?.general.deleteMany({ where: { id: generalId } });
|
await closeGatewayStatusStub();
|
||||||
|
await db?.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } });
|
||||||
|
await db?.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
|
await db?.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
|
||||||
|
await db?.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
|
await db?.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
|
await db?.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
|
await db?.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||||
|
await db?.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||||
|
await db?.readModelRevision.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
|
||||||
|
{ domain: 'dashboard.global', entityId: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db?.readModelOutbox.deleteMany();
|
||||||
|
await db?.webPushOutbox.deleteMany();
|
||||||
|
await db?.general.deleteMany({ where: { id: { in: fixtureGeneralIds } } });
|
||||||
|
if (createdFixtureWorld) {
|
||||||
|
await db?.worldState.deleteMany({ where: { id: fixtureWorldId } });
|
||||||
|
}
|
||||||
await disconnectDb?.();
|
await disconnectDb?.();
|
||||||
await deleteProfileRedisKeys();
|
await deleteProfileRedisKeys();
|
||||||
await redis?.disconnect();
|
await redis?.disconnect();
|
||||||
@@ -167,6 +669,31 @@ integration('game API security over HTTP transport', () => {
|
|||||||
restoreEnv();
|
restoreEnv();
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } });
|
||||||
|
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
|
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
|
||||||
|
await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
|
await db.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
|
await db.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
|
||||||
|
await db.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||||
|
await db.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } });
|
||||||
|
await db.readModelRevision.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
|
||||||
|
{ domain: 'dashboard.global', entityId: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.readModelOutbox.deleteMany();
|
||||||
|
await db.webPushOutbox.deleteMany();
|
||||||
|
receivedGatewayWebPushEvents = [];
|
||||||
|
if (redis) {
|
||||||
|
await redis.client.del(`sammo:${profileName}:read-model:revision`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts an authenticated query from a POST JSON body', async () => {
|
it('accepts an authenticated query from a POST JSON body', async () => {
|
||||||
const accessToken = await createAccessToken('json-query-body', {});
|
const accessToken = await createAccessToken('json-query-body', {});
|
||||||
const general = await requestTrpc('general.me', {
|
const general = await requestTrpc('general.me', {
|
||||||
@@ -190,30 +717,32 @@ integration('game API security over HTTP transport', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
label: 'global suspension',
|
label: 'global suspension',
|
||||||
sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' },
|
sanctions: () => ({ suspendedUntil: '2099-01-01T00:00:00.000Z' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'instance game restriction',
|
label: 'instance game restriction',
|
||||||
sanctions: {
|
sanctions: () => ({
|
||||||
serverRestrictions: {
|
serverRestrictions: {
|
||||||
[profileName]: {
|
[profileName]: {
|
||||||
blockedFeatures: ['game'],
|
blockedFeatures: ['game'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'profile-id wildcard restriction',
|
label: 'profile-id wildcard restriction',
|
||||||
sanctions: {
|
sanctions: () => ({
|
||||||
serverRestrictions: {
|
serverRestrictions: {
|
||||||
[profileId]: {
|
[profileId]: {
|
||||||
blockedFeatures: ['*'],
|
blockedFeatures: ['*'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
])('blocks an authenticated game API request for $label', async ({ label, sanctions }) => {
|
])('blocks an authenticated game API request for $label', async ({ label, sanctions }) => {
|
||||||
const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions);
|
const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions());
|
||||||
|
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const redisBefore = await readRealtimeRedisState();
|
||||||
const blocked = await requestTrpc('general.me', { accessToken });
|
const blocked = await requestTrpc('general.me', { accessToken });
|
||||||
|
|
||||||
expect(blocked.response.status).toBe(403);
|
expect(blocked.response.status).toBe(403);
|
||||||
@@ -224,6 +753,8 @@ integration('game API security over HTTP transport', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -243,6 +774,7 @@ integration('game API security over HTTP transport', () => {
|
|||||||
},
|
},
|
||||||
])('allows non-message APIs but blocks message send for $label', async ({ label, sanctions }) => {
|
])('allows non-message APIs but blocks message send for $label', async ({ label, sanctions }) => {
|
||||||
const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions);
|
const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions);
|
||||||
|
const idempotencyKey = `${mutationRequestPrefix}message-${label.replaceAll(' ', '-')}`;
|
||||||
const general = await requestTrpc('general.me', { accessToken });
|
const general = await requestTrpc('general.me', { accessToken });
|
||||||
expect(general.response.status).toBe(200);
|
expect(general.response.status).toBe(200);
|
||||||
expect(general.body).toMatchObject({
|
expect(general.body).toMatchObject({
|
||||||
@@ -254,6 +786,9 @@ integration('game API security over HTTP transport', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const telemetryBefore = await readAccessTelemetryState();
|
||||||
|
const redisBefore = await readRealtimeRedisState();
|
||||||
|
|
||||||
const message = await requestTrpc('messages.send', {
|
const message = await requestTrpc('messages.send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -263,6 +798,7 @@ integration('game API security over HTTP transport', () => {
|
|||||||
text: '차단되어야 하는 메시지',
|
text: '차단되어야 하는 메시지',
|
||||||
},
|
},
|
||||||
accessToken,
|
accessToken,
|
||||||
|
idempotencyKey,
|
||||||
});
|
});
|
||||||
expect(message.response.status).toBe(403);
|
expect(message.response.status).toBe(403);
|
||||||
expect(message.body).toMatchObject({
|
expect(message.body).toMatchObject({
|
||||||
@@ -272,6 +808,59 @@ integration('game API security over HTTP transport', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await expectApiInputEvent(idempotencyKey, 'messages.send', {
|
||||||
|
actorUserId: userId,
|
||||||
|
status: 'FAILED',
|
||||||
|
});
|
||||||
|
expect(telemetryBefore).toEqual({ periods: [], generals: [], accessLogs: [] });
|
||||||
|
const telemetryAfter = await readAccessTelemetryState();
|
||||||
|
expect(telemetryAfter.periods).toEqual([
|
||||||
|
{
|
||||||
|
id: expect.any(Number),
|
||||||
|
worldStateId: fixtureWorldId,
|
||||||
|
year: 190,
|
||||||
|
month: 1,
|
||||||
|
startedAt: expect.any(Date),
|
||||||
|
lastRefresh: expect.any(Date),
|
||||||
|
refresh: 1,
|
||||||
|
online: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const trafficPeriod = telemetryAfter.periods[0];
|
||||||
|
if (!trafficPeriod) throw new Error('message access did not create its traffic period');
|
||||||
|
expect(telemetryAfter.generals).toEqual([
|
||||||
|
{
|
||||||
|
periodId: trafficPeriod.id,
|
||||||
|
generalId,
|
||||||
|
userId,
|
||||||
|
refresh: 1,
|
||||||
|
lastRefresh: trafficPeriod.lastRefresh,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(telemetryAfter.accessLogs).toEqual([
|
||||||
|
{
|
||||||
|
id: expect.any(Number),
|
||||||
|
generalId,
|
||||||
|
userId,
|
||||||
|
lastRefresh: trafficPeriod.lastRefresh,
|
||||||
|
lastActionAt: null,
|
||||||
|
refresh: 1,
|
||||||
|
refreshTotal: 1,
|
||||||
|
refreshScore: 1,
|
||||||
|
refreshScoreTotal: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(trafficPeriod.lastRefresh.getTime()).toBeGreaterThanOrEqual(trafficPeriod.startedAt.getTime());
|
||||||
|
expect(
|
||||||
|
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [
|
||||||
|
'traffic_period',
|
||||||
|
'traffic_period_general',
|
||||||
|
'general_access_log',
|
||||||
|
])
|
||||||
|
).toEqual(
|
||||||
|
withoutDurableTables(durableBefore, ['traffic_period', 'traffic_period_general', 'general_access_log'])
|
||||||
|
);
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a restricted signed gateway token before issuing a game access token', async () => {
|
it('rejects a restricted signed gateway token before issuing a game access token', async () => {
|
||||||
@@ -285,6 +874,8 @@ integration('game API security over HTTP transport', () => {
|
|||||||
}),
|
}),
|
||||||
secret
|
secret
|
||||||
);
|
);
|
||||||
|
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const redisBefore = await readRealtimeRedisState();
|
||||||
const blocked = await requestTrpc('auth.exchangeGatewayToken', {
|
const blocked = await requestTrpc('auth.exchangeGatewayToken', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
input: { gatewayToken },
|
input: { gatewayToken },
|
||||||
@@ -298,8 +889,878 @@ integration('game API security over HTTP transport', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
label: 'missing bearer token',
|
||||||
|
accessToken: async () => undefined,
|
||||||
|
expectedStatus: 401,
|
||||||
|
expectedCode: 'UNAUTHORIZED',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'unknown bearer token',
|
||||||
|
accessToken: async () => 'unknown-security-http-token',
|
||||||
|
expectedStatus: 401,
|
||||||
|
expectedCode: 'UNAUTHORIZED',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'access token stored for another profile',
|
||||||
|
accessToken: async () => {
|
||||||
|
const otherProfileStore = new RedisAccessTokenStore(redis!.client, `${profileName}:other`);
|
||||||
|
const created = await otherProfileStore.create(
|
||||||
|
buildPayload('cross-profile', {}, userId, `${profileName}:other`)
|
||||||
|
);
|
||||||
|
if (!created) throw new Error('failed to seed the cross-profile access token');
|
||||||
|
return created.accessToken;
|
||||||
|
},
|
||||||
|
expectedStatus: 401,
|
||||||
|
expectedCode: 'UNAUTHORIZED',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'gameplay sanction',
|
||||||
|
accessToken: () =>
|
||||||
|
createAccessToken('matrix-sanction', {
|
||||||
|
serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } },
|
||||||
|
}),
|
||||||
|
expectedStatus: 403,
|
||||||
|
expectedCode: 'FORBIDDEN',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'rejects $label before creating an API input event or any durable/Redis gameplay side effect',
|
||||||
|
async ({ label, accessToken, expectedStatus, expectedCode }) => {
|
||||||
|
const idempotencyKey = `${mutationRequestPrefix}auth-${label.replaceAll(' ', '-')}`;
|
||||||
|
const token = await accessToken();
|
||||||
|
const databaseBefore = await readReservedMutationState();
|
||||||
|
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const redisBefore = await readRealtimeRedisState();
|
||||||
|
|
||||||
|
const result = await requestReservedGeneral(token, idempotencyKey);
|
||||||
|
|
||||||
|
expect(result.response.status).toBe(expectedStatus);
|
||||||
|
expect(result.body).toMatchObject({ error: { data: { code: expectedCode } } });
|
||||||
|
expect(await readReservedMutationState()).toEqual(databaseBefore);
|
||||||
|
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||||
|
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(ownershipDenialCases)(
|
||||||
|
'keeps reserved queues, journal/outbox, ENGINE events, and profile Redis unchanged for $label general ownership denial',
|
||||||
|
async ({ label, actorUserId, targetGeneralId }) => {
|
||||||
|
const idempotencyKey = `${mutationRequestPrefix}owner-${label.replaceAll(' ', '-')}`;
|
||||||
|
const accessToken = await createAccessToken(`matrix-owner-${label.replaceAll(' ', '-')}`, {}, actorUserId);
|
||||||
|
const databaseBefore = await readReservedMutationState();
|
||||||
|
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const redisBefore = await readRealtimeRedisState();
|
||||||
|
|
||||||
|
const result = await requestReservedGeneral(accessToken, idempotencyKey, targetGeneralId);
|
||||||
|
|
||||||
|
expect(result.response.status).toBe(403);
|
||||||
|
expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } });
|
||||||
|
expect(await readReservedMutationState()).toEqual(databaseBefore);
|
||||||
|
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||||
|
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', {
|
||||||
|
actorUserId,
|
||||||
|
status: 'FAILED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(ownershipDenialCases)(
|
||||||
|
'keeps reserved queues, journal/outbox, ENGINE events, and profile Redis unchanged for $label nation ownership denial',
|
||||||
|
async ({ label, actorUserId, targetGeneralId }) => {
|
||||||
|
const idempotencyKey = `${mutationRequestPrefix}nation-owner-${label.replaceAll(' ', '-')}`;
|
||||||
|
const accessToken = await createAccessToken(
|
||||||
|
`matrix-nation-owner-${label.replaceAll(' ', '-')}`,
|
||||||
|
{},
|
||||||
|
actorUserId
|
||||||
|
);
|
||||||
|
const databaseBefore = await readReservedMutationState();
|
||||||
|
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const redisBefore = await readRealtimeRedisState();
|
||||||
|
|
||||||
|
const result = await requestReservedNation(accessToken, idempotencyKey, targetGeneralId);
|
||||||
|
|
||||||
|
expect(result.response.status).toBe(403);
|
||||||
|
expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } });
|
||||||
|
expect(await readReservedMutationState()).toEqual(databaseBefore);
|
||||||
|
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||||
|
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||||
|
actorUserId,
|
||||||
|
status: 'FAILED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('keeps the nation queue unchanged when an owned general is below the officer threshold', async () => {
|
||||||
|
const idempotencyKey = `${mutationRequestPrefix}nation-non-officer`;
|
||||||
|
const accessToken = await createAccessToken('matrix-nation-non-officer', {}, ordinaryUserId);
|
||||||
|
const databaseBefore = await readReservedMutationState();
|
||||||
|
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const redisBefore = await readRealtimeRedisState();
|
||||||
|
|
||||||
|
const result = await requestReservedNation(accessToken, idempotencyKey, ordinaryGeneralId);
|
||||||
|
|
||||||
|
expect(result.response.status).toBe(403);
|
||||||
|
expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } });
|
||||||
|
expect(await readReservedMutationState()).toEqual(databaseBefore);
|
||||||
|
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore);
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||||
|
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||||
|
actorUserId: ordinaryUserId,
|
||||||
|
status: 'FAILED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits an owned general reservation once with an authenticated actor and durable journal', async () => {
|
||||||
|
const idempotencyKey = `${mutationRequestPrefix}general-success`;
|
||||||
|
const accessToken = await createAccessToken('matrix-general-success', {});
|
||||||
|
const databaseBefore = await readReservedMutationState();
|
||||||
|
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const allowedTablesBefore = await readSuccessAllowedTableState();
|
||||||
|
const redisBefore = await readRealtimeRedisState();
|
||||||
|
|
||||||
|
const result = await requestReservedGeneral(accessToken, idempotencyKey);
|
||||||
|
|
||||||
|
expect(result.response.status).toBe(200);
|
||||||
|
expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
|
||||||
|
expect(
|
||||||
|
await db.generalTurn.findMany({
|
||||||
|
where: { generalId },
|
||||||
|
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
|
||||||
|
orderBy: { turnIdx: 'asc' },
|
||||||
|
})
|
||||||
|
).toEqual(
|
||||||
|
Array.from({ length: 30 }, (_, turnIdx) => ({
|
||||||
|
generalId,
|
||||||
|
turnIdx,
|
||||||
|
actionCode: '휴식',
|
||||||
|
arg: {},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
expect(await db.generalTurnRevision.findUnique({ where: { generalId } })).toMatchObject({
|
||||||
|
revision: 1,
|
||||||
|
leaseOwner: null,
|
||||||
|
leaseExpiresAt: null,
|
||||||
|
});
|
||||||
|
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', {
|
||||||
|
actorUserId: userId,
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await db.inputEvent.count({
|
||||||
|
where: { target: 'ENGINE', requestId: { startsWith: idempotencyKey } },
|
||||||
|
})
|
||||||
|
).toBe(0);
|
||||||
|
await expect.poll(() => db.readModelOutbox.count()).toBe(1);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const row = await db.readModelOutbox.findFirst();
|
||||||
|
return {
|
||||||
|
delivered: row?.deliveredAt instanceof Date,
|
||||||
|
attempts: row?.attempts ?? null,
|
||||||
|
locked: row?.lockedAt instanceof Date,
|
||||||
|
lastError: row?.lastError ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ timeout: 5_000, interval: 50 }
|
||||||
|
)
|
||||||
|
.toEqual({ delivered: true, attempts: 1, locked: false, lastError: null });
|
||||||
|
const readModelRedisRevisionKey = `sammo:${profileName}:read-model:revision`;
|
||||||
|
await expect
|
||||||
|
.poll(() => redis!.client.get(readModelRedisRevisionKey), { timeout: 5_000, interval: 50 })
|
||||||
|
.toBe('1');
|
||||||
|
expect(
|
||||||
|
await db.readModelRevision.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ domain: 'reserved.general', entityId: generalId },
|
||||||
|
{ domain: 'dashboard.global', entityId: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { domain: true, entityId: true, revision: true },
|
||||||
|
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
{ domain: 'dashboard.global', entityId: 0, revision: 1n },
|
||||||
|
{ domain: 'reserved.general', entityId: generalId, revision: 1n },
|
||||||
|
]);
|
||||||
|
expect(await db.readModelOutbox.findMany({ select: { payload: true } })).toEqual([
|
||||||
|
{
|
||||||
|
payload: {
|
||||||
|
version: 1,
|
||||||
|
changes: [
|
||||||
|
['dashboard.global', 0, '1'],
|
||||||
|
['reserved.general', generalId, '1'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const databaseAfter = await readReservedMutationState();
|
||||||
|
expect(databaseAfter.generals).toEqual(databaseBefore.generals);
|
||||||
|
expect(databaseAfter.generalTurns).toEqual(
|
||||||
|
Array.from({ length: 30 }, (_, turnIdx) => ({
|
||||||
|
generalId,
|
||||||
|
turnIdx,
|
||||||
|
actionCode: '휴식',
|
||||||
|
arg: {},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
expect(databaseAfter.generalTurnRevisions).toEqual([
|
||||||
|
{ generalId, revision: 1, leaseOwner: null, leaseExpiresAt: null },
|
||||||
|
]);
|
||||||
|
expect(databaseAfter.nationTurns).toEqual(databaseBefore.nationTurns);
|
||||||
|
expect(databaseAfter.nationTurnRevisions).toEqual(databaseBefore.nationTurnRevisions);
|
||||||
|
expect(databaseAfter.messages).toEqual(databaseBefore.messages);
|
||||||
|
expect(databaseAfter.logs).toEqual(databaseBefore.logs);
|
||||||
|
expect(databaseAfter.engineInputEvents).toEqual(databaseBefore.engineInputEvents);
|
||||||
|
expect(databaseAfter.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount);
|
||||||
|
expect(databaseAfter.eventCount).toBe(databaseBefore.eventCount);
|
||||||
|
expect(databaseAfter.auctionCount).toBe(databaseBefore.auctionCount);
|
||||||
|
expect(databaseAfter.auctionBidCount).toBe(databaseBefore.auctionBidCount);
|
||||||
|
expectSingleActorActivity(databaseAfter.generalAccessLogs);
|
||||||
|
const allowedTablesAfter = await readSuccessAllowedTableState();
|
||||||
|
expect(allowedTablesBefore.readModelOutbox).toEqual([]);
|
||||||
|
expect(allowedTablesAfter.generalTurns.filter((row) => row.generalId !== generalId)).toEqual(
|
||||||
|
allowedTablesBefore.generalTurns.filter((row) => row.generalId !== generalId)
|
||||||
|
);
|
||||||
|
const committedGeneralTurns = allowedTablesAfter.generalTurns.filter((row) => row.generalId === generalId);
|
||||||
|
expect(
|
||||||
|
committedGeneralTurns.map(({ generalId: rowGeneralId, turnIdx, actionCode, arg }) => ({
|
||||||
|
generalId: rowGeneralId,
|
||||||
|
turnIdx,
|
||||||
|
actionCode,
|
||||||
|
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(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId !== generalId)).toEqual(
|
||||||
|
allowedTablesBefore.generalTurnRevisions.filter((row) => row.generalId !== generalId)
|
||||||
|
);
|
||||||
|
expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId === generalId)).toEqual([
|
||||||
|
{
|
||||||
|
generalId,
|
||||||
|
revision: 1,
|
||||||
|
leaseOwner: null,
|
||||||
|
leaseExpiresAt: null,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== generalId)).toEqual(
|
||||||
|
allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== generalId)
|
||||||
|
);
|
||||||
|
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([
|
||||||
|
{
|
||||||
|
id: expect.any(Number),
|
||||||
|
generalId,
|
||||||
|
userId,
|
||||||
|
lastRefresh: null,
|
||||||
|
lastActionAt: expect.any(Date),
|
||||||
|
refresh: 0,
|
||||||
|
refreshTotal: 0,
|
||||||
|
refreshScore: 0,
|
||||||
|
refreshScoreTotal: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const expectedReadModelKeys = new Set([`dashboard.global:0`, `reserved.general:${generalId}`]);
|
||||||
|
expect(
|
||||||
|
allowedTablesAfter.readModelRevisions.filter(
|
||||||
|
({ domain, entityId }) => !expectedReadModelKeys.has(`${domain}:${entityId}`)
|
||||||
|
)
|
||||||
|
).toEqual(
|
||||||
|
allowedTablesBefore.readModelRevisions.filter(
|
||||||
|
({ domain, entityId }) => !expectedReadModelKeys.has(`${domain}:${entityId}`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
allowedTablesAfter.readModelRevisions.filter(({ domain, entityId }) =>
|
||||||
|
expectedReadModelKeys.has(`${domain}:${entityId}`)
|
||||||
|
)
|
||||||
|
).toEqual([
|
||||||
|
{ domain: 'dashboard.global', entityId: 0, revision: 1n, updatedAt: expect.any(Date) },
|
||||||
|
{ domain: 'reserved.general', entityId: generalId, revision: 1n, updatedAt: expect.any(Date) },
|
||||||
|
]);
|
||||||
|
expect(allowedTablesAfter.readModelOutbox).toEqual([
|
||||||
|
{
|
||||||
|
id: expect.anything(),
|
||||||
|
payload: {
|
||||||
|
version: 1,
|
||||||
|
changes: [
|
||||||
|
['dashboard.global', 0, '1'],
|
||||||
|
['reserved.general', generalId, '1'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attempts: 1,
|
||||||
|
availableAt: expect.any(Date),
|
||||||
|
lockedAt: null,
|
||||||
|
lockOwner: null,
|
||||||
|
deliveredAt: expect.any(Date),
|
||||||
|
lastError: null,
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const deliveredOutbox = allowedTablesAfter.readModelOutbox[0];
|
||||||
|
if (!deliveredOutbox?.deliveredAt) throw new Error('read-model outbox was not delivered');
|
||||||
|
expect(typeof deliveredOutbox.id).toBe('bigint');
|
||||||
|
expect(deliveredOutbox.id).toBeGreaterThan(0n);
|
||||||
|
expect(deliveredOutbox.deliveredAt.getTime()).toBeGreaterThanOrEqual(deliveredOutbox.createdAt.getTime());
|
||||||
|
expect(
|
||||||
|
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [
|
||||||
|
'general_turn',
|
||||||
|
'general_turn_revision',
|
||||||
|
'general_access_log',
|
||||||
|
'read_model_revision',
|
||||||
|
'read_model_outbox',
|
||||||
|
])
|
||||||
|
).toEqual(
|
||||||
|
withoutDurableTables(durableBefore, [
|
||||||
|
'general_turn',
|
||||||
|
'general_turn_revision',
|
||||||
|
'general_access_log',
|
||||||
|
'read_model_revision',
|
||||||
|
'read_model_outbox',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(redisBefore.some(([key]) => key === readModelRedisRevisionKey)).toBe(false);
|
||||||
|
const expectedRedisAfter: Array<[string, string | null]> = [...redisBefore, [readModelRedisRevisionKey, '1']];
|
||||||
|
expectedRedisAfter.sort(([left], [right]) => left.localeCompare(right));
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(expectedRedisAfter);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it('accepts the minimum officer level into its own nation queue partition over HTTP', async () => {
|
||||||
|
const idempotencyKey = `${mutationRequestPrefix}nation-minimum-officer-success`;
|
||||||
|
const accessToken = await createAccessToken('matrix-nation-minimum-officer-success', {}, sameNationUserId);
|
||||||
|
const databaseBefore = await readReservedMutationState();
|
||||||
|
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const allowedTablesBefore = await readSuccessAllowedTableState();
|
||||||
|
const redisBefore = await readRealtimeRedisState();
|
||||||
|
|
||||||
|
const result = await requestReservedNation(accessToken, idempotencyKey, sameNationGeneralId);
|
||||||
|
|
||||||
|
expect(result.response.status).toBe(200);
|
||||||
|
expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
|
||||||
|
const expectedTurns = Array.from({ length: 12 }, (_, turnIdx) => ({
|
||||||
|
nationId: ownerNationId,
|
||||||
|
officerLevel: 5,
|
||||||
|
turnIdx,
|
||||||
|
actionCode: '휴식',
|
||||||
|
arg: {},
|
||||||
|
}));
|
||||||
|
expect(
|
||||||
|
await db.nationTurn.findMany({
|
||||||
|
where: { nationId: ownerNationId, officerLevel: 5 },
|
||||||
|
select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true },
|
||||||
|
orderBy: { turnIdx: 'asc' },
|
||||||
|
})
|
||||||
|
).toEqual(expectedTurns);
|
||||||
|
expect(
|
||||||
|
await db.nationTurnRevision.findUnique({
|
||||||
|
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 5 } },
|
||||||
|
})
|
||||||
|
).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null });
|
||||||
|
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||||
|
actorUserId: sameNationUserId,
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
});
|
||||||
|
|
||||||
|
const committed = await readReservedMutationState();
|
||||||
|
expect(committed.generals).toEqual(databaseBefore.generals);
|
||||||
|
expect(committed.generalTurns).toEqual(databaseBefore.generalTurns);
|
||||||
|
expect(committed.generalTurnRevisions).toEqual(databaseBefore.generalTurnRevisions);
|
||||||
|
expect(committed.nationTurns).toEqual(expectedTurns);
|
||||||
|
expect(committed.nationTurnRevisions).toEqual([
|
||||||
|
{
|
||||||
|
nationId: ownerNationId,
|
||||||
|
officerLevel: 5,
|
||||||
|
revision: 1,
|
||||||
|
leaseOwner: null,
|
||||||
|
leaseExpiresAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(committed.generalAccessLogs).toEqual([
|
||||||
|
{
|
||||||
|
generalId: sameNationGeneralId,
|
||||||
|
userId: sameNationUserId,
|
||||||
|
lastRefresh: null,
|
||||||
|
refresh: 0,
|
||||||
|
refreshTotal: 0,
|
||||||
|
refreshScore: 0,
|
||||||
|
refreshScoreTotal: 0,
|
||||||
|
lastActionAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(committed.readModelRevisions).toEqual(databaseBefore.readModelRevisions);
|
||||||
|
expect(committed.readModelOutbox).toEqual(databaseBefore.readModelOutbox);
|
||||||
|
expect(committed.messages).toEqual(databaseBefore.messages);
|
||||||
|
expect(committed.logs).toEqual(databaseBefore.logs);
|
||||||
|
expect(committed.engineInputEvents).toEqual(databaseBefore.engineInputEvents);
|
||||||
|
expect(committed.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount);
|
||||||
|
expect(committed.eventCount).toBe(databaseBefore.eventCount);
|
||||||
|
expect(committed.auctionCount).toBe(databaseBefore.auctionCount);
|
||||||
|
expect(committed.auctionBidCount).toBe(databaseBefore.auctionBidCount);
|
||||||
|
|
||||||
|
const allowedTablesAfter = await readSuccessAllowedTableState();
|
||||||
|
expect(
|
||||||
|
allowedTablesAfter.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 5)
|
||||||
|
).toEqual(
|
||||||
|
allowedTablesBefore.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 5)
|
||||||
|
);
|
||||||
|
const committedNationTurns = allowedTablesAfter.nationTurns.filter(
|
||||||
|
(row) => row.nationId === ownerNationId && row.officerLevel === 5
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
committedNationTurns.map(({ nationId, officerLevel, turnIdx, actionCode, arg }) => ({
|
||||||
|
nationId,
|
||||||
|
officerLevel,
|
||||||
|
turnIdx,
|
||||||
|
actionCode,
|
||||||
|
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(
|
||||||
|
allowedTablesAfter.nationTurnRevisions.filter(
|
||||||
|
(row) => row.nationId !== ownerNationId || row.officerLevel !== 5
|
||||||
|
)
|
||||||
|
).toEqual(
|
||||||
|
allowedTablesBefore.nationTurnRevisions.filter(
|
||||||
|
(row) => row.nationId !== ownerNationId || row.officerLevel !== 5
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
allowedTablesAfter.nationTurnRevisions.filter(
|
||||||
|
(row) => row.nationId === ownerNationId && row.officerLevel === 5
|
||||||
|
)
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
nationId: ownerNationId,
|
||||||
|
officerLevel: 5,
|
||||||
|
revision: 1,
|
||||||
|
leaseOwner: null,
|
||||||
|
leaseExpiresAt: null,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== sameNationGeneralId)).toEqual(
|
||||||
|
allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== sameNationGeneralId)
|
||||||
|
);
|
||||||
|
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === sameNationGeneralId)).toEqual([
|
||||||
|
{
|
||||||
|
id: expect.any(Number),
|
||||||
|
generalId: sameNationGeneralId,
|
||||||
|
userId: sameNationUserId,
|
||||||
|
lastRefresh: null,
|
||||||
|
lastActionAt: expect.any(Date),
|
||||||
|
refresh: 0,
|
||||||
|
refreshTotal: 0,
|
||||||
|
refreshScore: 0,
|
||||||
|
refreshScoreTotal: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [
|
||||||
|
'nation_turn',
|
||||||
|
'nation_turn_revision',
|
||||||
|
'general_access_log',
|
||||||
|
])
|
||||||
|
).toEqual(withoutDurableTables(durableBefore, ['nation_turn', 'nation_turn_revision', 'general_access_log']));
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it('commits an owned officer nation reservation and rejects duplicate idempotency replay without a second queue mutation', async () => {
|
||||||
|
const idempotencyKey = `${mutationRequestPrefix}nation-success`;
|
||||||
|
const accessToken = await createAccessToken('matrix-nation-success', {});
|
||||||
|
const databaseBefore = await readReservedMutationState();
|
||||||
|
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const allowedTablesBefore = await readSuccessAllowedTableState();
|
||||||
|
const redisBefore = await readRealtimeRedisState();
|
||||||
|
|
||||||
|
const first = await requestReservedNation(accessToken, idempotencyKey, generalId);
|
||||||
|
expect(first.response.status).toBe(200);
|
||||||
|
expect(first.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
|
||||||
|
expect(
|
||||||
|
await db.nationTurn.findMany({
|
||||||
|
where: { nationId: ownerNationId, officerLevel: 12 },
|
||||||
|
select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true },
|
||||||
|
orderBy: { turnIdx: 'asc' },
|
||||||
|
})
|
||||||
|
).toEqual(
|
||||||
|
Array.from({ length: 12 }, (_, turnIdx) => ({
|
||||||
|
nationId: ownerNationId,
|
||||||
|
officerLevel: 12,
|
||||||
|
turnIdx,
|
||||||
|
actionCode: '휴식',
|
||||||
|
arg: {},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await db.nationTurnRevision.findUnique({
|
||||||
|
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
|
||||||
|
})
|
||||||
|
).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null });
|
||||||
|
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||||
|
actorUserId: userId,
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
});
|
||||||
|
|
||||||
|
const committed = await readReservedMutationState();
|
||||||
|
expect(committed.generals).toEqual(databaseBefore.generals);
|
||||||
|
expect(committed.generalTurns).toEqual(databaseBefore.generalTurns);
|
||||||
|
expect(committed.generalTurnRevisions).toEqual(databaseBefore.generalTurnRevisions);
|
||||||
|
expect(committed.nationTurns).toEqual(
|
||||||
|
Array.from({ length: 12 }, (_, turnIdx) => ({
|
||||||
|
nationId: ownerNationId,
|
||||||
|
officerLevel: 12,
|
||||||
|
turnIdx,
|
||||||
|
actionCode: '휴식',
|
||||||
|
arg: {},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
expect(committed.nationTurnRevisions).toEqual([
|
||||||
|
{
|
||||||
|
nationId: ownerNationId,
|
||||||
|
officerLevel: 12,
|
||||||
|
revision: 1,
|
||||||
|
leaseOwner: null,
|
||||||
|
leaseExpiresAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(committed.readModelRevisions).toEqual(databaseBefore.readModelRevisions);
|
||||||
|
expect(committed.readModelOutbox).toEqual(databaseBefore.readModelOutbox);
|
||||||
|
expect(committed.messages).toEqual(databaseBefore.messages);
|
||||||
|
expect(committed.logs).toEqual(databaseBefore.logs);
|
||||||
|
expect(committed.engineInputEvents).toEqual(databaseBefore.engineInputEvents);
|
||||||
|
expect(committed.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount);
|
||||||
|
expect(committed.eventCount).toBe(databaseBefore.eventCount);
|
||||||
|
expect(committed.auctionCount).toBe(databaseBefore.auctionCount);
|
||||||
|
expect(committed.auctionBidCount).toBe(databaseBefore.auctionBidCount);
|
||||||
|
expectSingleActorActivity(committed.generalAccessLogs);
|
||||||
|
const allowedTablesAfter = await readSuccessAllowedTableState();
|
||||||
|
expect(
|
||||||
|
allowedTablesAfter.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 12)
|
||||||
|
).toEqual(
|
||||||
|
allowedTablesBefore.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 12)
|
||||||
|
);
|
||||||
|
const committedNationTurns = allowedTablesAfter.nationTurns.filter(
|
||||||
|
(row) => row.nationId === ownerNationId && row.officerLevel === 12
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
committedNationTurns.map(({ nationId, officerLevel, turnIdx, actionCode, arg }) => ({
|
||||||
|
nationId,
|
||||||
|
officerLevel,
|
||||||
|
turnIdx,
|
||||||
|
actionCode,
|
||||||
|
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(
|
||||||
|
allowedTablesAfter.nationTurnRevisions.filter(
|
||||||
|
(row) => row.nationId !== ownerNationId || row.officerLevel !== 12
|
||||||
|
)
|
||||||
|
).toEqual(
|
||||||
|
allowedTablesBefore.nationTurnRevisions.filter(
|
||||||
|
(row) => row.nationId !== ownerNationId || row.officerLevel !== 12
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
allowedTablesAfter.nationTurnRevisions.filter(
|
||||||
|
(row) => row.nationId === ownerNationId && row.officerLevel === 12
|
||||||
|
)
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
nationId: ownerNationId,
|
||||||
|
officerLevel: 12,
|
||||||
|
revision: 1,
|
||||||
|
leaseOwner: null,
|
||||||
|
leaseExpiresAt: null,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== generalId)).toEqual(
|
||||||
|
allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== generalId)
|
||||||
|
);
|
||||||
|
expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([
|
||||||
|
{
|
||||||
|
id: expect.any(Number),
|
||||||
|
generalId,
|
||||||
|
userId,
|
||||||
|
lastRefresh: null,
|
||||||
|
lastActionAt: expect.any(Date),
|
||||||
|
refresh: 0,
|
||||||
|
refreshTotal: 0,
|
||||||
|
refreshScore: 0,
|
||||||
|
refreshScoreTotal: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [
|
||||||
|
'nation_turn',
|
||||||
|
'nation_turn_revision',
|
||||||
|
'general_access_log',
|
||||||
|
])
|
||||||
|
).toEqual(withoutDurableTables(durableBefore, ['nation_turn', 'nation_turn_revision', 'general_access_log']));
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(redisBefore);
|
||||||
|
const replayDurableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
|
||||||
|
const replayRedisBefore = await readRealtimeRedisState();
|
||||||
|
const replayJournalBefore = await db.inputEvent.findUniqueOrThrow({
|
||||||
|
where: { requestId: `${idempotencyKey}:turns.reserved.setNation` },
|
||||||
|
});
|
||||||
|
const replay = await requestReservedNation(accessToken, idempotencyKey, generalId);
|
||||||
|
expect(replay.response.status).toBe(409);
|
||||||
|
expect(replay.body).toMatchObject({ error: { data: { code: 'CONFLICT' } } });
|
||||||
|
expect(await readReservedMutationState()).toEqual(committed);
|
||||||
|
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(replayDurableBefore);
|
||||||
|
expect(await readRealtimeRedisState()).toEqual(replayRedisBefore);
|
||||||
|
expect(
|
||||||
|
await db.inputEvent.findUniqueOrThrow({
|
||||||
|
where: { requestId: `${idempotencyKey}:turns.reserved.setNation` },
|
||||||
|
})
|
||||||
|
).toEqual(replayJournalBefore);
|
||||||
|
expect(await db.inputEvent.count({ where: { requestId: `${idempotencyKey}:turns.reserved.setNation` } })).toBe(
|
||||||
|
1
|
||||||
|
);
|
||||||
|
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
|
||||||
|
actorUserId: userId,
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delivers a non-UTC-session Web Push outbox row once with its original instant', async () => {
|
||||||
|
const eventId = `security-http-web-push-${process.pid}`;
|
||||||
|
const beforeInsert = Date.now();
|
||||||
|
await db.$transaction(async (transaction) => {
|
||||||
|
await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`;
|
||||||
|
await expect(
|
||||||
|
enqueueWebPushOutboxEvents(transaction, [
|
||||||
|
{
|
||||||
|
eventId,
|
||||||
|
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||||
|
userIds: [userId],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).resolves.toBe(1);
|
||||||
|
});
|
||||||
|
const afterInsert = Date.now();
|
||||||
|
|
||||||
|
await expect.poll(() => receivedGatewayWebPushEvents.length, { timeout: 6_000, interval: 50 }).toBe(1);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const row = await db.webPushOutbox.findUniqueOrThrow({ where: { eventId } });
|
||||||
|
return {
|
||||||
|
attempts: row.attempts,
|
||||||
|
locked: row.lockedAt instanceof Date,
|
||||||
|
lockOwner: row.lockOwner,
|
||||||
|
delivered: row.deliveredAt instanceof Date,
|
||||||
|
lastError: row.lastError,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ timeout: 6_000, interval: 50 }
|
||||||
|
)
|
||||||
|
.toEqual({ attempts: 1, locked: false, lockOwner: null, delivered: true, lastError: null });
|
||||||
|
const delivered = await db.webPushOutbox.findUniqueOrThrow({ where: { eventId } });
|
||||||
|
expect(delivered).toMatchObject({
|
||||||
|
attempts: 1,
|
||||||
|
lockedAt: null,
|
||||||
|
lockOwner: null,
|
||||||
|
deliveredAt: expect.any(Date),
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
const [storedInstant] = await db.$queryRaw<Array<{ createdMs: number }>>`
|
||||||
|
SELECT (EXTRACT(EPOCH FROM "created_at") * 1000)::double precision AS "createdMs"
|
||||||
|
FROM "web_push_outbox"
|
||||||
|
WHERE "event_id" = ${eventId}
|
||||||
|
`;
|
||||||
|
if (!storedInstant) throw new Error('web push outbox instant was not persisted');
|
||||||
|
|
||||||
|
const [received] = receivedGatewayWebPushEvents;
|
||||||
|
expect(received?.internalToken).toMatch(/^[a-f0-9]{64}$/u);
|
||||||
|
expect(received?.body).toEqual({
|
||||||
|
version: 1,
|
||||||
|
eventId: `game:${profileName}:${eventId}`,
|
||||||
|
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||||
|
profileName,
|
||||||
|
userIds: [userId],
|
||||||
|
occurredAt: expect.any(String),
|
||||||
|
});
|
||||||
|
const occurredAt = Date.parse((received?.body as { occurredAt: string }).occurredAt);
|
||||||
|
expect(occurredAt).toBeGreaterThanOrEqual(beforeInsert - 1_000);
|
||||||
|
expect(occurredAt).toBeLessThanOrEqual(afterInsert + 1_000);
|
||||||
|
expect(Math.abs(occurredAt - storedInstant.createdMs)).toBeLessThanOrEqual(1);
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
it('keeps Web Push due, lease, and prune boundaries in UTC wall time under a KST database session', async () => {
|
||||||
|
const eventIds = {
|
||||||
|
future: `security-http-web-push-future-${process.pid}`,
|
||||||
|
recentLock: `security-http-web-push-recent-lock-${process.pid}`,
|
||||||
|
staleLock: `security-http-web-push-stale-lock-${process.pid}`,
|
||||||
|
retainedDelivery: `security-http-web-push-retained-delivery-${process.pid}`,
|
||||||
|
prunedDelivery: `security-http-web-push-pruned-delivery-${process.pid}`,
|
||||||
|
} as const;
|
||||||
|
const [databaseSession] = await db.$queryRaw<Array<{ timeZone: string }>>`
|
||||||
|
SELECT current_setting('TIMEZONE') AS "timeZone"
|
||||||
|
`;
|
||||||
|
expect(databaseSession?.timeZone).toBe('Asia/Seoul');
|
||||||
|
|
||||||
|
await db.$transaction(async (transaction) => {
|
||||||
|
await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`;
|
||||||
|
const [transactionSession] = await transaction.$queryRaw<Array<{ timeZone: string }>>`
|
||||||
|
SELECT current_setting('TIMEZONE') AS "timeZone"
|
||||||
|
`;
|
||||||
|
expect(transactionSession?.timeZone).toBe('Asia/Seoul');
|
||||||
|
await transaction.$executeRaw`
|
||||||
|
INSERT INTO "web_push_outbox" (
|
||||||
|
"event_id",
|
||||||
|
"event_type",
|
||||||
|
"user_ids",
|
||||||
|
"attempts",
|
||||||
|
"available_at",
|
||||||
|
"locked_at",
|
||||||
|
"lock_owner",
|
||||||
|
"delivered_at",
|
||||||
|
"created_at"
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
${eventIds.future},
|
||||||
|
'PRIVATE_MESSAGE_RECEIVED',
|
||||||
|
ARRAY[${userId}]::text[],
|
||||||
|
0,
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '2 hours',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
${eventIds.recentLock},
|
||||||
|
'PRIVATE_MESSAGE_RECEIVED',
|
||||||
|
ARRAY[${userId}]::text[],
|
||||||
|
4,
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 minute',
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 second',
|
||||||
|
'previous-owner',
|
||||||
|
NULL,
|
||||||
|
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
${eventIds.staleLock},
|
||||||
|
'PRIVATE_MESSAGE_RECEIVED',
|
||||||
|
ARRAY[${userId}]::text[],
|
||||||
|
2,
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 minute',
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '31 seconds',
|
||||||
|
'previous-owner',
|
||||||
|
NULL,
|
||||||
|
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
${eventIds.retainedDelivery},
|
||||||
|
'PRIVATE_MESSAGE_RECEIVED',
|
||||||
|
ARRAY[${userId}]::text[],
|
||||||
|
1,
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '20 hours',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '20 hours',
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '21 hours'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
${eventIds.prunedDelivery},
|
||||||
|
'PRIVATE_MESSAGE_RECEIVED',
|
||||||
|
ARRAY[${userId}]::text[],
|
||||||
|
1,
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '25 hours',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '25 hours',
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '26 hours'
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const boundaryWorker = new WebPushOutboxWorker(db, process.env.GATEWAY_INTERNAL_API_URL!, secret, profileName, {
|
||||||
|
intervalMs: 60_000,
|
||||||
|
});
|
||||||
|
boundaryWorker.start();
|
||||||
|
await boundaryWorker.stop();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const staleLock = await db.webPushOutbox.findUnique({
|
||||||
|
where: { eventId: eventIds.staleLock },
|
||||||
|
});
|
||||||
|
return staleLock
|
||||||
|
? {
|
||||||
|
attempts: staleLock.attempts,
|
||||||
|
lockedAt: staleLock.lockedAt,
|
||||||
|
lockOwner: staleLock.lockOwner,
|
||||||
|
delivered: staleLock.deliveredAt instanceof Date,
|
||||||
|
lastError: staleLock.lastError,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
{ timeout: 6_000, interval: 50 }
|
||||||
|
)
|
||||||
|
.toEqual({ attempts: 3, lockedAt: null, lockOwner: null, delivered: true, lastError: null });
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
db.webPushOutbox.count({
|
||||||
|
where: { eventId: eventIds.prunedDelivery },
|
||||||
|
}),
|
||||||
|
{ timeout: 6_000, interval: 50 }
|
||||||
|
)
|
||||||
|
.toBe(0);
|
||||||
|
|
||||||
|
const remaining = await db.webPushOutbox.findMany({
|
||||||
|
where: { eventId: { in: Object.values(eventIds) } },
|
||||||
|
orderBy: { eventId: 'asc' },
|
||||||
|
});
|
||||||
|
const byEventId = new Map(remaining.map((row) => [row.eventId, row]));
|
||||||
|
expect(byEventId.get(eventIds.future)).toMatchObject({
|
||||||
|
attempts: 0,
|
||||||
|
lockedAt: null,
|
||||||
|
lockOwner: null,
|
||||||
|
deliveredAt: null,
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
expect(byEventId.get(eventIds.recentLock)).toMatchObject({
|
||||||
|
attempts: 4,
|
||||||
|
lockedAt: expect.any(Date),
|
||||||
|
lockOwner: 'previous-owner',
|
||||||
|
deliveredAt: null,
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
expect(byEventId.get(eventIds.retainedDelivery)).toMatchObject({
|
||||||
|
attempts: 1,
|
||||||
|
lockedAt: null,
|
||||||
|
lockOwner: null,
|
||||||
|
deliveredAt: expect.any(Date),
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
expect(byEventId.has(eventIds.prunedDelivery)).toBe(false);
|
||||||
|
expect(receivedGatewayWebPushEvents).toHaveLength(1);
|
||||||
|
expect(receivedGatewayWebPushEvents[0]?.body).toMatchObject({
|
||||||
|
eventId: `game:${profileName}:${eventIds.staleLock}`,
|
||||||
|
});
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// Flush invalidates every token issued before the user watermark. Keep it
|
||||||
|
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
||||||
|
// by the transport authorization matrix above.
|
||||||
it('invalidates an existing access token after a gateway flush event', async () => {
|
it('invalidates an existing access token after a gateway flush event', async () => {
|
||||||
const accessToken = await createAccessToken('flush', {});
|
const accessToken = await createAccessToken('flush', {});
|
||||||
expect((await requestTrpc('general.me', { accessToken })).response.status).toBe(200);
|
expect((await requestTrpc('general.me', { accessToken })).response.status).toBe(200);
|
||||||
|
|||||||
@@ -241,26 +241,40 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const;
|
||||||
const attempts = await Promise.allSettled([
|
const attempts = await Promise.allSettled([
|
||||||
appRouter.createCaller(buildContext('select-pool-create-a')).join.selectPoolGeneral({
|
appRouter.createCaller(buildContext(createRequestIds[0])).join.selectPoolGeneral({
|
||||||
uniqueName: firstReservation.candidates[0]!.uniqueName,
|
uniqueName: firstReservation.candidates[0]!.uniqueName,
|
||||||
personality: 'che_안전',
|
personality: 'che_안전',
|
||||||
}),
|
}),
|
||||||
appRouter.createCaller(buildContext('select-pool-create-b')).join.selectPoolGeneral({
|
appRouter.createCaller(buildContext(createRequestIds[1])).join.selectPoolGeneral({
|
||||||
uniqueName: firstReservation.candidates[1]!.uniqueName,
|
uniqueName: firstReservation.candidates[1]!.uniqueName,
|
||||||
personality: 'che_유지',
|
personality: 'che_유지',
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(1);
|
expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(1);
|
||||||
expect(attempts.filter((attempt) => attempt.status === 'rejected')).toHaveLength(1);
|
expect(attempts.filter((attempt) => attempt.status === 'rejected')).toHaveLength(1);
|
||||||
|
const successfulAttemptIndex = attempts.findIndex((attempt) => attempt.status === 'fulfilled');
|
||||||
|
if (successfulAttemptIndex < 0) {
|
||||||
|
throw new Error('one concurrent selection request must succeed');
|
||||||
|
}
|
||||||
|
const successfulRequestId = createRequestIds[successfulAttemptIndex];
|
||||||
|
if (!successfulRequestId) {
|
||||||
|
throw new Error('successful selection request must have a request ID');
|
||||||
|
}
|
||||||
|
|
||||||
const initial = await db.general.findFirstOrThrow({ where: { userId } });
|
const initial = await db.general.findFirstOrThrow({ where: { userId } });
|
||||||
const initialRuntime = runtime!.world.getGeneralById(initial.id);
|
const initialRuntime = runtime!.world.getGeneralById(initial.id);
|
||||||
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } });
|
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } });
|
||||||
expect(initial).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
|
expect(initial).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
|
||||||
const acceptedEvent = await db.inputEvent.findFirstOrThrow({
|
const acceptedEvent = await db.inputEvent.findUniqueOrThrow({
|
||||||
where: { actorUserId: userId, eventType: 'selectPoolCreate', status: 'SUCCEEDED' },
|
where: { requestId: `${successfulRequestId}:join.selectPoolGeneral` },
|
||||||
orderBy: { sequence: 'desc' },
|
});
|
||||||
|
expect(acceptedEvent).toMatchObject({
|
||||||
|
actorUserId: userId,
|
||||||
|
eventType: 'selectPoolCreate',
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
|
||||||
});
|
});
|
||||||
if (!initialAccess.lastRefresh) {
|
if (!initialAccess.lastRefresh) {
|
||||||
throw new Error('selected general must have an initial access timestamp');
|
throw new Error('selected general must have an initial access timestamp');
|
||||||
@@ -479,7 +493,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => {
|
it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => {
|
||||||
const logicalNowMs = runtime!.world.getGameNow(new Date()).getTime();
|
const logicalNow = runtime!.world.getGameNow(new Date());
|
||||||
|
const logicalNowMs = logicalNow.getTime();
|
||||||
|
const logicalNowTick = runtime!.world.dateToGameTick(logicalNow);
|
||||||
const reservation = await appRouter
|
const reservation = await appRouter
|
||||||
.createCaller(buildContext('select-pool-other-reserve', otherAuth))
|
.createCaller(buildContext('select-pool-other-reserve', otherAuth))
|
||||||
.join.getSelectionPool();
|
.join.getSelectionPool();
|
||||||
@@ -495,7 +511,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
|
|
||||||
await db.selectPoolEntry.update({
|
await db.selectPoolEntry.update({
|
||||||
where: { uniqueName: candidate.uniqueName },
|
where: { uniqueName: candidate.uniqueName },
|
||||||
data: { reservedUntil: new Date(logicalNowMs - 60_000) },
|
data: {
|
||||||
|
reservedUntil: new Date(logicalNowMs - 60_000),
|
||||||
|
reservedUntilTick: BigInt(logicalNowTick - 1),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({
|
appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import type { GameApiContext } from '../src/context.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
const fixtureId = 9_984_241;
|
||||||
|
const fallbackPollId = fixtureId + 1;
|
||||||
|
const fixtureUserId = 'vote-comment-timestamp-user';
|
||||||
|
const routeText = '설문 댓글 UTC writer 검증';
|
||||||
|
const fallbackText = '설문 댓글 UTC default 검증';
|
||||||
|
|
||||||
|
const auth: GameSessionTokenPayload = {
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:vote-comment-timestamp',
|
||||||
|
issuedAt: '2026-08-24T00:00:00.000Z',
|
||||||
|
expiresAt: '2027-08-24T00:00:00.000Z',
|
||||||
|
sessionId: 'vote-comment-timestamp-session',
|
||||||
|
user: {
|
||||||
|
id: fixtureUserId,
|
||||||
|
username: fixtureUserId,
|
||||||
|
displayName: fixtureUserId,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
integration('vote comment operational timestamp', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
const cleanup = async (): Promise<void> => {
|
||||||
|
await db.inputEvent.deleteMany({
|
||||||
|
where: { requestId: { startsWith: 'integration:vote-comment-timestamp' } },
|
||||||
|
});
|
||||||
|
await db.voteComment.deleteMany({ where: { voteId: { in: [fixtureId, fallbackPollId] } } });
|
||||||
|
await db.vote.deleteMany({ where: { voteId: { in: [fixtureId, fallbackPollId] } } });
|
||||||
|
await db.votePoll.deleteMany({ where: { id: { in: [fixtureId, fallbackPollId] } } });
|
||||||
|
await db.general.deleteMany({ where: { id: fixtureId } });
|
||||||
|
await db.nation.deleteMany({ where: { id: fixtureId } });
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
await cleanup();
|
||||||
|
await db.nation.create({ data: { id: fixtureId, name: '시각국', color: '#123456' } });
|
||||||
|
await db.general.create({
|
||||||
|
data: {
|
||||||
|
id: fixtureId,
|
||||||
|
userId: fixtureUserId,
|
||||||
|
name: '시각장수',
|
||||||
|
nationId: fixtureId,
|
||||||
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.votePoll.create({
|
||||||
|
data: {
|
||||||
|
id: fixtureId,
|
||||||
|
title: '시각 설문',
|
||||||
|
options: ['찬성', '반대'],
|
||||||
|
multipleOptions: 1,
|
||||||
|
revealMode: 'after_vote',
|
||||||
|
openerGeneralId: fixtureId,
|
||||||
|
openerName: '시각장수',
|
||||||
|
startAt: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
|
||||||
|
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
|
||||||
|
SELECT current_setting('TIMEZONE') AS "timeZone"
|
||||||
|
`;
|
||||||
|
expect(session?.timeZone).toBe('Asia/Seoul');
|
||||||
|
|
||||||
|
const routeWindowStart = Date.now();
|
||||||
|
const caller = appRouter.createCaller({
|
||||||
|
requestId: 'integration:vote-comment-timestamp',
|
||||||
|
db,
|
||||||
|
auth,
|
||||||
|
profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' },
|
||||||
|
turnDaemon: {},
|
||||||
|
} as unknown as GameApiContext);
|
||||||
|
await expect(caller.vote.addComment({ voteId: fixtureId, text: routeText })).resolves.toEqual({ ok: true });
|
||||||
|
const routeWindowEnd = Date.now();
|
||||||
|
|
||||||
|
const fallbackWindowStart = Date.now();
|
||||||
|
await db.$executeRaw`
|
||||||
|
INSERT INTO "vote_comment" (
|
||||||
|
"vote_id",
|
||||||
|
"general_id",
|
||||||
|
"nation_id",
|
||||||
|
"general_name",
|
||||||
|
"nation_name",
|
||||||
|
"text"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${fixtureId},
|
||||||
|
${fixtureId},
|
||||||
|
${fixtureId},
|
||||||
|
'시각장수',
|
||||||
|
'시각국',
|
||||||
|
${fallbackText}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const fallbackWindowEnd = Date.now();
|
||||||
|
|
||||||
|
const pollFallbackWindowStart = Date.now();
|
||||||
|
await db.$executeRaw`
|
||||||
|
INSERT INTO "vote_poll" (
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"body",
|
||||||
|
"options",
|
||||||
|
"multiple_options",
|
||||||
|
"reveal_mode",
|
||||||
|
"opener_general_id",
|
||||||
|
"opener_name",
|
||||||
|
"start_at"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${fallbackPollId},
|
||||||
|
'이전 writer 기본값 설문',
|
||||||
|
'',
|
||||||
|
'["찬성", "반대"]'::jsonb,
|
||||||
|
1,
|
||||||
|
'after_vote',
|
||||||
|
${fixtureId},
|
||||||
|
'시각장수',
|
||||||
|
${new Date('0200-01-01T00:00:00.000Z')}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const pollFallbackWindowEnd = Date.now();
|
||||||
|
|
||||||
|
const voteFallbackWindowStart = Date.now();
|
||||||
|
await db.$executeRaw`
|
||||||
|
INSERT INTO "vote" ("vote_id", "general_id", "nation_id", "selection")
|
||||||
|
VALUES (${fixtureId}, ${fixtureId}, ${fixtureId}, '[0]'::jsonb)
|
||||||
|
`;
|
||||||
|
const voteFallbackWindowEnd = Date.now();
|
||||||
|
|
||||||
|
const rows = await db.voteComment.findMany({
|
||||||
|
where: { voteId: fixtureId },
|
||||||
|
select: { text: true, createdAt: true },
|
||||||
|
});
|
||||||
|
const routeRow = rows.find((row) => row.text === routeText);
|
||||||
|
const fallbackRow = rows.find((row) => row.text === fallbackText);
|
||||||
|
expect(routeRow?.createdAt.getTime()).toBeGreaterThanOrEqual(routeWindowStart);
|
||||||
|
expect(routeRow?.createdAt.getTime()).toBeLessThanOrEqual(routeWindowEnd);
|
||||||
|
expect(fallbackRow?.createdAt.getTime()).toBeGreaterThanOrEqual(fallbackWindowStart);
|
||||||
|
expect(fallbackRow?.createdAt.getTime()).toBeLessThanOrEqual(fallbackWindowEnd);
|
||||||
|
|
||||||
|
const fallbackPoll = await db.votePoll.findUniqueOrThrow({ where: { id: fallbackPollId } });
|
||||||
|
expect(fallbackPoll.createdAt.getTime()).toBeGreaterThanOrEqual(pollFallbackWindowStart);
|
||||||
|
expect(fallbackPoll.createdAt.getTime()).toBeLessThanOrEqual(pollFallbackWindowEnd);
|
||||||
|
expect(fallbackPoll.updatedAt.getTime()).toBeGreaterThanOrEqual(pollFallbackWindowStart);
|
||||||
|
expect(fallbackPoll.updatedAt.getTime()).toBeLessThanOrEqual(pollFallbackWindowEnd);
|
||||||
|
|
||||||
|
const fallbackVote = await db.vote.findUniqueOrThrow({
|
||||||
|
where: { voteId_generalId: { voteId: fixtureId, generalId: fixtureId } },
|
||||||
|
});
|
||||||
|
expect(fallbackVote.createdAt.getTime()).toBeGreaterThanOrEqual(voteFallbackWindowStart);
|
||||||
|
expect(fallbackVote.createdAt.getTime()).toBeLessThanOrEqual(voteFallbackWindowEnd);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -130,6 +130,9 @@ const buildContext = (options: {
|
|||||||
if (text.includes('INSERT INTO vote_comment')) {
|
if (text.includes('INSERT INTO vote_comment')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
if (text.includes('UPDATE vote_poll')) {
|
||||||
|
return [{ id: 1 }];
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
const db = {
|
const db = {
|
||||||
@@ -290,6 +293,60 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
expect(insert?.values).not.toContain('관리자 표시명');
|
expect(insert?.values).not.toContain('관리자 표시명');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('binds current operational timestamps in every raw SQL vote writer', async () => {
|
||||||
|
const auth = buildAuth(['admin.survey.open']);
|
||||||
|
const fixture = buildContext({ auth });
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
const windowStart = Date.now();
|
||||||
|
|
||||||
|
await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
|
||||||
|
await expect(
|
||||||
|
caller.vote.createPoll({
|
||||||
|
title: '시각 설문',
|
||||||
|
options: ['찬성', '반대'],
|
||||||
|
revealMode: 'after_vote',
|
||||||
|
closePrevious: true,
|
||||||
|
})
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true });
|
||||||
|
await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
|
||||||
|
const windowEnd = Date.now();
|
||||||
|
|
||||||
|
const mutationQueries = fixture.queryRaw.mock.calls
|
||||||
|
.map(([query]) => query)
|
||||||
|
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query)));
|
||||||
|
const commentInsert = mutationQueries.find((query) => sqlText(query).includes('INSERT INTO vote_comment'));
|
||||||
|
const pollInsert = mutationQueries.find((query) => sqlText(query).includes('INSERT INTO vote_poll'));
|
||||||
|
const pollUpdates = mutationQueries.filter((query) => sqlText(query).includes('UPDATE vote_poll'));
|
||||||
|
const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
|
||||||
|
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
|
||||||
|
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id'));
|
||||||
|
const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => {
|
||||||
|
expect(query).toBeDefined();
|
||||||
|
const value = query?.values.at(index);
|
||||||
|
expect(value).toBeInstanceOf(Date);
|
||||||
|
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
|
||||||
|
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
|
||||||
|
return value as Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(sqlText(commentInsert!)).toContain('created_at');
|
||||||
|
expectCurrentDateAt(commentInsert, -1);
|
||||||
|
expect(sqlText(pollInsert!)).toContain('created_at');
|
||||||
|
expect(sqlText(pollInsert!)).toContain('updated_at');
|
||||||
|
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2);
|
||||||
|
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
|
||||||
|
expect(pollUpdatedAt).toBe(pollCreatedAt);
|
||||||
|
|
||||||
|
expect(pollUpdates).toHaveLength(3);
|
||||||
|
expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
|
||||||
|
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt);
|
||||||
|
expect(sqlText(editPollUpdate!)).toContain('updated_at');
|
||||||
|
expectCurrentDateAt(editPollUpdate, -2);
|
||||||
|
expect(sqlText(closePollUpdate!)).toContain('updated_at');
|
||||||
|
expectCurrentDateAt(closePollUpdate, -2);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports the current world develcost as the legacy five-times survey reward', async () => {
|
it('reports the current world develcost as the legacy five-times survey reward', async () => {
|
||||||
const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } });
|
const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } });
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ interface NpcSelectionTokenRow {
|
|||||||
nonce: number;
|
nonce: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000;
|
|
||||||
const VALID_SECONDS = 90;
|
const VALID_SECONDS = 90;
|
||||||
const PICK_MORE_SECONDS = 10;
|
const PICK_MORE_SECONDS = 10;
|
||||||
const KEEP_COUNT = 3;
|
const KEEP_COUNT = 3;
|
||||||
@@ -103,19 +102,11 @@ const fail = (code: NpcPossessionErrorCode, message: string): never => {
|
|||||||
|
|
||||||
const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTime() / 1000) * 1000);
|
const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTime() / 1000) * 1000);
|
||||||
|
|
||||||
const formatLegacySeedTime = (value: Date): string => {
|
|
||||||
const pad = (part: number): string => String(part).padStart(2, '0');
|
|
||||||
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
|
|
||||||
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
|
|
||||||
koreaTime.getUTCDate()
|
|
||||||
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildNpcSelectionTokenSeed = (
|
export const buildNpcSelectionTokenSeed = (
|
||||||
hiddenSeed: string | number,
|
hiddenSeed: string | number,
|
||||||
ownerIdentity: string | number,
|
ownerIdentity: string | number,
|
||||||
now: Date
|
acceptedGameTick: number
|
||||||
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, formatLegacySeedTime(now));
|
): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, acceptedGameTick);
|
||||||
|
|
||||||
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
const readHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||||
const meta = asRecord(worldState.meta);
|
const meta = asRecord(worldState.meta);
|
||||||
@@ -298,11 +289,15 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
refresh?: boolean;
|
refresh?: boolean;
|
||||||
keepIds?: number[];
|
keepIds?: number[];
|
||||||
now?: Date;
|
now?: Date;
|
||||||
|
acceptedGameTick: number;
|
||||||
selectionObserver?: NpcPossessionSelectionObserver;
|
selectionObserver?: NpcPossessionSelectionObserver;
|
||||||
}): Promise<NpcPossessionReservation> => {
|
}): Promise<NpcPossessionReservation> => {
|
||||||
const { db, worldState, userId } = options;
|
const { db, worldState, userId } = options;
|
||||||
requireNpcPossessionWorld(worldState);
|
requireNpcPossessionWorld(worldState);
|
||||||
const now = truncateToSeconds(options.now ?? new Date());
|
const now = truncateToSeconds(options.now ?? new Date());
|
||||||
|
if (!Number.isSafeInteger(options.acceptedGameTick)) {
|
||||||
|
fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 수락 tick이 올바르지 않습니다.');
|
||||||
|
}
|
||||||
await lockNpcPossession(db, userId);
|
await lockNpcPossession(db, userId);
|
||||||
|
|
||||||
if (await db.general.findFirst({ where: { userId }, select: { id: true } })) {
|
if (await db.general.findFirst({ where: { userId }, select: { id: true } })) {
|
||||||
@@ -402,7 +397,7 @@ export const reserveNpcPossessionCandidates = async (options: {
|
|||||||
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
|
generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId)))
|
||||||
);
|
);
|
||||||
const selectionRng = new LiteHashDRBG(
|
const selectionRng = new LiteHashDRBG(
|
||||||
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, now)
|
buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, options.acceptedGameTick)
|
||||||
);
|
);
|
||||||
const rng = options.selectionObserver?.onRandomDraw
|
const rng = options.selectionObserver?.onRandomDraw
|
||||||
? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw)
|
? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw)
|
||||||
|
|||||||
@@ -2499,12 +2499,14 @@ const insertVoteSelection = async (
|
|||||||
if (!ctx.commandDb) {
|
if (!ctx.commandDb) {
|
||||||
return 'missing';
|
return 'missing';
|
||||||
}
|
}
|
||||||
|
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)
|
INSERT INTO vote (vote_id, general_id, nation_id, selection, created_at)
|
||||||
SELECT poll.id,
|
SELECT poll.id,
|
||||||
${general.id},
|
${general.id},
|
||||||
${general.nationId},
|
${general.nationId},
|
||||||
CAST(${JSON.stringify(selection)} AS jsonb)
|
CAST(${JSON.stringify(selection)} AS jsonb),
|
||||||
|
${createdAt}
|
||||||
FROM vote_poll poll
|
FROM vote_poll poll
|
||||||
WHERE poll.id = ${command.voteId}
|
WHERE poll.id = ${command.voteId}
|
||||||
ON CONFLICT (vote_id, general_id) DO NOTHING
|
ON CONFLICT (vote_id, general_id) DO NOTHING
|
||||||
|
|||||||
@@ -286,4 +286,37 @@ integration('game cancellation transaction', () => {
|
|||||||
generalMode: 'DELETE',
|
generalMode: 'DELETE',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a native inheritance log inside the cancellation boundary under a KST session', async () => {
|
||||||
|
const text = '신규/복귀 생성으로 포인트 1500 지급';
|
||||||
|
await db.inheritanceLog.deleteMany({ where: { userId, text } });
|
||||||
|
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
|
||||||
|
SELECT current_setting('TIMEZONE') AS "timeZone"
|
||||||
|
`;
|
||||||
|
expect(session?.timeZone).toBe('Asia/Seoul');
|
||||||
|
|
||||||
|
const createdAfter = Date.now();
|
||||||
|
const log = await db.inheritanceLog.create({
|
||||||
|
data: { userId, serverId, year: 190, month: 7, text },
|
||||||
|
});
|
||||||
|
const createdBefore = Date.now();
|
||||||
|
expect(log.createdAt.getTime()).toBeGreaterThanOrEqual(createdAfter);
|
||||||
|
expect(log.createdAt.getTime()).toBeLessThanOrEqual(createdBefore);
|
||||||
|
|
||||||
|
const result = await cancelGame({
|
||||||
|
cancellationId: 'game-cancellation-kst-default-fixture',
|
||||||
|
databaseUrl: databaseUrl!,
|
||||||
|
cancelledBy: 'admin',
|
||||||
|
reason: 'KST 기본값 경계 검증',
|
||||||
|
historyMode: 'RETAIN_ABANDONED',
|
||||||
|
generalMode: 'RETAIN',
|
||||||
|
earnedPointRetentionPercent: 40,
|
||||||
|
cancelledAt: new Date(createdBefore + 1_000),
|
||||||
|
});
|
||||||
|
expect(result.settlements[userId]).toMatchObject({
|
||||||
|
earnedPoint: 1_790.005,
|
||||||
|
retainedEarnedPoint: 716,
|
||||||
|
finalPoint: 10_716,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -560,12 +560,6 @@ integration('immediate general action persistence', () => {
|
|||||||
generalId,
|
generalId,
|
||||||
text: expect.stringContaining('낙양'),
|
text: expect.stringContaining('낙양'),
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
|
||||||
scope: 'NATION',
|
|
||||||
category: 'HISTORY',
|
|
||||||
nationId: existingNationId + 1,
|
|
||||||
text: expect.stringContaining('통합장수'),
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
scope: 'SYSTEM',
|
scope: 'SYSTEM',
|
||||||
category: 'SUMMARY',
|
category: 'SUMMARY',
|
||||||
@@ -578,6 +572,17 @@ integration('immediate general action persistence', () => {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
// Ref creates this action logger while the actor is still unaffiliated,
|
||||||
|
// so its queued national history is discarded when nationID is 0.
|
||||||
|
await expect(
|
||||||
|
db.logEntry.count({
|
||||||
|
where: {
|
||||||
|
scope: 'NATION',
|
||||||
|
category: 'HISTORY',
|
||||||
|
nationId: existingNationId + 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toBe(0);
|
||||||
|
|
||||||
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||||
expect(reloaded.snapshot.generals.find((entry) => entry.id === generalId)).toMatchObject({
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === generalId)).toMatchObject({
|
||||||
|
|||||||
@@ -502,7 +502,6 @@ integration('RaiseInvader database persistence', () => {
|
|||||||
diplomacyCountsPerNation: [2],
|
diplomacyCountsPerNation: [2],
|
||||||
diplomacyStates: ['1:24'],
|
diplomacyStates: ['1:24'],
|
||||||
isunited: 1,
|
isunited: 1,
|
||||||
isUnited: 1,
|
|
||||||
blockChangeScout: false,
|
blockChangeScout: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -633,7 +632,6 @@ integration('RaiseInvader database persistence', () => {
|
|||||||
result: 'Deleted',
|
result: 'Deleted',
|
||||||
endingEventPresent: false,
|
endingEventPresent: false,
|
||||||
isunited: 3,
|
isunited: 3,
|
||||||
isUnited: 3,
|
|
||||||
refreshLimit: 300,
|
refreshLimit: 300,
|
||||||
logs: [
|
logs: [
|
||||||
'<C>●</>200년 4월:<L><b>【이벤트】</b></>이민족을 모두 소탕했습니다!',
|
'<C>●</>200년 4월:<L><b>【이벤트】</b></>이민족을 모두 소탕했습니다!',
|
||||||
@@ -644,5 +642,5 @@ integration('RaiseInvader database persistence', () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await dbHooks.close();
|
await dbHooks.close();
|
||||||
}
|
}
|
||||||
});
|
}, 30_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { buildNpcSelectionTokenSeed } from '../src/turn/npcPossessionService.js';
|
import { buildNpcSelectionTokenSeed } from '../src/turn/npcPossessionService.js';
|
||||||
|
|
||||||
describe('NPC possession legacy token contracts', () => {
|
describe('NPC possession legacy token contracts', () => {
|
||||||
it('builds the Ref SelectNPCToken seed from the Seoul whole-second timestamp', () => {
|
it('builds the Ref SelectNPCToken seed from the accepted game tick', () => {
|
||||||
expect(buildNpcSelectionTokenSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe(
|
expect(buildNpcSelectionTokenSeed('seed', 42, 72_000_001)).toBe(
|
||||||
'str(4,seed)|str(14,SelectNPCToken)|int(42)|str(19,2026-07-31 08:59:58)'
|
'str(4,seed)|str(14,SelectNPCToken)|int(42)|int(72000001)'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ integration('select pool release during general deletion', () => {
|
|||||||
'release-candidate',
|
'release-candidate',
|
||||||
'claim-candidate',
|
'claim-candidate',
|
||||||
'conflict-candidate',
|
'conflict-candidate',
|
||||||
'early-protected-candidate',
|
'early-protected',
|
||||||
'later-free-candidate',
|
'later-free',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -203,8 +203,8 @@ integration('select pool release during general deletion', () => {
|
|||||||
'release-candidate',
|
'release-candidate',
|
||||||
'claim-candidate',
|
'claim-candidate',
|
||||||
'conflict-candidate',
|
'conflict-candidate',
|
||||||
'early-protected-candidate',
|
'early-protected',
|
||||||
'later-free-candidate',
|
'later-free',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -366,12 +366,12 @@ integration('select pool release during general deletion', () => {
|
|||||||
await db.selectPoolEntry.createMany({
|
await db.selectPoolEntry.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
uniqueName: 'early-protected-candidate',
|
uniqueName: 'early-protected',
|
||||||
ownerUserId: 'protected-user',
|
ownerUserId: 'protected-user',
|
||||||
generalId: null,
|
generalId: null,
|
||||||
reservedUntil,
|
reservedUntil,
|
||||||
info: {
|
info: {
|
||||||
uniqueName: 'early-protected-candidate',
|
uniqueName: 'early-protected',
|
||||||
generalName: '보호후보',
|
generalName: '보호후보',
|
||||||
leadership: 70,
|
leadership: 70,
|
||||||
strength: 80,
|
strength: 80,
|
||||||
@@ -383,12 +383,12 @@ integration('select pool release during general deletion', () => {
|
|||||||
} as GamePrisma.InputJsonValue,
|
} as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uniqueName: 'later-free-candidate',
|
uniqueName: 'later-free',
|
||||||
ownerUserId: null,
|
ownerUserId: null,
|
||||||
generalId: null,
|
generalId: null,
|
||||||
reservedUntil: null,
|
reservedUntil: null,
|
||||||
info: {
|
info: {
|
||||||
uniqueName: 'later-free-candidate',
|
uniqueName: 'later-free',
|
||||||
generalName: '후행후보',
|
generalName: '후행후보',
|
||||||
leadership: 70,
|
leadership: 70,
|
||||||
strength: 80,
|
strength: 80,
|
||||||
@@ -406,8 +406,8 @@ integration('select pool release during general deletion', () => {
|
|||||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||||
});
|
});
|
||||||
const entries = world.listGeneralPoolEntries()!;
|
const entries = world.listGeneralPoolEntries()!;
|
||||||
const protectedCandidate = entries.find((entry) => entry.uniqueName === 'early-protected-candidate')!.candidate;
|
const protectedCandidate = entries.find((entry) => entry.uniqueName === 'early-protected')!.candidate;
|
||||||
const laterCandidate = entries.find((entry) => entry.uniqueName === 'later-free-candidate')!.candidate;
|
const laterCandidate = entries.find((entry) => entry.uniqueName === 'later-free')!.candidate;
|
||||||
const template = world.getGeneralById(generalId)!;
|
const template = world.getGeneralById(generalId)!;
|
||||||
expect(
|
expect(
|
||||||
world.addGeneral({
|
world.addGeneral({
|
||||||
@@ -446,7 +446,7 @@ integration('select pool release during general deletion', () => {
|
|||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
partial: false,
|
partial: false,
|
||||||
})
|
})
|
||||||
).rejects.toThrow('select_pool 후보를 점유하지 못했습니다: early-protected-candidate');
|
).rejects.toThrow('select_pool 후보를 점유하지 못했습니다: early-protected');
|
||||||
} finally {
|
} finally {
|
||||||
await hooks.close();
|
await hooks.close();
|
||||||
}
|
}
|
||||||
@@ -454,14 +454,14 @@ integration('select pool release during general deletion', () => {
|
|||||||
await expect(db.general.findUnique({ where: { id: protectedGeneralId } })).resolves.toBeNull();
|
await expect(db.general.findUnique({ where: { id: protectedGeneralId } })).resolves.toBeNull();
|
||||||
await expect(db.general.findUnique({ where: { id: laterGeneralId } })).resolves.toBeNull();
|
await expect(db.general.findUnique({ where: { id: laterGeneralId } })).resolves.toBeNull();
|
||||||
await expect(
|
await expect(
|
||||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'early-protected-candidate' } })
|
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'early-protected' } })
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
generalId: null,
|
generalId: null,
|
||||||
ownerUserId: 'protected-user',
|
ownerUserId: 'protected-user',
|
||||||
reservedUntil,
|
reservedUntil,
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'later-free-candidate' } })
|
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'later-free' } })
|
||||||
).resolves.toMatchObject({ generalId: null, ownerUserId: null, reservedUntil: null });
|
).resolves.toMatchObject({ generalId: null, ownerUserId: null, reservedUntil: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -222,11 +222,12 @@ describe('voteReward command', () => {
|
|||||||
|
|
||||||
let voteInserted = false;
|
let voteInserted = false;
|
||||||
let voteQueryCount = 0;
|
let voteQueryCount = 0;
|
||||||
|
let voteInsertQuery: { strings: readonly string[]; values: readonly unknown[] } | undefined;
|
||||||
const commandDb = {
|
const commandDb = {
|
||||||
auction: {
|
auction: {
|
||||||
findMany: async () => [],
|
findMany: async () => [],
|
||||||
},
|
},
|
||||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
$queryRaw: async (query: { strings: readonly string[]; values: readonly unknown[] }) => {
|
||||||
voteQueryCount += 1;
|
voteQueryCount += 1;
|
||||||
if (query.strings.join(' ').includes('SELECT options')) {
|
if (query.strings.join(' ').includes('SELECT options')) {
|
||||||
return [
|
return [
|
||||||
@@ -239,6 +240,7 @@ describe('voteReward command', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
voteInsertQuery = query;
|
||||||
if (voteInserted) return [];
|
if (voteInserted) return [];
|
||||||
voteInserted = true;
|
voteInserted = true;
|
||||||
return [{ id: 11 }];
|
return [{ id: 11 }];
|
||||||
@@ -255,12 +257,23 @@ describe('voteReward command', () => {
|
|||||||
acceptedGameTick: 0,
|
acceptedGameTick: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const writerWindowStart = Date.now();
|
||||||
const result = await handler.handle(command, { db: commandDb as any });
|
const result = await handler.handle(command, { db: commandDb as any });
|
||||||
|
const writerWindowEnd = Date.now();
|
||||||
expect(result && result.type).toBe('voteReward');
|
expect(result && result.type).toBe('voteReward');
|
||||||
if (!result || result.type !== 'voteReward' || !result.ok) {
|
if (!result || result.type !== 'voteReward' || !result.ok) {
|
||||||
throw new Error('voteReward result missing');
|
throw new Error('voteReward result missing');
|
||||||
}
|
}
|
||||||
expect(result.awardedUnique).toBe(true);
|
expect(result.awardedUnique).toBe(true);
|
||||||
|
expect(voteInsertQuery?.strings.join(' ')).toContain('created_at');
|
||||||
|
expect(
|
||||||
|
voteInsertQuery?.values.filter(
|
||||||
|
(value) =>
|
||||||
|
value instanceof Date &&
|
||||||
|
value.getTime() >= writerWindowStart &&
|
||||||
|
value.getTime() <= writerWindowEnd
|
||||||
|
)
|
||||||
|
).toHaveLength(1);
|
||||||
|
|
||||||
const updated = world.getGeneralById(1);
|
const updated = world.getGeneralById(1);
|
||||||
// ENGINE is the single reward linearization point, so it uses the
|
// ENGINE is the single reward linearization point, so it uses the
|
||||||
|
|||||||
@@ -684,6 +684,197 @@ export const buildWorkspaceCommands = (
|
|||||||
return commands;
|
return commands;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PROFILE_MIGRATION_TIME_ZONE = 'Asia/Seoul';
|
||||||
|
const PROFILE_MIGRATION_TIME_ZONE_OPTION = `-c TimeZone=${PROFILE_MIGRATION_TIME_ZONE}`;
|
||||||
|
const PROFILE_MIGRATION_TIME_ZONE_MENTION = /(^|[^A-Z0-9_])timezone(?=$|[^A-Z0-9_])/iu;
|
||||||
|
|
||||||
|
const profileMigrationTimeZoneError = (source: string): Error =>
|
||||||
|
new Error(
|
||||||
|
`Profile migration refused: ${source} must not configure a TimeZone other than ${PROFILE_MIGRATION_TIME_ZONE}.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenizePostgresOptions = (rawOptions: string, source: string): string[] => {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
let token = '';
|
||||||
|
let quote: "'" | '"' | null = null;
|
||||||
|
let escaping = false;
|
||||||
|
|
||||||
|
for (const character of rawOptions) {
|
||||||
|
if (escaping) {
|
||||||
|
token += character;
|
||||||
|
escaping = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '\\') {
|
||||||
|
escaping = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote) {
|
||||||
|
if (character === quote) quote = null;
|
||||||
|
else token += character;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === "'" || character === '"') {
|
||||||
|
quote = character;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/\s/u.test(character)) {
|
||||||
|
if (token) {
|
||||||
|
tokens.push(token);
|
||||||
|
token = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
token += character;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escaping || quote) throw profileMigrationTimeZoneError(source);
|
||||||
|
if (token) tokens.push(token);
|
||||||
|
return tokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPostgresOptionTimeZones = (rawOptions: string, source: string): string[] => {
|
||||||
|
const tokens = tokenizePostgresOptions(rawOptions, source);
|
||||||
|
const timeZones: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < tokens.length; index += 1) {
|
||||||
|
const token = tokens[index]!;
|
||||||
|
let setting: string | undefined;
|
||||||
|
if (token === '-c') {
|
||||||
|
setting = tokens[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else if (token.startsWith('-c') && token.length > 2) {
|
||||||
|
setting = token.slice(2);
|
||||||
|
} else if (token.startsWith('--') && token.length > 2) {
|
||||||
|
setting = token.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
if (PROFILE_MIGRATION_TIME_ZONE_MENTION.test(token)) throw profileMigrationTimeZoneError(source);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = setting.indexOf('=');
|
||||||
|
const name = separator >= 0 ? setting.slice(0, separator).trim() : setting.trim();
|
||||||
|
if (name.toLowerCase() !== 'timezone') continue;
|
||||||
|
const value = separator >= 0 ? setting.slice(separator + 1).trim() : '';
|
||||||
|
if (!value) throw profileMigrationTimeZoneError(source);
|
||||||
|
timeZones.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeZones.length === 0 && PROFILE_MIGRATION_TIME_ZONE_MENTION.test(rawOptions)) {
|
||||||
|
throw profileMigrationTimeZoneError(source);
|
||||||
|
}
|
||||||
|
return timeZones;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertProfileMigrationTimeZone = (timeZone: string, source: string): void => {
|
||||||
|
if (timeZone.trim().toLowerCase() !== PROFILE_MIGRATION_TIME_ZONE.toLowerCase()) {
|
||||||
|
throw profileMigrationTimeZoneError(source);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inspectProfileMigrationDatabaseUrl = (
|
||||||
|
profileDatabaseUrl: string
|
||||||
|
): { url: URL; optionKeys: string[]; existingOptions: string[]; configuredTimeZones: string[] } => {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(profileDatabaseUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Profile migration refused: DATABASE_URL is not a valid URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionKeys = [...new Set([...url.searchParams.keys()].filter((key) => key.toLowerCase() === 'options'))];
|
||||||
|
const existingOptions = optionKeys
|
||||||
|
.flatMap((key) => url.searchParams.getAll(key))
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const configuredTimeZones = existingOptions.flatMap((options) =>
|
||||||
|
readPostgresOptionTimeZones(options, 'DATABASE_URL options')
|
||||||
|
);
|
||||||
|
for (const timeZone of configuredTimeZones) {
|
||||||
|
assertProfileMigrationTimeZone(timeZone, 'DATABASE_URL options');
|
||||||
|
}
|
||||||
|
for (const [key, value] of url.searchParams) {
|
||||||
|
if (key.toLowerCase() === 'timezone') assertProfileMigrationTimeZone(value, 'DATABASE_URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url, optionKeys, existingOptions, configuredTimeZones };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildProfileMigrationDatabaseUrl = (profileDatabaseUrl: string): string => {
|
||||||
|
const { url, optionKeys, existingOptions, configuredTimeZones } =
|
||||||
|
inspectProfileMigrationDatabaseUrl(profileDatabaseUrl);
|
||||||
|
|
||||||
|
for (const key of optionKeys) url.searchParams.delete(key);
|
||||||
|
if (configuredTimeZones.length === 0) existingOptions.push(PROFILE_MIGRATION_TIME_ZONE_OPTION);
|
||||||
|
url.searchParams.set('options', existingOptions.join(' '));
|
||||||
|
return url.href;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertProfileMigrationEnvironmentTimeZone = (env?: Record<string, string>): void => {
|
||||||
|
const pgOptions = env?.PGOPTIONS?.trim();
|
||||||
|
if (pgOptions) {
|
||||||
|
for (const timeZone of readPostgresOptionTimeZones(pgOptions, 'PGOPTIONS')) {
|
||||||
|
assertProfileMigrationTimeZone(timeZone, 'PGOPTIONS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pgTimeZone = env?.PGTZ?.trim();
|
||||||
|
if (pgTimeZone) assertProfileMigrationTimeZone(pgTimeZone, 'PGTZ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildProfileMigrationEnv = (
|
||||||
|
profileDatabaseUrl: string,
|
||||||
|
env?: Record<string, string>
|
||||||
|
): Record<string, string> => {
|
||||||
|
assertProfileMigrationEnvironmentTimeZone(env);
|
||||||
|
return { ...(env ?? {}), DATABASE_URL: buildProfileMigrationDatabaseUrl(profileDatabaseUrl) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildProfileMigrationPreflightEnv = (
|
||||||
|
profileDatabaseUrl: string,
|
||||||
|
env?: Record<string, string>
|
||||||
|
): Record<string, string> => {
|
||||||
|
inspectProfileMigrationDatabaseUrl(profileDatabaseUrl);
|
||||||
|
assertProfileMigrationEnvironmentTimeZone(env);
|
||||||
|
return { ...(env ?? {}), DATABASE_URL: profileDatabaseUrl };
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_MIGRATION_TIME_ZONE_PREFLIGHT = `
|
||||||
|
import pg from 'pg';
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
let connected = false;
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
connected = true;
|
||||||
|
const result = await client.query("SELECT current_setting('TimeZone') AS timezone");
|
||||||
|
if (result.rows[0]?.timezone !== '${PROFILE_MIGRATION_TIME_ZONE}') {
|
||||||
|
throw new Error('Profile migration refused: database session TimeZone does not match the required migration contract.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (connected) await client.end();
|
||||||
|
}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
export const buildProfileMigrationPreflightCommand = (
|
||||||
|
workspaceRoot: string,
|
||||||
|
profileDatabaseUrl: string,
|
||||||
|
env?: Record<string, string>
|
||||||
|
): BuildCommand => ({
|
||||||
|
command: 'pnpm',
|
||||||
|
args: [
|
||||||
|
'--filter',
|
||||||
|
'@sammo-ts/infra',
|
||||||
|
'exec',
|
||||||
|
'node',
|
||||||
|
'--input-type=module',
|
||||||
|
'--eval',
|
||||||
|
PROFILE_MIGRATION_TIME_ZONE_PREFLIGHT,
|
||||||
|
],
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
env: buildProfileMigrationPreflightEnv(profileDatabaseUrl, env),
|
||||||
|
});
|
||||||
|
|
||||||
export const buildProfileMigrationCommand = (
|
export const buildProfileMigrationCommand = (
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
profileDatabaseUrl: string,
|
profileDatabaseUrl: string,
|
||||||
@@ -692,7 +883,7 @@ export const buildProfileMigrationCommand = (
|
|||||||
command: 'pnpm',
|
command: 'pnpm',
|
||||||
args: ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
args: ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
env: { ...(env ?? {}), DATABASE_URL: profileDatabaseUrl },
|
env: buildProfileMigrationEnv(profileDatabaseUrl, env),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapRuntimeStates = (profileNames: string[], processNames: Map<string, boolean>): ProfileRuntimeSnapshot[] =>
|
const mapRuntimeStates = (profileNames: string[], processNames: Map<string, boolean>): ProfileRuntimeSnapshot[] =>
|
||||||
@@ -2269,7 +2460,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
onProgress?: BuildProgressObserver
|
onProgress?: BuildProgressObserver
|
||||||
): Promise<Awaited<ReturnType<BuildRunner['run']>>> {
|
): Promise<Awaited<ReturnType<BuildRunner['run']>>> {
|
||||||
return this.buildRunner.run(
|
return this.buildRunner.run(
|
||||||
[buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv)],
|
[
|
||||||
|
buildProfileMigrationPreflightCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv),
|
||||||
|
buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv),
|
||||||
|
],
|
||||||
onProgress,
|
onProgress,
|
||||||
{ signal: this.activeOperationAbortSignal }
|
{ signal: this.activeOperationAbortSignal }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|||||||
import {
|
import {
|
||||||
buildProfileFrontendCommands,
|
buildProfileFrontendCommands,
|
||||||
buildProfileMigrationCommand,
|
buildProfileMigrationCommand,
|
||||||
|
buildProfileMigrationPreflightCommand,
|
||||||
buildProcessDefinitions,
|
buildProcessDefinitions,
|
||||||
buildSharedProfileFrontendCommands,
|
buildSharedProfileFrontendCommands,
|
||||||
buildWorkspaceCommands,
|
buildWorkspaceCommands,
|
||||||
@@ -437,10 +438,89 @@ describe('buildWorkspaceCommands', () => {
|
|||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
DATABASE_URL: databaseUrl,
|
DATABASE_URL: 'postgresql://integration.invalid/sammo?schema=che&options=-c+TimeZone%3DAsia%2FSeoul',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves existing non-timezone options and adds the migration KST session', () => {
|
||||||
|
const command = buildProfileMigrationCommand(
|
||||||
|
'/srv/sammo/worktrees/0123456789abcdef',
|
||||||
|
'postgresql://integration.invalid/sammo?schema=che&options=-c%20statement_timeout%3D30000'
|
||||||
|
);
|
||||||
|
const migrationUrl = new URL(command.env?.DATABASE_URL ?? '');
|
||||||
|
|
||||||
|
expect(migrationUrl.searchParams.getAll('options')).toEqual(['-c statement_timeout=30000 -c TimeZone=Asia/Seoul']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an already explicit KST migration contract without adding another override', () => {
|
||||||
|
const command = buildProfileMigrationCommand(
|
||||||
|
'/srv/sammo/worktrees/0123456789abcdef',
|
||||||
|
'postgresql://integration.invalid/sammo?schema=che&options=-c%20TimeZone%3DAsia%2FSeoul'
|
||||||
|
);
|
||||||
|
const migrationUrl = new URL(command.env?.DATABASE_URL ?? '');
|
||||||
|
|
||||||
|
expect(migrationUrl.searchParams.getAll('options')).toEqual(['-c TimeZone=Asia/Seoul']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on conflicting or ambiguous migration timezone sources without exposing the URL', () => {
|
||||||
|
const secretUrl =
|
||||||
|
'postgresql://migration:super-secret@integration.invalid/sammo?schema=che&options=-c%20TimeZone%3DUTC';
|
||||||
|
|
||||||
|
for (const build of [
|
||||||
|
() => buildProfileMigrationCommand('/srv/sammo/worktree', secretUrl),
|
||||||
|
() =>
|
||||||
|
buildProfileMigrationCommand(
|
||||||
|
'/srv/sammo/worktree',
|
||||||
|
'postgresql://integration.invalid/sammo?schema=che&timezone=UTC'
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
buildProfileMigrationCommand('/srv/sammo/worktree', 'postgresql://integration.invalid/sammo', {
|
||||||
|
PGOPTIONS: '-c statement_timeout=30000 --TimeZone=UTC',
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
buildProfileMigrationCommand('/srv/sammo/worktree', 'postgresql://integration.invalid/sammo', {
|
||||||
|
PGTZ: 'UTC',
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
buildProfileMigrationCommand(
|
||||||
|
'/srv/sammo/worktree',
|
||||||
|
'postgresql://integration.invalid/sammo?options=--TimeZone%20UTC'
|
||||||
|
),
|
||||||
|
]) {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
build();
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toContain('Profile migration refused');
|
||||||
|
expect((error as Error).message).not.toContain('super-secret');
|
||||||
|
expect((error as Error).message).not.toContain(secretUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the unmodified runtime URL before building the separate migration-only URL', () => {
|
||||||
|
const profileDatabaseUrl = 'postgresql://integration.invalid/sammo?schema=che';
|
||||||
|
const preflight = buildProfileMigrationPreflightCommand('/srv/sammo/worktree', profileDatabaseUrl);
|
||||||
|
const migration = buildProfileMigrationCommand('/srv/sammo/worktree', profileDatabaseUrl);
|
||||||
|
|
||||||
|
expect(preflight.args.slice(0, 6)).toEqual([
|
||||||
|
'--filter',
|
||||||
|
'@sammo-ts/infra',
|
||||||
|
'exec',
|
||||||
|
'node',
|
||||||
|
'--input-type=module',
|
||||||
|
'--eval',
|
||||||
|
]);
|
||||||
|
expect(preflight.args.at(-1)).toContain("current_setting('TimeZone')");
|
||||||
|
expect(preflight.env?.DATABASE_URL).toBe(profileDatabaseUrl);
|
||||||
|
expect(migration.env?.DATABASE_URL).toBe(
|
||||||
|
'postgresql://integration.invalid/sammo?schema=che&options=-c+TimeZone%3DAsia%2FSeoul'
|
||||||
|
);
|
||||||
|
expect(preflight.env?.DATABASE_URL).not.toBe(migration.env?.DATABASE_URL);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildProfileFrontendCommands', () => {
|
describe('buildProfileFrontendCommands', () => {
|
||||||
|
|||||||
@@ -225,12 +225,26 @@ describe('profile DEPLOY operation', () => {
|
|||||||
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_API_URL');
|
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_API_URL');
|
||||||
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_SSE_URL');
|
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_SSE_URL');
|
||||||
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_PROFILE');
|
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_PROFILE');
|
||||||
expect(commandGroups[1]?.map((command) => command.args)).toEqual([
|
expect(commandGroups[1]?.[0]?.args.slice(0, 6)).toEqual([
|
||||||
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
'--filter',
|
||||||
|
'@sammo-ts/infra',
|
||||||
|
'exec',
|
||||||
|
'node',
|
||||||
|
'--input-type=module',
|
||||||
|
'--eval',
|
||||||
|
]);
|
||||||
|
expect(commandGroups[1]?.[0]?.args.at(-1)).toContain("current_setting('TimeZone')");
|
||||||
|
expect(commandGroups[1]?.[1]?.args).toEqual([
|
||||||
|
'--filter',
|
||||||
|
'@sammo-ts/infra',
|
||||||
|
'prisma:migrate:deploy:game',
|
||||||
]);
|
]);
|
||||||
expect(commandGroups[1]?.[0]?.env?.DATABASE_URL).toBe(
|
expect(commandGroups[1]?.[0]?.env?.DATABASE_URL).toBe(
|
||||||
'postgresql://user:encoded%23password@integration.invalid/sammo?schema=che'
|
'postgresql://user:encoded%23password@integration.invalid/sammo?schema=che'
|
||||||
);
|
);
|
||||||
|
expect(commandGroups[1]?.[1]?.env?.DATABASE_URL).toBe(
|
||||||
|
'postgresql://user:encoded%23password@integration.invalid/sammo?schema=che&options=-c+TimeZone%3DAsia%2FSeoul'
|
||||||
|
);
|
||||||
expect(startedDefinitions).toHaveLength(backendProcessNames.length);
|
expect(startedDefinitions).toHaveLength(backendProcessNames.length);
|
||||||
for (const definition of startedDefinitions) {
|
for (const definition of startedDefinitions) {
|
||||||
expect(definition.env?.DATABASE_URL).toBe(
|
expect(definition.env?.DATABASE_URL).toBe(
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { PnpmBuildRunner } from '../src/orchestrator/buildRunner.js';
|
||||||
|
import { buildProfileMigrationPreflightCommand } from '../src/orchestrator/gatewayOrchestrator.js';
|
||||||
|
|
||||||
|
const utcDatabaseUrl = process.env.PROFILE_MIGRATION_UTC_DATABASE_URL?.trim();
|
||||||
|
|
||||||
|
describe('profile migration timezone preflight', () => {
|
||||||
|
it.skipIf(!utcDatabaseUrl)('rejects an actual database role whose default session timezone is UTC', async () => {
|
||||||
|
if (!utcDatabaseUrl) throw new Error('PROFILE_MIGRATION_UTC_DATABASE_URL is required');
|
||||||
|
const path = process.env.PATH;
|
||||||
|
if (!path) throw new Error('PATH is required');
|
||||||
|
const command = buildProfileMigrationPreflightCommand(process.cwd(), utcDatabaseUrl, { PATH: path });
|
||||||
|
|
||||||
|
const result = await new PnpmBuildRunner().run([command]);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.output).toContain('database session TimeZone does not match the required migration contract');
|
||||||
|
expect(result.output).not.toContain(utcDatabaseUrl);
|
||||||
|
const password = decodeURIComponent(new URL(utcDatabaseUrl).password);
|
||||||
|
if (password) expect(result.output).not.toContain(password);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: '20260823010000_add_web_push_notifications',
|
gatewaySchemaHead: '20260823010000_add_web_push_notifications',
|
||||||
gameSchemaHead: '20260823010000_add_web_push_outbox',
|
gameSchemaHead: '20260824080000_vote_utc_wall_timestamps',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
## 범위와 판정 기준
|
## 범위와 판정 기준
|
||||||
|
|
||||||
`app/game-api/src/router/**`의 mutation에서 직접 또는 router 전용 helper를 거쳐
|
`app/game-api/src/router/**`의 mutation에서 직접 또는 router 전용 helper를 거쳐
|
||||||
`ctx.turnDaemon.requestCommand()`를 호출하는 46개 route를 조사했다. `authedProcedure`,
|
`ctx.turnDaemon.requestCommand()`를 호출하는 **49개 route**를 조사했다. 하나의 route가
|
||||||
`accessAuthedProcedure`, `accessAuthedInputProcedure`는 mutation일 때
|
여러 command를 보내더라도 route는 한 번만 세었다.
|
||||||
`app/game-api/src/trpc.ts:42-75`의 API `input_event` transaction을 만든다.
|
|
||||||
`engineAuthedProcedure`, `accessEngineAuthedProcedure`,
|
`authedProcedure`, `accessAuthedProcedure`, `accessAuthedInputProcedure`는 mutation일 때
|
||||||
|
`app/game-api/src/trpc.ts:61-104`, `:168-180`, `:215-221`의 API `input_event`
|
||||||
|
transaction을 만든다. `engineAuthedProcedure`, `accessEngineAuthedProcedure`,
|
||||||
`accessEngineAuthedInputProcedure`는 인증/접속 계측만 수행하며 API outer transaction을
|
`accessEngineAuthedInputProcedure`는 인증/접속 계측만 수행하며 API outer transaction을
|
||||||
만들지 않는다(`app/game-api/src/trpc.ts:150-175`).
|
만들지 않는다(`app/game-api/src/trpc.ts:182-192`, `:222-227`).
|
||||||
|
|
||||||
판정은 다음과 같다.
|
판정은 다음과 같다.
|
||||||
|
|
||||||
@@ -16,48 +18,65 @@
|
|||||||
transaction이 소유하고, ENGINE handler가 mutation 직전 mutable state를 다시 검증한다.
|
transaction이 소유하고, ENGINE handler가 mutation 직전 mutable state를 다시 검증한다.
|
||||||
- **혼합/saga 필요**: API DB write, Redis 원본 상태, 보상 명령 또는 API snapshot에서만
|
- **혼합/saga 필요**: API DB write, Redis 원본 상태, 보상 명령 또는 API snapshot에서만
|
||||||
수행하는 권한/값 합성이 ENGINE 변경과 결합한다. procedure만 바꾸지 않는다.
|
수행하는 권한/값 합성이 ENGINE 변경과 결합한다. procedure만 바꾸지 않는다.
|
||||||
- **기존 정상**: 이미 ENGINE procedure이고 API outer input event가 없다.
|
- **기존 ENGINE**: 이 inventory의 기존 기준선에서 이미 ENGINE procedure였고 API
|
||||||
|
outer input event가 없었다.
|
||||||
|
- **ENGINE 소유 + 불필요한 API outer**: gameplay durable mutation은 ENGINE이 전부
|
||||||
|
소유하지만 route가 아직 API `input_event`/journal transaction에 감싸여 있다.
|
||||||
|
|
||||||
ENGINE 전환 route의 명시적 `requestId`는 기존 middleware가 만든 child identity
|
현재 합계는 **ENGINE 전환 26 + 혼합 13 + 기존 ENGINE 9 + 불필요한 API
|
||||||
`<http request id>:<trpc path>:engine:0:<command type>`를 유지한다. 따라서 deploy 전후
|
outer 1 = 49**다.
|
||||||
동일 HTTP request identity의 ENGINE event가 달라지지 않는다.
|
|
||||||
|
|
||||||
## 이번에 ENGINE procedure로 전환
|
ENGINE 전환 route의 explicit `requestId`는 각 route가 기존에 사용하던 HTTP 요청
|
||||||
|
또는 user/client-scoped durable identity를 유지한다. `inheritanceAction`은
|
||||||
|
`<http request id>:inherit.<action>:engine:0:inheritanceAction`을 사용하고,
|
||||||
|
`join.getSelectionPool`은 `select-pool:<user>:<http request id>:reserve`를 사용한다.
|
||||||
|
selection-pool create/reselect는 client request ID가 있을 때
|
||||||
|
`select-pool:<user>:<client request id>:<operation>`을, 없을 때 HTTP request/path identity를 사용한다
|
||||||
|
(`app/game-api/src/router/join/index.ts:75-91`).
|
||||||
|
|
||||||
| route | 이전 outer transaction | API-side 작업 | ENGINE 소유 근거 |
|
## ENGINE procedure로 전환된 route
|
||||||
| ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `general.vacation`, `general.setMySetting`, `general.dropItem` | 있음 | session-owned general 조회만 수행 | route `app/game-api/src/router/general/index.ts:657-706`; ENGINE이 general 존재/현재 설정/보유 item을 다시 검사하고 변경 `app/game-engine/src/turn/worldCommandHandler.ts:1476`, `:1514`, `:1571` |
|
|
||||||
| `nation.appoint`, `nation.changePermission`, `nation.kick` | 있음 | session actor 조회만 수행 | route `app/game-api/src/router/nation/endpoints/appoint.ts:7`, `changePermission.ts:7`, `kick.ts:7`; ENGINE이 actor 직위, 국가, 대상/도시를 다시 검사 `app/game-engine/src/turn/worldCommandHandler.ts:1648`, `:1718`, `:1888` |
|
|
||||||
| `troop.create`, `troop.join`, `troop.exit`, `troop.kick`, `troop.rename` | 있음 | actor 및 조기 권한/대상 조회; API DB write 없음 | route `app/game-api/src/router/troop/index.ts:165-314`; 동일 membership/leader/nation/name 검증과 mutation은 ENGINE `app/game-engine/src/turn/worldCommandHandler.ts:924`, `:1012`, `:1079`, `:1128`, `:1180` |
|
|
||||||
| `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`, `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique` | 있음 | auction/general/world 조기 validation; commit 뒤 Redis timer index 갱신 | route `app/game-api/src/router/auction/index.ts:325-649`; auction open/bid DB mutation과 경합/resource 재검증은 ENGINE transaction `app/game-engine/src/turn/worldCommandHandler.ts:1613`, `app/game-engine/src/auction/bidder.ts:183-550`. Redis zset은 durable auction row에서 재구성 가능한 scheduler index이며 API DB transaction의 일부가 아니었다. |
|
|
||||||
| `inherit.openUniqueAuction` | 있음 | world/general/minimum bid 조기 validation; inheritance point 차감 없음 | route `app/game-api/src/router/inherit/index.ts:794-835`; 공통 `auctionOpen` ENGINE handler와 Redis timer index만 사용 |
|
|
||||||
|
|
||||||
합계 18개 route다. 모든 전환은 procedure 변경과 stable ENGINE request ID만 포함하며
|
| route | 현재 procedure / API-side 작업 | ENGINE 소유 근거 |
|
||||||
ENGINE handler, DB schema, journal/publisher foundation은 변경하지 않았다.
|
| --- | --- | --- |
|
||||||
|
| `general.vacation`, `general.setMySetting`, `general.dropItem` | `engineAuthedProcedure`/`accessEngineAuthedInputProcedure`; session-owned general 조회만 수행 (`app/game-api/src/router/general/index.ts:767-815`) | ENGINE이 general 존재, 현재 설정과 item 보유를 다시 검증하고 변경 (`app/game-engine/src/turn/worldCommandHandler.ts:1712-1841`) |
|
||||||
|
| `nation.appoint`, `nation.changePermission`, `nation.kick` | `engineAuthedProcedure`; session actor 조회만 수행 (`app/game-api/src/router/nation/endpoints/appoint.ts:7-32`, `changePermission.ts:7-35`, `kick.ts:7-24`) | ENGINE이 actor 직위, 국가, 대상/도시를 다시 검증 (`app/game-engine/src/turn/worldCommandHandler.ts:1894-2291`) |
|
||||||
|
| `troop.create`, `troop.join`, `troop.exit`, `troop.kick`, `troop.rename` | `engineAuthedProcedure`; actor 및 조기 권한/대상 조회, API DB write 없음 (`app/game-api/src/router/troop/index.ts:429-577`) | membership/leader/nation/name 검증과 mutation을 ENGINE이 소유 (`app/game-engine/src/turn/worldCommandHandler.ts:1131-1430`) |
|
||||||
|
| `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`, `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique` | `engineAuthedProcedure`; auction/general/world 조기 validation과 commit 뒤 Redis timer index 갱신 (`app/game-api/src/router/auction/index.ts:337-662`) | open/bid DB mutation과 경합/resource 재검증은 ENGINE transaction (`app/game-engine/src/turn/worldCommandHandler.ts:1859-1885`, `app/game-engine/src/auction/bidder.ts`). Redis zset은 durable auction row에서 재구성하는 scheduler index다. |
|
||||||
|
| `inherit.openUniqueAuction` | `engineAuthedProcedure`; world/general/minimum bid 조기 validation (`app/game-api/src/router/inherit/index.ts:389-428`) | 공통 `auctionOpen` ENGINE handler가 mutation을 소유하고 API는 Redis timer index만 갱신 (`app/game-api/src/auction/open.ts:22-51`) |
|
||||||
|
| `join.getSelectionPool` | `engineAuthedProcedure`; actor와 accepted game time만 전달 (`app/game-api/src/router/join/index.ts:390-406`) | `selectPoolReserve` ENGINE handler가 world/DB 상태에서 예약을 재검증하고 저장 (`app/game-engine/src/turn/worldCommandHandler.ts:401-437`) |
|
||||||
|
| `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique`, `inherit.checkOwner` | 모두 `engineAuthedProcedure`; 인증 user ID와 action 입력만 전달 (`app/game-api/src/router/inherit/index.ts:107-124`, `:301-388`, `:429-445`) | `inheritanceAction` ENGINE handler가 general/user 소유권, 통일 상태, 잔액, 대상, RNG, general patch, inheritance point/log/message를 한 transaction에서 처리 (`app/game-engine/src/turn/worldCommandHandler.ts:807-818`, `app/game-engine/src/turn/inheritanceActionService.ts:313-669`) |
|
||||||
|
|
||||||
|
합계 **26개 route**다.
|
||||||
|
|
||||||
## 혼합 또는 validation 이관이 먼저 필요한 route
|
## 혼합 또는 validation 이관이 먼저 필요한 route
|
||||||
|
|
||||||
| route | 현재 procedure / outer transaction | 보류 근거 |
|
| route | 현재 procedure / outer transaction | 보류 근거 |
|
||||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --- | --- | --- |
|
||||||
| `messages.respond` | `authedProcedure`, action별 분기 (`app/game-api/src/router/messages/index.ts`) | `scout`와 `raiseInvader`는 stable `messageRespond` ENGINE command가 actor/message를 다시 잠그고 처리한다. `noAggression`/`cancelNA`/`stopWar`는 기존 API transaction의 `respondToDiplomaticMessage`가 처리하므로 route 전체를 ENGINE-owned로 분류하지 않는다. 두 경로 모두 commit journal을 남긴다. |
|
| `messages.respond` | `authedProcedure`, action별 분기 (`app/game-api/src/router/messages/index.ts:318-372`) | `scout`/`raiseInvader`는 `messageRespond` ENGINE command가 처리하지만 `noAggression`/`cancelNA`/`stopWar`는 API transaction의 `respondToDiplomaticMessage` 경로가 처리한다. route 전체를 ENGINE-owned로 보지 않는다. |
|
||||||
| `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique` | `authedProcedure`, 있음 (`app/game-api/src/router/inherit/index.ts:343`, `:405`, `:486`, `:542`, `:599`, `:746`) | ENGINE `patchGeneral` 뒤 API transaction이 inheritance point, inheritance log, 일부 user-state를 쓴다(`:388-402`, `:464-483`, `:523-539`, `:581-596`, `:700-742`, `:777-790`). 현재 outer transaction도 먼저 commit된 ENGINE 변경을 rollback하지 못한다. 한 ENGINE command로 합치거나 durable saga가 필요하다. |
|
| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, outer 있음 (`app/game-api/src/router/nation/endpoints/setNotice.ts:11`, `setScoutMsg.ts:11`, `setSecretLimit.ts:10`, `setRate.ts:10`, `setBlockWar.ts:10`, `setBill.ts:10`, `setBlockScout.ts:10`) | API가 actor 권한과 nation meta를 읽어 full metadata patch를 합성한다. `setNationMeta`는 `_updatedAt` CAS만 검사하므로 actor/permission과 합성 의미를 ENGINE으로 옮겨야 한다 (`app/game-api/src/router/nation/shared.ts:431-458`, `app/game-engine/src/turn/worldCommandHandler.ts:499-542`). |
|
||||||
| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, 있음 (`app/game-api/src/router/nation/endpoints/setNotice.ts:11`, `setScoutMsg.ts:11`, `setSecretLimit.ts:10`, `setRate.ts:10`, `setBlockWar.ts:10`, `setBill.ts:10`, `setBlockScout.ts:10`) | API가 actor 권한과 nation meta를 읽어 full metadata patch를 합성한다. ENGINE `setNationMeta`는 `_updatedAt` CAS만 검사하고 actor 권한을 알지 못한다(`app/game-engine/src/turn/worldCommandHandler.ts:430-472`). actor/permission을 command와 ENGINE validation으로 옮긴 뒤 전환한다. |
|
| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, outer 있음 (`app/game-api/src/router/npc/index.ts:545-812`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 `setNationMeta` CAS를 사용한다. ENGINE은 권한/합성 의미를 소유하지 않는다. |
|
||||||
| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, 있음 (`app/game-api/src/router/npc/index.ts:540`, `:703`, `:756`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 같은 `setNationMeta` CAS를 사용한다(`:540-702`, `:703-755`, `:756-807`). ENGINE이 권한/합성 의미를 소유하지 않는다. |
|
| `tournament.join`, `tournament.placeBet` | `authedProcedure`, outer 있음 (`app/game-api/src/router/tournament/index.ts:393-458`, `:515-620`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다. 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. |
|
||||||
| `tournament.join`, `tournament.placeBet` | `authedProcedure`, 있음 (`app/game-api/src/router/tournament/index.ts:376`, `:523`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다(`:376-463`, `:523-628`). 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. |
|
|
||||||
| `vote.submitVote` | `authedProcedure`, 있음 (`app/game-api/src/router/vote/index.ts:349-528`) | API transaction이 vote row를 insert한 뒤 ENGINE `voteReward`를 기다리고 commit 뒤 front-status publish를 수행한다. vote/reward 단일 소유 command 또는 idempotent saga 없이는 분리할 수 없다. 이 작업에서는 vote journal/publisher를 수정하지 않았다. |
|
|
||||||
|
|
||||||
합계 20개 route다. 특히 inheritance/vote의 현재 outer transaction은 API 절반만
|
합계 **13개 route**다.
|
||||||
rollback하므로 “원자적”이라고 간주하면 안 된다.
|
|
||||||
|
|
||||||
## 이미 API outer transaction이 없는 정상 route
|
## 기존에 API outer transaction이 없던 ENGINE route
|
||||||
|
|
||||||
| route | 근거 |
|
| route | 근거 |
|
||||||
| ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --- | --- |
|
||||||
| `general.adjustIcon` | `engineAuthedProcedure`; `app/game-api/src/router/general/index.ts:584-610`. helper가 stable account-icon request ID로 ENGINE command를 보냄. |
|
| `general.adjustIcon` | `engineAuthedProcedure`; helper가 stable account-icon request ID로 ENGINE command를 보냄 (`app/game-api/src/router/general/index.ts:694-720`, `app/game-api/src/services/accountIconSync.ts:40-75`) |
|
||||||
| `general.ensureDieOnPrestartStatus`, `general.dieOnPrestart`, `general.buildNationCandidate`, `general.instantRetreat` | `accessEngineAuthedProcedure`/`accessEngineAuthedInputProcedure`; `app/game-api/src/router/general/index.ts:612-656`. user/general 조회는 outer transaction 밖이고 command마다 stable request ID가 있다. |
|
| `general.ensureDieOnPrestartStatus`, `general.dieOnPrestart`, `general.buildNationCandidate`, `general.instantRetreat` | `accessEngineAuthedProcedure`/`accessEngineAuthedInputProcedure`; user/general 조회는 outer transaction 밖이고 command마다 stable request ID가 있음 (`app/game-api/src/router/general/index.ts:104-144`, `:722-766`) |
|
||||||
| `join.selectPoolGeneral`, `join.reselectPoolGeneral`, `join.createGeneral`, `join.possessGeneral` | `engineAuthedProcedure`; `app/game-api/src/router/join/index.ts:390`, `:434`, `:462`, `:585`. client request ID가 있으면 user-scoped durable ENGINE identity를 사용한다. |
|
| `join.selectPoolGeneral`, `join.reselectPoolGeneral`, `join.createGeneral`, `join.possessGeneral` | `engineAuthedProcedure`; client request ID가 있으면 user-scoped durable identity를 사용 (`app/game-api/src/router/join/index.ts:407-564`, `:608-642`) |
|
||||||
|
|
||||||
합계 9개 route다.
|
합계 **9개 route**다.
|
||||||
|
|
||||||
|
## ENGINE이 소유하지만 API outer transaction이 남은 route
|
||||||
|
|
||||||
|
| route | 현재 상태 | 남은 일 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `vote.submitVote` | `authedProcedure`가 API outer `input_event` transaction을 만든다. API는 poll/actor 조기 validation 후 `voteReward`를 보내고 `front.general` journal을 표시한다 (`app/game-api/src/router/vote/index.ts:294-369`). ENGINE은 poll row lock, 선택 validation, vote insert, reward/idempotency marker, 금·아이템·로그 변경을 한 mutation transaction에서 소유한다 (`app/game-engine/src/turn/worldCommandHandler.ts:2430-2847`). | viewer-specific `front.general` invalidation을 ENGINE commit journal로 옮기고, 현재 middleware가 만드는 `voteReward` child identity를 explicit request ID로 보존한 뒤 ENGINE procedure로 전환한다. |
|
||||||
|
|
||||||
|
합계 **1개 route**다. Gameplay DB mutation 소유권 기준으로는 불필요한 outer이지만,
|
||||||
|
`front.general` journal이 API에 남아 있으므로 procedure만 바꾸면 실시간 갱신 계약을 잃는다.
|
||||||
|
|
||||||
## 검증 계약
|
## 검증 계약
|
||||||
|
|
||||||
@@ -68,6 +87,14 @@ rollback하므로 “원자적”이라고 간주하면 안 된다.
|
|||||||
- `app/game-api/test/troopRouter.test.ts`: troop mutation의 동일 계약을 검증한다.
|
- `app/game-api/test/troopRouter.test.ts`: troop mutation의 동일 계약을 검증한다.
|
||||||
- `app/game-api/test/auctionRouter.test.ts`: auction mutation이 API transaction 없이
|
- `app/game-api/test/auctionRouter.test.ts`: auction mutation이 API transaction 없이
|
||||||
daemon command와 Redis timer projection을 완료하는 계약을 검증한다.
|
daemon command와 Redis timer projection을 완료하는 계약을 검증한다.
|
||||||
|
- `app/game-api/test/inheritRouter.test.ts`,
|
||||||
|
`app/game-engine/test/inheritanceActionPersistence.integration.test.ts`: 인증 actor, point/log,
|
||||||
|
general/message 변경이 `inheritanceAction` ENGINE transaction에 함께 있는지 검증한다.
|
||||||
|
- `app/game-api/test/voteRouter.test.ts`, `app/game-engine/test/voteReward.test.ts`: API 조기
|
||||||
|
validation과 ENGINE의 vote insert/reward/idempotency 경계를 검증한다. API outer/journal
|
||||||
|
제거는 아직 검증 대상이 아니다.
|
||||||
- raw inventory 재검색: `rg -n "requestCommand\\(" app/game-api/src/router`와
|
- raw inventory 재검색: `rg -n "requestCommand\\(" app/game-api/src/router`와
|
||||||
`openAuctionWithDaemon`, `patchGeneral`, `updateNationMeta`,
|
`openAuctionWithDaemon`, `requestInheritanceAction`, `updateNationMeta`,
|
||||||
`adjustAccountIconForUser` caller 검색을 함께 실행해야 helper 경유 route를 놓치지 않는다.
|
`adjustAccountIconForUser`, `requestImmediateAction`, `requestJoinCreateCommand`,
|
||||||
|
`requestNpcPossessionCommand` caller 검색을 함께 실행해 helper 경유 route를 놓치지
|
||||||
|
않는다.
|
||||||
|
|||||||
@@ -21,6 +21,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:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh",
|
||||||
"coverage:activate:game": "node scripts/activate-read-model-coverage.mjs",
|
"coverage:activate:game": "node scripts/activate-read-model-coverage.mjs",
|
||||||
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
|
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
|
||||||
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
||||||
|
|||||||
@@ -97,12 +97,12 @@ model ReadModelOutbox {
|
|||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
payload Json
|
payload Json
|
||||||
attempts Int @default(0)
|
attempts Int @default(0)
|
||||||
availableAt DateTime @default(now()) @map("available_at")
|
availableAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("available_at") @db.Timestamp(3)
|
||||||
lockedAt DateTime? @map("locked_at")
|
lockedAt DateTime? @map("locked_at") @db.Timestamp(3)
|
||||||
lockOwner String? @map("lock_owner")
|
lockOwner String? @map("lock_owner")
|
||||||
deliveredAt DateTime? @map("delivered_at")
|
deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3)
|
||||||
lastError String? @map("last_error")
|
lastError String? @map("last_error")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
|
||||||
|
|
||||||
@@index([deliveredAt, availableAt, id], map: "read_model_outbox_delivered_at_available_at_id_idx")
|
@@index([deliveredAt, availableAt, id], map: "read_model_outbox_delivered_at_available_at_id_idx")
|
||||||
@@map("read_model_outbox")
|
@@map("read_model_outbox")
|
||||||
@@ -116,12 +116,12 @@ model WebPushOutbox {
|
|||||||
year Int?
|
year Int?
|
||||||
month Int?
|
month Int?
|
||||||
attempts Int @default(0)
|
attempts Int @default(0)
|
||||||
availableAt DateTime @default(now()) @map("available_at")
|
availableAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("available_at") @db.Timestamp(3)
|
||||||
lockedAt DateTime? @map("locked_at")
|
lockedAt DateTime? @map("locked_at") @db.Timestamp(3)
|
||||||
lockOwner String? @map("lock_owner")
|
lockOwner String? @map("lock_owner")
|
||||||
deliveredAt DateTime? @map("delivered_at")
|
deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3)
|
||||||
lastError String? @map("last_error")
|
lastError String? @map("last_error")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
|
||||||
|
|
||||||
@@index([deliveredAt, availableAt, id], map: "web_push_outbox_delivered_at_available_at_id_idx")
|
@@index([deliveredAt, availableAt, id], map: "web_push_outbox_delivered_at_available_at_id_idx")
|
||||||
@@map("web_push_outbox")
|
@@map("web_push_outbox")
|
||||||
@@ -901,8 +901,8 @@ model VotePoll {
|
|||||||
endAt DateTime? @map("end_at")
|
endAt DateTime? @map("end_at")
|
||||||
endTick BigInt? @map("end_tick")
|
endTick BigInt? @map("end_tick")
|
||||||
closedAt DateTime? @map("closed_at")
|
closedAt DateTime? @map("closed_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||||
|
|
||||||
votes Vote[]
|
votes Vote[]
|
||||||
comments VoteComment[]
|
comments VoteComment[]
|
||||||
@@ -916,7 +916,7 @@ model Vote {
|
|||||||
generalId Int @map("general_id")
|
generalId Int @map("general_id")
|
||||||
nationId Int @map("nation_id")
|
nationId Int @map("nation_id")
|
||||||
selection Json
|
selection Json
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
|
||||||
|
|
||||||
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
|
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -933,7 +933,7 @@ model VoteComment {
|
|||||||
generalName String @map("general_name")
|
generalName String @map("general_name")
|
||||||
nationName String @map("nation_name")
|
nationName String @map("nation_name")
|
||||||
text String
|
text String
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
|
||||||
|
|
||||||
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
|
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
-- The game schema contains legacy wall-clock DateTime fields, so its shared
|
||||||
|
-- connection pool cannot be changed to UTC wholesale. Keep the two operational
|
||||||
|
-- outboxes rollback-compatible as TIMESTAMP(3), but make their scheduling and
|
||||||
|
-- event-time contract explicitly UTC wall time.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Read-model journal inserts omit both timestamp columns and therefore used the
|
||||||
|
-- database session wall clock. Normalize that single-provenance history to UTC
|
||||||
|
-- wall time. Web Push createMany already wrote JavaScript UTC fields, so its
|
||||||
|
-- created_at values must remain byte-for-byte unchanged.
|
||||||
|
UPDATE "read_model_outbox"
|
||||||
|
SET "created_at" = ("created_at" AT TIME ZONE current_setting('TimeZone')) AT TIME ZONE 'UTC';
|
||||||
|
|
||||||
|
-- Both outboxes are at-least-once. Release stale leases and make every pending
|
||||||
|
-- row immediately eligible so mixed historical available_at provenance cannot
|
||||||
|
-- strand it. Read-model replay is a tolerated duplicate invalidation; Web Push
|
||||||
|
-- replay is deduplicated by its stable Gateway event ID.
|
||||||
|
UPDATE "read_model_outbox"
|
||||||
|
SET
|
||||||
|
"available_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
|
"locked_at" = NULL,
|
||||||
|
"lock_owner" = NULL
|
||||||
|
WHERE "delivered_at" IS NULL;
|
||||||
|
|
||||||
|
UPDATE "web_push_outbox"
|
||||||
|
SET
|
||||||
|
"available_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
||||||
|
"locked_at" = NULL,
|
||||||
|
"lock_owner" = NULL
|
||||||
|
WHERE "delivered_at" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "read_model_outbox"
|
||||||
|
ALTER COLUMN "available_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||||
|
ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC');
|
||||||
|
|
||||||
|
ALTER TABLE "web_push_outbox"
|
||||||
|
ALTER COLUMN "available_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||||
|
ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
-- Vote mutations use raw SQL to preserve their locked transaction and Ref
|
||||||
|
-- ordering. Game connections retain the Ref-compatible Seoul session, so make
|
||||||
|
-- timestamp-omitting insert fallbacks safe for older and future writers.
|
||||||
|
-- Historical rows are intentionally preserved because
|
||||||
|
-- Prisma-seeded UTC values and DB-default Seoul values have no durable
|
||||||
|
-- provenance marker that would allow a safe blanket rewrite.
|
||||||
|
ALTER TABLE "vote_poll"
|
||||||
|
ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||||
|
ALTER COLUMN "updated_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC');
|
||||||
|
|
||||||
|
ALTER TABLE "vote"
|
||||||
|
ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC');
|
||||||
|
|
||||||
|
ALTER TABLE "vote_comment"
|
||||||
|
ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC');
|
||||||
@@ -27,10 +27,19 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여
|
|||||||
- `world_state`, `nation`, `city`, `general`, `message`, `troop`
|
- `world_state`, `nation`, `city`, `general`, `message`, `troop`
|
||||||
- `general_turn`, `nation_turn`과 revision·lease field
|
- `general_turn`, `nation_turn`과 revision·lease field
|
||||||
- `input_event`, `turn_daemon_lease`
|
- `input_event`, `turn_daemon_lease`
|
||||||
- `read_model_revision`, `read_model_outbox`, `read_model_revision_meta`
|
- `read_model_revision`, `read_model_outbox`, `read_model_revision_meta`, `web_push_outbox`
|
||||||
|
- 두 outbox의 `available_at`, `locked_at`, `delivered_at`, `created_at`은
|
||||||
|
millisecond 정밀도 `timestamp without time zone`을 유지한다. 이 migration
|
||||||
|
이후 신규 값과 pending/dispatcher 운영 계약은 UTC wall 값으로 통일하되,
|
||||||
|
이미 전달된 과거 행의 표시용 시각 전체를 일괄 재해석하지 않는다.
|
||||||
- `read_model_revision_meta.id=1`의 `coverage_version=0`
|
- `read_model_revision_meta.id=1`의 `coverage_version=0`
|
||||||
- `diplomacy`, `event`, `log_entry`, `error_log`
|
- `diplomacy`, `event`, `log_entry`, `error_log`
|
||||||
- auction, board, vote, yearbook, archive와 inheritance table
|
- auction, board, vote, yearbook, archive와 inheritance table
|
||||||
|
- `vote_poll.created_at/updated_at`, `vote.created_at`, `vote_comment.created_at`의
|
||||||
|
신규 raw-SQL fallback은 KST game session에서도 UTC wall 값을 기록한다. 현재
|
||||||
|
writer는 JavaScript `Date`를 명시하고, 이전 writer 형태의 column 생략도 새
|
||||||
|
default로 안전해야 한다. 기존 vote timestamp는 Prisma UTC와 raw KST 출처를
|
||||||
|
구분할 표식이 없어 소급 이동하지 않는다.
|
||||||
- `nation.chief_general_id`
|
- `nation.chief_general_id`
|
||||||
- `city.trade` nullable, `city.trust` REAL
|
- `city.trade` nullable, `city.trust` REAL
|
||||||
- `auction_bid.meta` JSONB NOT NULL
|
- `auction_bid.meta` JSONB NOT NULL
|
||||||
@@ -42,6 +51,42 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여
|
|||||||
검증이 끝나면 이름을 직접 확인한 임시 database와 role만 제거합니다. 공유
|
검증이 끝나면 이름을 직접 확인한 임시 database와 role만 제거합니다. 공유
|
||||||
database나 Compose volume을 삭제하지 않습니다.
|
database나 Compose volume을 삭제하지 않습니다.
|
||||||
|
|
||||||
|
## Game outbox UTC-wall populated upgrade 검증
|
||||||
|
|
||||||
|
Git에서 제외된 전용 PostgreSQL URL을 주입해 target 직전 migration chain부터
|
||||||
|
실제 data upgrade와 두 번째 deploy no-op까지 검증합니다.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL=... \
|
||||||
|
pnpm --filter @sammo-ts/infra verify:migration:outbox-utc
|
||||||
|
```
|
||||||
|
|
||||||
|
검증기는 실행별 소유권 comment가 있는 schema만 만들고 정리합니다. KST DB
|
||||||
|
default로 생성된 ReadModel `created_at`의 UTC-wall 변환, 기존 JavaScript UTC
|
||||||
|
WebPush `created_at` 보존, 두 pending outbox의 requeue·lease 해제, delivered 행
|
||||||
|
보존, target checksum과 이전 DML shape 호환성을 확인합니다. 이전 binary를 별도
|
||||||
|
build해 실행하거나 down migration을 제공한다는 뜻은 아닙니다.
|
||||||
|
|
||||||
|
운영 release-controller의 게임 migration 명령은 profile runtime URL을 바꾸지
|
||||||
|
않고 migration 연결에만 `options=-c TimeZone=Asia/Seoul`을 추가합니다. 이미
|
||||||
|
명시된 `DATABASE_URL` option/query, `PGOPTIONS` 또는 `PGTZ`가 다른 timezone을
|
||||||
|
요구하면 마지막 옵션으로 덮지 않고 migration 시작 전에 실패합니다. 그 다음
|
||||||
|
변경하지 않은 원본 profile URL로 `current_setting('TimeZone')`을 조회해 기존
|
||||||
|
writer session이 실제로 `Asia/Seoul`인지 확인한 뒤에만, KST option을 고정한
|
||||||
|
별도 URL로 Prisma migration을 실행합니다. 이는 이미 배포된 migration checksum을
|
||||||
|
보존하면서 legacy game wall-clock provenance가 다른 과거 시각을 0700 migration이
|
||||||
|
잘못 재해석하는 일을 막습니다.
|
||||||
|
|
||||||
|
실제 fail-closed 경계는 timezone option이 없는 일회성 UTC-default role URL을
|
||||||
|
`PROFILE_MIGRATION_UTC_DATABASE_URL`로 주입하고 다음처럼 재현합니다. 이 URL은
|
||||||
|
격리 DB의 disposable role만 사용하며 문서·로그에 값을 남기지 않습니다.
|
||||||
|
marker는 `external_fixture`로 등록되어 일반 조건부 runner가 일회성 role을 만들거나
|
||||||
|
이 테스트를 실행하지 않으며, 위 URL을 준비한 명시적 실행에서만 활성화됩니다.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm --filter @sammo-ts/gateway-api test profileMigrationTimezone.integration.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
## NPC selection 중복 owner preflight
|
## NPC selection 중복 owner preflight
|
||||||
|
|
||||||
`20260731000000_add_npc_selection_token`은 `general.user_id` 중복을 발견하면
|
`20260731000000_add_npc_selection_token`은 `general.user_id` 중복을 발견하면
|
||||||
|
|||||||
@@ -0,0 +1,596 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
: "${GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL:?GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL is required}"
|
||||||
|
|
||||||
|
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
package_dir=$(dirname "$script_dir")
|
||||||
|
prisma_dir="$package_dir/prisma"
|
||||||
|
target_migration=20260824070000_game_outbox_utc_wall_timestamps
|
||||||
|
run_id=$(date -u +%m%d%H%M%S)_$$
|
||||||
|
schema_name="game_outbox_utc_upgrade_$run_id"
|
||||||
|
ownership_token="sammo-game-outbox-utc-migration:$run_id"
|
||||||
|
work_dir=$(mktemp -d "$package_dir/.game-outbox-utc-migration.XXXXXX")
|
||||||
|
|
||||||
|
case "$schema_name" in
|
||||||
|
game_outbox_utc_upgrade_[0-9]*_[0-9]*) ;;
|
||||||
|
*)
|
||||||
|
echo "unsafe outbox migration schema name" >&2
|
||||||
|
exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
cleanup_status=0
|
||||||
|
OWNERSHIP_TOKEN=$ownership_token \
|
||||||
|
SCHEMA_NAME=$schema_name \
|
||||||
|
DATABASE_URL=$GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL \
|
||||||
|
pnpm --dir "$package_dir" exec node --input-type=module -e '
|
||||||
|
import pg from "pg";
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const ownership = await client.query(
|
||||||
|
"SELECT obj_description(oid, $$pg_namespace$$) AS owner FROM pg_namespace WHERE nspname = $1",
|
||||||
|
[process.env.SCHEMA_NAME]
|
||||||
|
);
|
||||||
|
if (ownership.rowCount > 0) {
|
||||||
|
if (ownership.rows[0]?.owner !== process.env.OWNERSHIP_TOKEN) {
|
||||||
|
throw new Error(`refusing to drop unowned schema: ${process.env.SCHEMA_NAME}`);
|
||||||
|
}
|
||||||
|
await client.query(`DROP SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)} CASCADE`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
' >/dev/null 2>&1 || cleanup_status=1
|
||||||
|
case "$work_dir" in
|
||||||
|
"$package_dir"/.game-outbox-utc-migration.*)
|
||||||
|
rm -r -- "$work_dir" || cleanup_status=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "refusing to remove unsafe outbox migration work directory: $work_dir" >&2
|
||||||
|
cleanup_status=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
return "$cleanup_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_exit() {
|
||||||
|
exit_status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
if ! cleanup && [ "$exit_status" -eq 0 ]; then
|
||||||
|
exit_status=1
|
||||||
|
fi
|
||||||
|
exit "$exit_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap handle_exit EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
[ -d "$prisma_dir/migrations/$target_migration" ] || {
|
||||||
|
echo "target migration is missing: $target_migration" >&2
|
||||||
|
exit 66
|
||||||
|
}
|
||||||
|
|
||||||
|
build_database_url() {
|
||||||
|
SCHEMA_NAME=$schema_name DATABASE_URL=$GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL \
|
||||||
|
pnpm --dir "$package_dir" exec node --input-type=module -e '
|
||||||
|
const url = new URL(process.env.DATABASE_URL);
|
||||||
|
url.searchParams.set("schema", process.env.SCHEMA_NAME);
|
||||||
|
// The migration must reinterpret the predecessor DB-default timestamps
|
||||||
|
// from the real legacy KST session contract, independently of host TZ.
|
||||||
|
url.searchParams.set("options", "-c TimeZone=Asia/Seoul");
|
||||||
|
process.stdout.write(url.href);
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
database_url=$(build_database_url)
|
||||||
|
|
||||||
|
OWNERSHIP_TOKEN=$ownership_token \
|
||||||
|
SCHEMA_NAME=$schema_name \
|
||||||
|
DATABASE_URL=$GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL \
|
||||||
|
pnpm --dir "$package_dir" exec node --input-type=module -e '
|
||||||
|
import pg from "pg";
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||||
|
const apostrophe = String.fromCharCode(39);
|
||||||
|
const quoteLiteral = (value) =>
|
||||||
|
`${apostrophe}${value.replaceAll(apostrophe, apostrophe.repeat(2))}${apostrophe}`;
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(`CREATE SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||||
|
await client.query(
|
||||||
|
`COMMENT ON SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)} IS ${quoteLiteral(
|
||||||
|
process.env.OWNERSHIP_TOKEN
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
'
|
||||||
|
|
||||||
|
stage_prisma="$work_dir/prisma"
|
||||||
|
mkdir -p "$stage_prisma/migrations"
|
||||||
|
cp "$prisma_dir/game.prisma" "$stage_prisma/game.prisma"
|
||||||
|
found_target=0
|
||||||
|
for migration_dir in "$prisma_dir"/migrations/[0-9]*; do
|
||||||
|
migration_name=$(basename "$migration_dir")
|
||||||
|
if [ "$migration_name" = "$target_migration" ]; then
|
||||||
|
found_target=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
cp -R "$migration_dir" "$stage_prisma/migrations/$migration_name"
|
||||||
|
done
|
||||||
|
[ "$found_target" -eq 1 ] || {
|
||||||
|
echo "target migration was not found in migration order" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cd "$package_dir"
|
||||||
|
if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$stage_prisma/game.prisma" \
|
||||||
|
pnpm exec prisma migrate deploy --schema "$stage_prisma/game.prisma" \
|
||||||
|
>"$work_dir/predecessor-deploy.log" 2>&1; then
|
||||||
|
echo "failed to deploy the predecessor game migration chain" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCHEMA_NAME=$schema_name DATABASE_URL=$database_url \
|
||||||
|
pnpm exec node --input-type=module -e '
|
||||||
|
import pg from "pg";
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const timezone = await client.query("SHOW TimeZone");
|
||||||
|
if (timezone.rows[0]?.TimeZone !== "Asia/Seoul") {
|
||||||
|
throw new Error(`expected Asia/Seoul fixture session, received ${timezone.rows[0]?.TimeZone}`);
|
||||||
|
}
|
||||||
|
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE "_game_outbox_utc_upgrade_probe" (
|
||||||
|
"id" INTEGER PRIMARY KEY,
|
||||||
|
"before_utc" TIMESTAMP(3) NOT NULL,
|
||||||
|
"read_pending_created_before" TIMESTAMP(3)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO "_game_outbox_utc_upgrade_probe" ("id", "before_utc")
|
||||||
|
VALUES (1, CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$)
|
||||||
|
`);
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO "read_model_outbox" (
|
||||||
|
"id", "payload", "attempts", "available_at", "locked_at",
|
||||||
|
"lock_owner", "delivered_at", "last_error", "created_at"
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
910001,
|
||||||
|
$json$ {"version":1,"changes":[["general",101,"2"]],"fixture":"read-pending"} $json$::jsonb,
|
||||||
|
2,
|
||||||
|
TIMESTAMP $$2030-01-02 03:04:05.111$$,
|
||||||
|
TIMESTAMP $$2026-08-24 16:01:02.222$$,
|
||||||
|
$$legacy-read-worker$$,
|
||||||
|
NULL,
|
||||||
|
$$read retry$$,
|
||||||
|
DEFAULT
|
||||||
|
),
|
||||||
|
(
|
||||||
|
910002,
|
||||||
|
$json$ {"version":1,"changes":[["nation",7,"3"]],"fixture":"read-delivered"} $json$::jsonb,
|
||||||
|
3,
|
||||||
|
TIMESTAMP $$2026-08-24 15:00:00.333$$,
|
||||||
|
TIMESTAMP $$2026-08-24 15:01:00.444$$,
|
||||||
|
$$delivered-read-worker$$,
|
||||||
|
TIMESTAMP $$2026-08-24 15:02:00.555$$,
|
||||||
|
$$delivered read marker$$,
|
||||||
|
TIMESTAMP $$2026-08-24 16:10:00.789$$
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO "web_push_outbox" (
|
||||||
|
"id", "event_id", "event_type", "user_ids", "year", "month",
|
||||||
|
"attempts", "available_at", "locked_at", "lock_owner",
|
||||||
|
"delivered_at", "last_error", "created_at"
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
920001,
|
||||||
|
$$upgrade-web-pending$$,
|
||||||
|
$$PRIVATE_MESSAGE_RECEIVED$$,
|
||||||
|
ARRAY[$$user-b$$, $$user-a$$],
|
||||||
|
201,
|
||||||
|
7,
|
||||||
|
4,
|
||||||
|
TIMESTAMP $$2030-02-03 04:05:06.222$$,
|
||||||
|
TIMESTAMP $$2026-08-24 07:02:03.333$$,
|
||||||
|
$$legacy-web-worker$$,
|
||||||
|
NULL,
|
||||||
|
$$web retry$$,
|
||||||
|
TIMESTAMP $$2026-08-24 07:00:00.456$$
|
||||||
|
),
|
||||||
|
(
|
||||||
|
920002,
|
||||||
|
$$upgrade-web-delivered$$,
|
||||||
|
$$MONTH_CHANGED$$,
|
||||||
|
ARRAY[$$user-c$$],
|
||||||
|
202,
|
||||||
|
8,
|
||||||
|
5,
|
||||||
|
TIMESTAMP $$2026-08-24 07:10:00.333$$,
|
||||||
|
TIMESTAMP $$2026-08-24 07:11:00.444$$,
|
||||||
|
$$delivered-web-worker$$,
|
||||||
|
TIMESTAMP $$2026-08-24 07:12:00.555$$,
|
||||||
|
$$delivered web marker$$,
|
||||||
|
TIMESTAMP $$2026-08-24 07:09:00.789$$
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await client.query(`
|
||||||
|
UPDATE "_game_outbox_utc_upgrade_probe"
|
||||||
|
SET "read_pending_created_before" = (
|
||||||
|
SELECT "created_at" FROM "read_model_outbox" WHERE "id" = 910001
|
||||||
|
)
|
||||||
|
WHERE "id" = 1
|
||||||
|
`);
|
||||||
|
const readProvenance = await client.query(`
|
||||||
|
SELECT read."created_at" = probe."before_utc" + INTERVAL $$9 hours$$ AS "isKstDefault"
|
||||||
|
FROM "read_model_outbox" read
|
||||||
|
CROSS JOIN "_game_outbox_utc_upgrade_probe" probe
|
||||||
|
WHERE read."id" = 910001
|
||||||
|
`);
|
||||||
|
if (readProvenance.rows[0]?.isKstDefault !== true) {
|
||||||
|
throw new Error("predecessor read-model created_at did not use the KST database default");
|
||||||
|
}
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
'
|
||||||
|
|
||||||
|
if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \
|
||||||
|
pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" \
|
||||||
|
>"$work_dir/incremental-deploy.log" 2>&1; then
|
||||||
|
echo "failed to deploy the outbox UTC-wall migration" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCHEMA_NAME=$schema_name \
|
||||||
|
TARGET_MIGRATION=$target_migration \
|
||||||
|
MIGRATION_FILE="$prisma_dir/migrations/$target_migration/migration.sql" \
|
||||||
|
DATABASE_URL=$database_url \
|
||||||
|
pnpm exec node --input-type=module -e '
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||||
|
const canonicalize = (value) => {
|
||||||
|
if (Array.isArray(value)) return value.map(canonicalize);
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, entry]) => [key, canonicalize(entry)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const assertEqual = (actual, expected, label) => {
|
||||||
|
if (JSON.stringify(canonicalize(actual)) !== JSON.stringify(canonicalize(expected))) {
|
||||||
|
throw new Error(`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||||
|
|
||||||
|
const readRows = await client.query(`
|
||||||
|
SELECT
|
||||||
|
"read_model_outbox"."id"::int AS "id",
|
||||||
|
"payload",
|
||||||
|
"attempts",
|
||||||
|
to_char("available_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "availableAt",
|
||||||
|
CASE WHEN "locked_at" IS NULL THEN NULL
|
||||||
|
ELSE to_char("locked_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "lockedAt",
|
||||||
|
"lock_owner" AS "lockOwner",
|
||||||
|
CASE WHEN "delivered_at" IS NULL THEN NULL
|
||||||
|
ELSE to_char("delivered_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "deliveredAt",
|
||||||
|
"last_error" AS "lastError",
|
||||||
|
to_char("created_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "createdAt",
|
||||||
|
"created_at" = (
|
||||||
|
probe."read_pending_created_before" AT TIME ZONE $$Asia/Seoul$$
|
||||||
|
) AT TIME ZONE $$UTC$$ AS "createdNormalized",
|
||||||
|
"available_at" >= probe."before_utc" - INTERVAL $$1 second$$
|
||||||
|
AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$
|
||||||
|
AS "availableInMigrationWindow"
|
||||||
|
FROM "read_model_outbox"
|
||||||
|
CROSS JOIN "_game_outbox_utc_upgrade_probe" probe
|
||||||
|
WHERE "read_model_outbox"."id" IN (910001, 910002)
|
||||||
|
ORDER BY "read_model_outbox"."id"
|
||||||
|
`);
|
||||||
|
const [readPending, readDelivered] = readRows.rows;
|
||||||
|
if (!readPending || !readDelivered) throw new Error("read-model upgrade fixtures are missing");
|
||||||
|
assertEqual(
|
||||||
|
{
|
||||||
|
id: readPending.id,
|
||||||
|
payload: readPending.payload,
|
||||||
|
attempts: readPending.attempts,
|
||||||
|
lockedAt: readPending.lockedAt,
|
||||||
|
lockOwner: readPending.lockOwner,
|
||||||
|
deliveredAt: readPending.deliveredAt,
|
||||||
|
lastError: readPending.lastError,
|
||||||
|
createdNormalized: readPending.createdNormalized,
|
||||||
|
availableInMigrationWindow: readPending.availableInMigrationWindow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 910001,
|
||||||
|
payload: { version: 1, changes: [["general", 101, "2"]], fixture: "read-pending" },
|
||||||
|
attempts: 2,
|
||||||
|
lockedAt: null,
|
||||||
|
lockOwner: null,
|
||||||
|
deliveredAt: null,
|
||||||
|
lastError: "read retry",
|
||||||
|
createdNormalized: true,
|
||||||
|
availableInMigrationWindow: true,
|
||||||
|
},
|
||||||
|
"pending read-model row"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
{
|
||||||
|
id: readDelivered.id,
|
||||||
|
payload: readDelivered.payload,
|
||||||
|
attempts: readDelivered.attempts,
|
||||||
|
availableAt: readDelivered.availableAt,
|
||||||
|
lockedAt: readDelivered.lockedAt,
|
||||||
|
lockOwner: readDelivered.lockOwner,
|
||||||
|
deliveredAt: readDelivered.deliveredAt,
|
||||||
|
lastError: readDelivered.lastError,
|
||||||
|
createdAt: readDelivered.createdAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 910002,
|
||||||
|
payload: { version: 1, changes: [["nation", 7, "3"]], fixture: "read-delivered" },
|
||||||
|
attempts: 3,
|
||||||
|
availableAt: "2026-08-24 15:00:00.333",
|
||||||
|
lockedAt: "2026-08-24 15:01:00.444",
|
||||||
|
lockOwner: "delivered-read-worker",
|
||||||
|
deliveredAt: "2026-08-24 15:02:00.555",
|
||||||
|
lastError: "delivered read marker",
|
||||||
|
createdAt: "2026-08-24 07:10:00.789",
|
||||||
|
},
|
||||||
|
"delivered read-model row"
|
||||||
|
);
|
||||||
|
|
||||||
|
const webRows = await client.query(`
|
||||||
|
SELECT
|
||||||
|
"web_push_outbox"."id"::int AS "id",
|
||||||
|
"event_id" AS "eventId",
|
||||||
|
"event_type" AS "eventType",
|
||||||
|
"user_ids" AS "userIds",
|
||||||
|
"year",
|
||||||
|
"month",
|
||||||
|
"attempts",
|
||||||
|
to_char("available_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "availableAt",
|
||||||
|
CASE WHEN "locked_at" IS NULL THEN NULL
|
||||||
|
ELSE to_char("locked_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "lockedAt",
|
||||||
|
"lock_owner" AS "lockOwner",
|
||||||
|
CASE WHEN "delivered_at" IS NULL THEN NULL
|
||||||
|
ELSE to_char("delivered_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "deliveredAt",
|
||||||
|
"last_error" AS "lastError",
|
||||||
|
to_char("created_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "createdAt",
|
||||||
|
"available_at" >= probe."before_utc" - INTERVAL $$1 second$$
|
||||||
|
AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$
|
||||||
|
AS "availableInMigrationWindow"
|
||||||
|
FROM "web_push_outbox"
|
||||||
|
CROSS JOIN "_game_outbox_utc_upgrade_probe" probe
|
||||||
|
WHERE "web_push_outbox"."id" IN (920001, 920002)
|
||||||
|
ORDER BY "web_push_outbox"."id"
|
||||||
|
`);
|
||||||
|
const [webPending, webDelivered] = webRows.rows;
|
||||||
|
if (!webPending || !webDelivered) throw new Error("web-push upgrade fixtures are missing");
|
||||||
|
assertEqual(
|
||||||
|
{
|
||||||
|
id: webPending.id,
|
||||||
|
eventId: webPending.eventId,
|
||||||
|
eventType: webPending.eventType,
|
||||||
|
userIds: webPending.userIds,
|
||||||
|
year: webPending.year,
|
||||||
|
month: webPending.month,
|
||||||
|
attempts: webPending.attempts,
|
||||||
|
lockedAt: webPending.lockedAt,
|
||||||
|
lockOwner: webPending.lockOwner,
|
||||||
|
deliveredAt: webPending.deliveredAt,
|
||||||
|
lastError: webPending.lastError,
|
||||||
|
createdAt: webPending.createdAt,
|
||||||
|
availableInMigrationWindow: webPending.availableInMigrationWindow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 920001,
|
||||||
|
eventId: "upgrade-web-pending",
|
||||||
|
eventType: "PRIVATE_MESSAGE_RECEIVED",
|
||||||
|
userIds: ["user-b", "user-a"],
|
||||||
|
year: 201,
|
||||||
|
month: 7,
|
||||||
|
attempts: 4,
|
||||||
|
lockedAt: null,
|
||||||
|
lockOwner: null,
|
||||||
|
deliveredAt: null,
|
||||||
|
lastError: "web retry",
|
||||||
|
createdAt: "2026-08-24 07:00:00.456",
|
||||||
|
availableInMigrationWindow: true,
|
||||||
|
},
|
||||||
|
"pending web-push row"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
{
|
||||||
|
id: webDelivered.id,
|
||||||
|
eventId: webDelivered.eventId,
|
||||||
|
eventType: webDelivered.eventType,
|
||||||
|
userIds: webDelivered.userIds,
|
||||||
|
year: webDelivered.year,
|
||||||
|
month: webDelivered.month,
|
||||||
|
attempts: webDelivered.attempts,
|
||||||
|
availableAt: webDelivered.availableAt,
|
||||||
|
lockedAt: webDelivered.lockedAt,
|
||||||
|
lockOwner: webDelivered.lockOwner,
|
||||||
|
deliveredAt: webDelivered.deliveredAt,
|
||||||
|
lastError: webDelivered.lastError,
|
||||||
|
createdAt: webDelivered.createdAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 920002,
|
||||||
|
eventId: "upgrade-web-delivered",
|
||||||
|
eventType: "MONTH_CHANGED",
|
||||||
|
userIds: ["user-c"],
|
||||||
|
year: 202,
|
||||||
|
month: 8,
|
||||||
|
attempts: 5,
|
||||||
|
availableAt: "2026-08-24 07:10:00.333",
|
||||||
|
lockedAt: "2026-08-24 07:11:00.444",
|
||||||
|
lockOwner: "delivered-web-worker",
|
||||||
|
deliveredAt: "2026-08-24 07:12:00.555",
|
||||||
|
lastError: "delivered web marker",
|
||||||
|
createdAt: "2026-08-24 07:09:00.789",
|
||||||
|
},
|
||||||
|
"delivered web-push row"
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = await client.query(`
|
||||||
|
SELECT table_name AS "tableName", column_name AS "columnName",
|
||||||
|
data_type AS "dataType", datetime_precision AS "precision"
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = $1
|
||||||
|
AND table_name IN ($$read_model_outbox$$, $$web_push_outbox$$)
|
||||||
|
AND column_name IN ($$available_at$$, $$locked_at$$, $$delivered_at$$, $$created_at$$)
|
||||||
|
ORDER BY table_name, column_name
|
||||||
|
`, [process.env.SCHEMA_NAME]);
|
||||||
|
if (columns.rowCount !== 8) throw new Error(`expected eight outbox timestamp columns, received ${columns.rowCount}`);
|
||||||
|
for (const column of columns.rows) {
|
||||||
|
if (column.dataType !== "timestamp without time zone" || column.precision !== 3) {
|
||||||
|
throw new Error(`rollback-incompatible timestamp column: ${JSON.stringify(column)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedChecksum = createHash("sha256")
|
||||||
|
.update(await readFile(process.env.MIGRATION_FILE))
|
||||||
|
.digest("hex");
|
||||||
|
const history = await client.query(`
|
||||||
|
SELECT checksum, finished_at IS NOT NULL AS finished,
|
||||||
|
rolled_back_at IS NULL AS "notRolledBack", applied_steps_count AS steps
|
||||||
|
FROM "_prisma_migrations"
|
||||||
|
WHERE migration_name = $1
|
||||||
|
`, [process.env.TARGET_MIGRATION]);
|
||||||
|
assertEqual(history.rows, [{ checksum: expectedChecksum, finished: true, notRolledBack: true, steps: 1 }], "migration history");
|
||||||
|
|
||||||
|
// This is the rollback-compatibility boundary: the predecessor runtime
|
||||||
|
// DML shapes and TIMESTAMP(3) mappings remain accepted. It deliberately
|
||||||
|
// does not claim a down migration or execute a separately built old binary.
|
||||||
|
const rollbackProbe = await client.query(`
|
||||||
|
WITH inserted_read AS (
|
||||||
|
INSERT INTO "read_model_outbox" ("payload")
|
||||||
|
VALUES ($json$ {"version":1,"changes":[],"fixture":"old-read-shape"} $json$::jsonb)
|
||||||
|
RETURNING "created_at", "available_at"
|
||||||
|
), inserted_web AS (
|
||||||
|
INSERT INTO "web_push_outbox" (
|
||||||
|
"event_id", "event_type", "user_ids", "available_at", "created_at"
|
||||||
|
) VALUES (
|
||||||
|
$$upgrade-old-web-shape$$,
|
||||||
|
$$PRIVATE_MESSAGE_RECEIVED$$,
|
||||||
|
ARRAY[]::TEXT[],
|
||||||
|
CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$,
|
||||||
|
CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$
|
||||||
|
)
|
||||||
|
RETURNING "created_at", "available_at"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
inserted_read."created_at" BETWEEN
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "readCreatedUtc",
|
||||||
|
inserted_read."available_at" BETWEEN
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "readAvailableUtc",
|
||||||
|
inserted_web."created_at" BETWEEN
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "webCreatedUtc",
|
||||||
|
inserted_web."available_at" BETWEEN
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND
|
||||||
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "webAvailableUtc"
|
||||||
|
FROM inserted_read CROSS JOIN inserted_web
|
||||||
|
`);
|
||||||
|
assertEqual(
|
||||||
|
rollbackProbe.rows,
|
||||||
|
[{ readCreatedUtc: true, readAvailableUtc: true, webCreatedUtc: true, webAvailableUtc: true }],
|
||||||
|
"predecessor runtime DML compatibility"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
'
|
||||||
|
|
||||||
|
if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \
|
||||||
|
pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" \
|
||||||
|
>"$work_dir/noop-deploy.log" 2>&1; then
|
||||||
|
echo "second outbox UTC-wall migration deploy failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -Fq 'No pending migrations to apply' "$work_dir/noop-deploy.log"
|
||||||
|
|
||||||
|
if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \
|
||||||
|
pnpm exec prisma migrate status --schema "$prisma_dir/game.prisma" \
|
||||||
|
>"$work_dir/status.log" 2>&1; then
|
||||||
|
echo "outbox UTC-wall migration status is not clean" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -Fq 'Database schema is up to date' "$work_dir/status.log"
|
||||||
|
|
||||||
|
SCHEMA_NAME=$schema_name \
|
||||||
|
TARGET_MIGRATION=$target_migration \
|
||||||
|
MIGRATION_FILE="$prisma_dir/migrations/$target_migration/migration.sql" \
|
||||||
|
DATABASE_URL=$database_url \
|
||||||
|
pnpm exec node --input-type=module -e '
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import pg from "pg";
|
||||||
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const expectedChecksum = createHash("sha256")
|
||||||
|
.update(await readFile(process.env.MIGRATION_FILE))
|
||||||
|
.digest("hex");
|
||||||
|
const result = await client.query(`
|
||||||
|
SELECT count(*)::int AS count,
|
||||||
|
bool_and(checksum = $2) AS "checksumMatches",
|
||||||
|
bool_and(finished_at IS NOT NULL) AS finished,
|
||||||
|
bool_and(rolled_back_at IS NULL) AS "notRolledBack",
|
||||||
|
sum(applied_steps_count)::int AS steps
|
||||||
|
FROM ${`"${process.env.SCHEMA_NAME.replaceAll("\"", "\"\"")}"`}."_prisma_migrations"
|
||||||
|
WHERE migration_name = $1
|
||||||
|
`, [process.env.TARGET_MIGRATION, expectedChecksum]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (
|
||||||
|
row?.count !== 1 ||
|
||||||
|
row.checksumMatches !== true ||
|
||||||
|
row.finished !== true ||
|
||||||
|
row.notRolledBack !== true ||
|
||||||
|
row.steps !== 1
|
||||||
|
) {
|
||||||
|
throw new Error(`unexpected target migration history after no-op deploy: ${JSON.stringify(row)}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
'
|
||||||
|
|
||||||
|
echo "Game outbox UTC-wall populated upgrade, pending requeue, delivered preservation, checksum, no-op, and predecessor DML compatibility passed"
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
dispatchReadModelOutboxBatch,
|
dispatchReadModelOutboxBatch,
|
||||||
pruneDeliveredReadModelOutbox,
|
pruneDeliveredReadModelOutbox,
|
||||||
} from '../src/readModelOutboxDispatcher.js';
|
} from '../src/readModelOutboxDispatcher.js';
|
||||||
|
import { enqueueWebPushOutboxEvents } from '../src/webPushOutbox.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.READ_MODEL_JOURNAL_DATABASE_URL;
|
const databaseUrl = process.env.READ_MODEL_JOURNAL_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
@@ -26,7 +27,9 @@ integration('read-model outbox PostgreSQL delivery boundary', () => {
|
|||||||
afterAll(async () => disconnect?.());
|
afterAll(async () => disconnect?.());
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await prisma.$executeRaw`TRUNCATE TABLE "read_model_outbox", "read_model_revision" RESTART IDENTITY`;
|
await prisma.$executeRaw`
|
||||||
|
TRUNCATE TABLE "read_model_outbox", "read_model_revision", "web_push_outbox" RESTART IDENTITY
|
||||||
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const enqueue = async (entityId: number): Promise<void> => {
|
const enqueue = async (entityId: number): Promise<void> => {
|
||||||
@@ -47,6 +50,93 @@ integration('read-model outbox PostgreSQL delivery boundary', () => {
|
|||||||
expect(new Set(ids).size).toBe(20);
|
expect(new Set(ids).size).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps both game outbox defaults claimable as current instants in a non-UTC session', async () => {
|
||||||
|
const beforeInsert = Date.now();
|
||||||
|
await prisma.$transaction(async (transaction) => {
|
||||||
|
await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`;
|
||||||
|
await writeReadModelChangeJournal(transaction, [{ domain: 'general.content', entityId: 77 }]);
|
||||||
|
await enqueueWebPushOutboxEvents(transaction, [
|
||||||
|
{
|
||||||
|
eventId: 'non-utc-default-instant',
|
||||||
|
eventType: 'PRIVATE_MESSAGE_RECEIVED',
|
||||||
|
userIds: ['outbox-timezone-user'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
const afterInsert = Date.now();
|
||||||
|
|
||||||
|
const readModelRow = await prisma.readModelOutbox.findFirstOrThrow();
|
||||||
|
const webPushRow = await prisma.webPushOutbox.findFirstOrThrow();
|
||||||
|
for (const [label, instant] of Object.entries({
|
||||||
|
readModelAvailableAt: readModelRow.availableAt,
|
||||||
|
readModelCreatedAt: readModelRow.createdAt,
|
||||||
|
webPushAvailableAt: webPushRow.availableAt,
|
||||||
|
webPushCreatedAt: webPushRow.createdAt,
|
||||||
|
})) {
|
||||||
|
expect(instant.getTime(), label).toBeGreaterThanOrEqual(beforeInsert - 1_000);
|
||||||
|
expect(instant.getTime(), label).toBeLessThanOrEqual(afterInsert + 1_000);
|
||||||
|
}
|
||||||
|
const [storedDefaults] = await prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
|
readAvailableMs: number;
|
||||||
|
readCreatedMs: number;
|
||||||
|
webAvailableMs: number;
|
||||||
|
webCreatedMs: number;
|
||||||
|
}>
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
(SELECT (EXTRACT(EPOCH FROM "available_at") * 1000)::double precision
|
||||||
|
FROM "read_model_outbox" LIMIT 1) AS "readAvailableMs",
|
||||||
|
(SELECT (EXTRACT(EPOCH FROM "created_at") * 1000)::double precision
|
||||||
|
FROM "read_model_outbox" LIMIT 1) AS "readCreatedMs",
|
||||||
|
(SELECT (EXTRACT(EPOCH FROM "available_at") * 1000)::double precision
|
||||||
|
FROM "web_push_outbox" LIMIT 1) AS "webAvailableMs",
|
||||||
|
(SELECT (EXTRACT(EPOCH FROM "created_at") * 1000)::double precision
|
||||||
|
FROM "web_push_outbox" LIMIT 1) AS "webCreatedMs"
|
||||||
|
`;
|
||||||
|
if (!storedDefaults) throw new Error('outbox UTC-wall defaults were not persisted');
|
||||||
|
for (const [label, instantMs] of Object.entries(storedDefaults)) {
|
||||||
|
expect(instantMs, label).toBeGreaterThanOrEqual(beforeInsert - 1_000);
|
||||||
|
expect(instantMs, label).toBeLessThanOrEqual(afterInsert + 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockAt = new Date();
|
||||||
|
const retryAt = new Date(lockAt.getTime() + 60_000);
|
||||||
|
await prisma.webPushOutbox.update({
|
||||||
|
where: { eventId: 'non-utc-default-instant' },
|
||||||
|
data: { availableAt: retryAt, lockedAt: lockAt, lockOwner: 'non-utc-session-worker' },
|
||||||
|
});
|
||||||
|
const [webPushSchedule] = await prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
|
availableMs: number;
|
||||||
|
lockedMs: number;
|
||||||
|
due: boolean;
|
||||||
|
leaseExpired: boolean;
|
||||||
|
}>
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
(EXTRACT(EPOCH FROM "available_at") * 1000)::double precision AS "availableMs",
|
||||||
|
(EXTRACT(EPOCH FROM "locked_at") * 1000)::double precision AS "lockedMs",
|
||||||
|
"available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS "due",
|
||||||
|
"locked_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds'
|
||||||
|
AS "leaseExpired"
|
||||||
|
FROM "web_push_outbox"
|
||||||
|
WHERE "event_id" = 'non-utc-default-instant'
|
||||||
|
`;
|
||||||
|
if (!webPushSchedule) throw new Error('web push UTC-wall schedule was not persisted');
|
||||||
|
expect(webPushSchedule).toMatchObject({ due: false, leaseExpired: false });
|
||||||
|
expect(Math.abs(webPushSchedule.availableMs - retryAt.getTime())).toBeLessThanOrEqual(1);
|
||||||
|
expect(Math.abs(webPushSchedule.lockedMs - lockAt.getTime())).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
claimReadModelOutboxBatch(prisma, {
|
||||||
|
owner: 'non-utc-session-worker',
|
||||||
|
limit: 1,
|
||||||
|
now: new Date(afterInsert + 1_000),
|
||||||
|
})
|
||||||
|
).resolves.toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('releases a publish failure and later delivers the same row', async () => {
|
it('releases a publish failure and later delivers the same row', async () => {
|
||||||
await enqueue(7);
|
await enqueue(7);
|
||||||
const failedAt = new Date('2099-08-16T00:00:00.000Z');
|
const failedAt = new Date('2099-08-16T00:00:00.000Z');
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260823010000_add_web_push_notifications",
|
"gatewaySchemaHead": "20260823010000_add_web_push_notifications",
|
||||||
"gameSchemaHead": "20260823010000_add_web_push_outbox",
|
"gameSchemaHead": "20260824080000_vote_utc_wall_timestamps",
|
||||||
"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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession
|
|||||||
PROFILE_SEED_CLI_DATABASE_URL core
|
PROFILE_SEED_CLI_DATABASE_URL core
|
||||||
PROFILE_SEED_DATABASE_URL core
|
PROFILE_SEED_DATABASE_URL core
|
||||||
PROFILE_LOCK_SECONDARY_DATABASE_URL core
|
PROFILE_LOCK_SECONDARY_DATABASE_URL core
|
||||||
|
PROFILE_MIGRATION_UTC_DATABASE_URL external_fixture
|
||||||
READ_MODEL_JOURNAL_DATABASE_URL read_model_journal
|
READ_MODEL_JOURNAL_DATABASE_URL read_model_journal
|
||||||
RESERVED_TURN_DATABASE_URL core
|
RESERVED_TURN_DATABASE_URL core
|
||||||
|
SECURITY_TRANSPORT_DATABASE_URL security_transport
|
||||||
SELECT_POOL_DATABASE_URL select_pool
|
SELECT_POOL_DATABASE_URL select_pool
|
||||||
|
SCENARIO_LIFECYCLE_DATABASE_URL scenario_lifecycle
|
||||||
TURN_DAEMON_LEASE_DATABASE_URL core
|
TURN_DAEMON_LEASE_DATABASE_URL core
|
||||||
|
TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL reference_command_durable_matrix
|
||||||
TURN_DIFFERENTIAL_DATABASE_URL core
|
TURN_DIFFERENTIAL_DATABASE_URL core
|
||||||
TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle
|
TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle
|
||||||
WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway
|
WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway
|
||||||
|
|||||||
|
@@ -127,6 +127,11 @@ const readString = (record: Record<string, unknown>, key: string): string | null
|
|||||||
return typeof value === 'string' ? value : null;
|
return typeof value === 'string' ? value : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readNullableCode = (record: Record<string, unknown>, key: string): string | null => {
|
||||||
|
const value = readString(record, key);
|
||||||
|
return value && value !== 'None' ? value : null;
|
||||||
|
};
|
||||||
|
|
||||||
const readCommandInteger = (value: unknown, field: string, fallback: number | null): number | null => {
|
const readCommandInteger = (value: unknown, field: string, fallback: number | null): number | null => {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -388,13 +393,13 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
|||||||
maxBelong: readNumber(meta, 'max_belong'),
|
maxBelong: readNumber(meta, 'max_belong'),
|
||||||
maxDomesticCritical: readNumber(meta, 'max_domestic_critical'),
|
maxDomesticCritical: readNumber(meta, 'max_domestic_critical'),
|
||||||
betray: row.betray,
|
betray: row.betray,
|
||||||
personality: row.personality ?? null,
|
personality: readNullableCode(row, 'personality') ?? readNullableCode(row, 'personalCode'),
|
||||||
specialDomestic: row.specialDomestic ?? null,
|
specialDomestic: readNullableCode(row, 'specialDomestic') ?? readNullableCode(row, 'specialCode'),
|
||||||
specialWar: row.specialWar ?? null,
|
specialWar: readNullableCode(row, 'specialWar') ?? readNullableCode(row, 'special2Code'),
|
||||||
itemHorse: row.itemHorse ?? null,
|
itemHorse: readNullableCode(row, 'itemHorse') ?? readNullableCode(row, 'horseCode'),
|
||||||
itemWeapon: row.itemWeapon ?? null,
|
itemWeapon: readNullableCode(row, 'itemWeapon') ?? readNullableCode(row, 'weaponCode'),
|
||||||
itemBook: row.itemBook ?? null,
|
itemBook: readNullableCode(row, 'itemBook') ?? readNullableCode(row, 'bookCode'),
|
||||||
itemExtra: row.itemExtra ?? null,
|
itemExtra: readNullableCode(row, 'itemExtra') ?? readNullableCode(row, 'itemCode'),
|
||||||
picture: row.picture ?? null,
|
picture: row.picture ?? null,
|
||||||
imageServer: readNumber(row, 'imageServer'),
|
imageServer: readNumber(row, 'imageServer'),
|
||||||
injury: row.injury,
|
injury: row.injury,
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import type { GeneralTurnCommandKey, NationTurnCommandKey } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
export type CommandDurabilityRisk = 'R1' | 'R2' | 'R3';
|
||||||
|
export type CommandDurabilityScope = 'general' | 'nation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R1 mutates the actor or one aggregate, R2 crosses an actor/aggregate boundary,
|
||||||
|
* and R3 can fan out, create/delete entities, fight, or mutate relationships.
|
||||||
|
* The typed records deliberately fail compilation when a command is added
|
||||||
|
* without an explicit durability classification.
|
||||||
|
*/
|
||||||
|
export const generalCommandDurabilityRisk = {
|
||||||
|
che_거병: 'R3',
|
||||||
|
che_임관: 'R3',
|
||||||
|
che_랜덤임관: 'R3',
|
||||||
|
che_귀환: 'R1',
|
||||||
|
che_등용수락: 'R3',
|
||||||
|
che_장수대상임관: 'R3',
|
||||||
|
che_건국: 'R3',
|
||||||
|
cr_건국: 'R3',
|
||||||
|
che_무작위건국: 'R3',
|
||||||
|
che_훈련: 'R1',
|
||||||
|
cr_맹훈련: 'R1',
|
||||||
|
che_전투태세: 'R1',
|
||||||
|
che_단련: 'R1',
|
||||||
|
che_숙련전환: 'R1',
|
||||||
|
che_사기진작: 'R1',
|
||||||
|
che_요양: 'R1',
|
||||||
|
che_견문: 'R1',
|
||||||
|
che_장비매매: 'R2',
|
||||||
|
che_내정특기초기화: 'R1',
|
||||||
|
che_전투특기초기화: 'R1',
|
||||||
|
che_출병: 'R3',
|
||||||
|
che_주민선정: 'R2',
|
||||||
|
che_정착장려: 'R2',
|
||||||
|
che_농지개간: 'R2',
|
||||||
|
che_상업투자: 'R2',
|
||||||
|
che_기술연구: 'R2',
|
||||||
|
che_치안강화: 'R2',
|
||||||
|
che_수비강화: 'R2',
|
||||||
|
che_성벽보수: 'R2',
|
||||||
|
che_화계: 'R3',
|
||||||
|
che_집합: 'R3',
|
||||||
|
che_인재탐색: 'R3',
|
||||||
|
che_징병: 'R2',
|
||||||
|
che_모병: 'R2',
|
||||||
|
che_소집해제: 'R2',
|
||||||
|
che_군량매매: 'R2',
|
||||||
|
che_물자조달: 'R2',
|
||||||
|
che_헌납: 'R2',
|
||||||
|
che_이동: 'R3',
|
||||||
|
che_접경귀환: 'R1',
|
||||||
|
che_방랑: 'R3',
|
||||||
|
che_하야: 'R3',
|
||||||
|
che_은퇴: 'R3',
|
||||||
|
che_선양: 'R3',
|
||||||
|
che_모반시도: 'R3',
|
||||||
|
che_증여: 'R3',
|
||||||
|
che_해산: 'R3',
|
||||||
|
che_등용: 'R3',
|
||||||
|
che_첩보: 'R2',
|
||||||
|
che_파괴: 'R3',
|
||||||
|
che_선동: 'R3',
|
||||||
|
che_탈취: 'R3',
|
||||||
|
che_NPC능동: 'R1',
|
||||||
|
che_강행: 'R3',
|
||||||
|
휴식: 'R1',
|
||||||
|
} as const satisfies Record<GeneralTurnCommandKey, CommandDurabilityRisk>;
|
||||||
|
|
||||||
|
export const nationCommandDurabilityRisk = {
|
||||||
|
휴식: 'R1',
|
||||||
|
che_포상: 'R3',
|
||||||
|
che_부대탈퇴지시: 'R3',
|
||||||
|
che_발령: 'R3',
|
||||||
|
che_선전포고: 'R3',
|
||||||
|
che_종전제의: 'R3',
|
||||||
|
che_불가침제의: 'R3',
|
||||||
|
che_불가침파기제의: 'R3',
|
||||||
|
che_의병모집: 'R3',
|
||||||
|
che_허보: 'R3',
|
||||||
|
che_필사즉생: 'R3',
|
||||||
|
che_백성동원: 'R3',
|
||||||
|
che_이호경식: 'R3',
|
||||||
|
che_수몰: 'R3',
|
||||||
|
che_급습: 'R3',
|
||||||
|
che_피장파장: 'R3',
|
||||||
|
che_초토화: 'R3',
|
||||||
|
che_천도: 'R2',
|
||||||
|
che_국호변경: 'R1',
|
||||||
|
che_무작위수도이전: 'R3',
|
||||||
|
che_국기변경: 'R1',
|
||||||
|
che_증축: 'R2',
|
||||||
|
che_감축: 'R2',
|
||||||
|
cr_인구이동: 'R3',
|
||||||
|
che_몰수: 'R3',
|
||||||
|
che_물자원조: 'R3',
|
||||||
|
event_원융노병연구: 'R1',
|
||||||
|
event_화시병연구: 'R1',
|
||||||
|
event_음귀병연구: 'R1',
|
||||||
|
event_대검병연구: 'R1',
|
||||||
|
event_화륜차연구: 'R1',
|
||||||
|
event_산저병연구: 'R1',
|
||||||
|
event_극병연구: 'R1',
|
||||||
|
event_상병연구: 'R1',
|
||||||
|
event_무희연구: 'R1',
|
||||||
|
} as const satisfies Record<NationTurnCommandKey, CommandDurabilityRisk>;
|
||||||
|
|
||||||
|
export type CommandDurabilityFacet =
|
||||||
|
| 'single-actor'
|
||||||
|
| 'local-aggregate'
|
||||||
|
| 'cross-entity'
|
||||||
|
| 'placement-topology'
|
||||||
|
| 'hostile-rng-destructive'
|
||||||
|
| 'diplomacy-strategy'
|
||||||
|
| 'entity-creation-fanout'
|
||||||
|
| 'retirement-archive'
|
||||||
|
| 'multi-turn-research';
|
||||||
|
|
||||||
|
export interface CommandDurabilityEvidence {
|
||||||
|
scope: CommandDurabilityScope;
|
||||||
|
risk: CommandDurabilityRisk;
|
||||||
|
command: GeneralTurnCommandKey | NationTurnCommandKey;
|
||||||
|
facet: CommandDurabilityFacet;
|
||||||
|
testFile: string;
|
||||||
|
matrixRepresentative: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a representative durable matrix, not a claim that all 90 commands
|
||||||
|
* execute against PostgreSQL. Every scope/risk cell runs in the dedicated
|
||||||
|
* matrix; high-risk battle, creation, and destructive paths retain their
|
||||||
|
* stronger dedicated rollback/reload suites.
|
||||||
|
*/
|
||||||
|
export const commandDurabilityEvidence = [
|
||||||
|
{
|
||||||
|
scope: 'general',
|
||||||
|
risk: 'R1',
|
||||||
|
command: 'che_훈련',
|
||||||
|
facet: 'single-actor',
|
||||||
|
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||||
|
matrixRepresentative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'general',
|
||||||
|
risk: 'R2',
|
||||||
|
command: 'che_농지개간',
|
||||||
|
facet: 'local-aggregate',
|
||||||
|
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||||
|
matrixRepresentative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'general',
|
||||||
|
risk: 'R3',
|
||||||
|
command: 'che_증여',
|
||||||
|
facet: 'cross-entity',
|
||||||
|
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||||
|
matrixRepresentative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R1',
|
||||||
|
command: 'che_국호변경',
|
||||||
|
facet: 'local-aggregate',
|
||||||
|
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||||
|
matrixRepresentative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R2',
|
||||||
|
command: 'che_증축',
|
||||||
|
facet: 'local-aggregate',
|
||||||
|
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||||
|
matrixRepresentative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R3',
|
||||||
|
command: 'che_선전포고',
|
||||||
|
facet: 'diplomacy-strategy',
|
||||||
|
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||||
|
matrixRepresentative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'general',
|
||||||
|
risk: 'R3',
|
||||||
|
command: 'che_이동',
|
||||||
|
facet: 'placement-topology',
|
||||||
|
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||||
|
matrixRepresentative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R3',
|
||||||
|
command: 'che_물자원조',
|
||||||
|
facet: 'cross-entity',
|
||||||
|
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||||
|
matrixRepresentative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R1',
|
||||||
|
command: 'event_원융노병연구',
|
||||||
|
facet: 'multi-turn-research',
|
||||||
|
testFile: 'turnCommandRiskDurabilityMatrix.integration.test.ts',
|
||||||
|
matrixRepresentative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'general',
|
||||||
|
risk: 'R3',
|
||||||
|
command: 'che_출병',
|
||||||
|
facet: 'hostile-rng-destructive',
|
||||||
|
testFile: 'liveSortiePersistence.integration.test.ts',
|
||||||
|
matrixRepresentative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R3',
|
||||||
|
command: 'che_의병모집',
|
||||||
|
facet: 'entity-creation-fanout',
|
||||||
|
testFile: 'turnCommandFullLifecyclePersistence.integration.test.ts',
|
||||||
|
matrixRepresentative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'general',
|
||||||
|
risk: 'R3',
|
||||||
|
command: 'che_은퇴',
|
||||||
|
facet: 'retirement-archive',
|
||||||
|
testFile: 'generalTurnLifecyclePersistence.integration.test.ts',
|
||||||
|
matrixRepresentative: false,
|
||||||
|
},
|
||||||
|
] as const satisfies readonly CommandDurabilityEvidence[];
|
||||||
@@ -174,7 +174,7 @@ class TracingRandUtil extends RandUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase): KernelTrace => {
|
const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase, acceptedGameTick: number): KernelTrace => {
|
||||||
const reserved = new Set([...testCase.reservedIds, ...(testCase.boundaryReservedIds ?? [])]);
|
const reserved = new Set([...testCase.reservedIds, ...(testCase.boundaryReservedIds ?? [])]);
|
||||||
const candidates = fixture.candidates
|
const candidates = fixture.candidates
|
||||||
.filter(({ id }) => !reserved.has(id))
|
.filter(({ id }) => !reserved.has(id))
|
||||||
@@ -200,7 +200,7 @@ const runCoreKernelCase = (fixture: Fixture, testCase: FixtureCase): KernelTrace
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = buildNpcSelectionTokenSeed(fixture.hiddenSeed, fixture.owner, acceptedAt(fixture));
|
const seed = buildNpcSelectionTokenSeed(fixture.hiddenSeed, fixture.owner, acceptedGameTick);
|
||||||
const rng = new TracingRandUtil(new LiteHashDRBG(seed));
|
const rng = new TracingRandUtil(new LiteHashDRBG(seed));
|
||||||
const draws: number[] = [];
|
const draws: number[] = [];
|
||||||
const picked = chooseNpcPossessionCandidates(candidates, kept, rng, (selectedId) => {
|
const picked = chooseNpcPossessionCandidates(candidates, kept, rng, (selectedId) => {
|
||||||
@@ -227,6 +227,27 @@ const assertDedicatedDatabase = (rawUrl: string): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readReferenceAcceptedGameTick = (trace: ReferenceTrace): number => {
|
||||||
|
const ticks = new Set(
|
||||||
|
trace.cases.flatMap(({ seed }) => {
|
||||||
|
if (seed === null) return [];
|
||||||
|
const match = seed.match(/\|int\((-?\d+)\)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Ref SelectNPCToken seed does not end with an integer game tick: ${seed}`);
|
||||||
|
}
|
||||||
|
const tick = Number(match[1]);
|
||||||
|
if (!Number.isSafeInteger(tick)) {
|
||||||
|
throw new Error(`Ref SelectNPCToken tick is outside the safe integer range: ${match[1]}`);
|
||||||
|
}
|
||||||
|
return [tick];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (ticks.size !== 1) {
|
||||||
|
throw new Error(`Ref comparison returned ${ticks.size} distinct accepted game ticks`);
|
||||||
|
}
|
||||||
|
return [...ticks][0]!;
|
||||||
|
};
|
||||||
|
|
||||||
integration('NPC possession selector Ref differential', () => {
|
integration('NPC possession selector Ref differential', () => {
|
||||||
if (!workspaceRoot || !databaseUrl || !referenceEnabled) {
|
if (!workspaceRoot || !databaseUrl || !referenceEnabled) {
|
||||||
return;
|
return;
|
||||||
@@ -317,7 +338,10 @@ integration('NPC possession selector Ref differential', () => {
|
|||||||
await closeDb?.();
|
await closeDb?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
const runCoreReservationCase = async (testCase: FixtureCase): Promise<CoreReservationTrace> =>
|
const runCoreReservationCase = async (
|
||||||
|
testCase: FixtureCase,
|
||||||
|
acceptedGameTick: number
|
||||||
|
): Promise<CoreReservationTrace> =>
|
||||||
db.$transaction(async (transaction) => {
|
db.$transaction(async (transaction) => {
|
||||||
await transaction.npcSelectionToken.deleteMany();
|
await transaction.npcSelectionToken.deleteMany();
|
||||||
const validUntil = new Date('2099-12-31T23:59:59.000Z');
|
const validUntil = new Date('2099-12-31T23:59:59.000Z');
|
||||||
@@ -383,6 +407,7 @@ integration('NPC possession selector Ref differential', () => {
|
|||||||
refresh: hasPreviousToken,
|
refresh: hasPreviousToken,
|
||||||
keepIds: testCase.keepIds,
|
keepIds: testCase.keepIds,
|
||||||
now: acceptedAt(fixture),
|
now: acceptedAt(fixture),
|
||||||
|
acceptedGameTick,
|
||||||
selectionObserver: {
|
selectionObserver: {
|
||||||
onRandomDraw: (value) => randomDraws.push(value),
|
onRandomDraw: (value) => randomDraws.push(value),
|
||||||
onCandidateDraw: (selectedId) => draws.push(Number(selectedId)),
|
onCandidateDraw: (selectedId) => draws.push(Number(selectedId)),
|
||||||
@@ -427,11 +452,18 @@ integration('NPC possession selector Ref differential', () => {
|
|||||||
);
|
);
|
||||||
expect(firstReference.cases).toHaveLength(fixture.cases.length);
|
expect(firstReference.cases).toHaveLength(fixture.cases.length);
|
||||||
|
|
||||||
const coreKernelCases = fixture.cases.map((testCase) => runCoreKernelCase(fixture, testCase));
|
// The shared Ref database and disposable Core scenario intentionally have
|
||||||
|
// different clock bases. This selector differential isolates RNG by injecting
|
||||||
|
// the exact safe integer tick observed at the Ref endpoint into both Core paths;
|
||||||
|
// it does not claim clock-base parity between the two fixtures.
|
||||||
|
const referenceAcceptedGameTick = readReferenceAcceptedGameTick(firstReference);
|
||||||
|
const coreKernelCases = fixture.cases.map((testCase) =>
|
||||||
|
runCoreKernelCase(fixture, testCase, referenceAcceptedGameTick)
|
||||||
|
);
|
||||||
for (const [index, referenceCase] of firstReference.cases.entries()) {
|
for (const [index, referenceCase] of firstReference.cases.entries()) {
|
||||||
const testCase = fixture.cases[index]!;
|
const testCase = fixture.cases[index]!;
|
||||||
const coreKernel = coreKernelCases[index]!;
|
const coreKernel = coreKernelCases[index]!;
|
||||||
const coreReservation = await runCoreReservationCase(testCase);
|
const coreReservation = await runCoreReservationCase(testCase, referenceAcceptedGameTick);
|
||||||
expect(referenceCase.name).toBe(coreKernel.name);
|
expect(referenceCase.name).toBe(coreKernel.name);
|
||||||
expect(referenceCase.selectionStateUnchanged).toBe(true);
|
expect(referenceCase.selectionStateUnchanged).toBe(true);
|
||||||
expect(referenceCase.cancelled).toBe(coreKernel.cancelled);
|
expect(referenceCase.cancelled).toBe(coreKernel.cancelled);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type RedisConnector,
|
type RedisConnector,
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
|
|
||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
const databaseUrl = process.env.SCENARIO_LIFECYCLE_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
const scenarioId = 2900;
|
const scenarioId = 2900;
|
||||||
const userIds = ['scenario29-betting-winner', 'scenario29-betting-loser'] as const;
|
const userIds = ['scenario29-betting-winner', 'scenario29-betting-loser'] as const;
|
||||||
|
|||||||
+52
-7
@@ -485,11 +485,33 @@ databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence',
|
|||||||
}
|
}
|
||||||
world.executeGeneralTurn(executableActor);
|
world.executeGeneralTurn(executableActor);
|
||||||
|
|
||||||
const createdIds = world
|
const createdGenerals = world.peekDirtyState().createdGenerals;
|
||||||
.peekDirtyState()
|
const createdIds = createdGenerals.map((general) => general.id).sort((left, right) => left - right);
|
||||||
.createdGenerals.map((general) => general.id)
|
|
||||||
.sort((left, right) => left - right);
|
|
||||||
expect(createdIds).toEqual([102, 103, 104]);
|
expect(createdIds).toEqual([102, 103, 104]);
|
||||||
|
const createdVolunteerIdentity = createdGenerals
|
||||||
|
.map((general) => ({
|
||||||
|
id: general.id,
|
||||||
|
affinity: general.affinity,
|
||||||
|
npcState: general.npcState,
|
||||||
|
npcOrg: asRecord(general.meta).npc_org,
|
||||||
|
expLevel: asRecord(general.meta).explevel,
|
||||||
|
dedLevel: asRecord(general.meta).dedlevel,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.id - right.id);
|
||||||
|
expect(createdVolunteerIdentity).toEqual(
|
||||||
|
createdIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
affinity: expect.any(Number),
|
||||||
|
npcState: 4,
|
||||||
|
npcOrg: 4,
|
||||||
|
expLevel: 0,
|
||||||
|
dedLevel: 1,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
for (const volunteer of createdVolunteerIdentity) {
|
||||||
|
expect(volunteer.affinity).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(volunteer.affinity).toBeLessThanOrEqual(150);
|
||||||
|
}
|
||||||
expect(reservedTurns.peekDirtyState().generalInitializationIds.sort((left, right) => left - right)).toEqual(
|
expect(reservedTurns.peekDirtyState().generalInitializationIds.sort((left, right) => left - right)).toEqual(
|
||||||
createdIds
|
createdIds
|
||||||
);
|
);
|
||||||
@@ -527,6 +549,22 @@ databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence',
|
|||||||
).toEqual(Array.from({ length: 30 }, (_, turnIdx) => ({ turnIdx, action: '휴식', args: {} })));
|
).toEqual(Array.from({ length: 30 }, (_, turnIdx) => ({ turnIdx, action: '휴식', args: {} })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistedVolunteerIdentity = (
|
||||||
|
await db.general.findMany({
|
||||||
|
where: { id: { in: createdIds } },
|
||||||
|
select: { id: true, affinity: true, npcState: true, meta: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
})
|
||||||
|
).map((general) => ({
|
||||||
|
id: general.id,
|
||||||
|
affinity: general.affinity,
|
||||||
|
npcState: general.npcState,
|
||||||
|
npcOrg: asRecord(general.meta).npc_org,
|
||||||
|
expLevel: asRecord(general.meta).explevel,
|
||||||
|
dedLevel: asRecord(general.meta).dedlevel,
|
||||||
|
}));
|
||||||
|
expect(persistedVolunteerIdentity).toEqual(createdVolunteerIdentity);
|
||||||
|
|
||||||
const persistedNation = await db.nation.findUnique({ where: { id: nationId }, select: { meta: true } });
|
const persistedNation = await db.nation.findUnique({ where: { id: nationId }, select: { meta: true } });
|
||||||
expect(persistedNation?.meta).toMatchObject({ gennum: 4 });
|
expect(persistedNation?.meta).toMatchObject({ gennum: 4 });
|
||||||
|
|
||||||
@@ -534,9 +572,16 @@ databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence',
|
|||||||
expect(
|
expect(
|
||||||
reloaded.snapshot.generals
|
reloaded.snapshot.generals
|
||||||
.filter((general) => createdIds.includes(general.id))
|
.filter((general) => createdIds.includes(general.id))
|
||||||
.map((general) => general.id)
|
.map((general) => ({
|
||||||
.sort((left, right) => left - right)
|
id: general.id,
|
||||||
).toEqual(createdIds);
|
affinity: general.affinity,
|
||||||
|
npcState: general.npcState,
|
||||||
|
npcOrg: asRecord(general.meta).npc_org,
|
||||||
|
expLevel: asRecord(general.meta).explevel,
|
||||||
|
dedLevel: asRecord(general.meta).dedlevel,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.id - right.id)
|
||||||
|
).toEqual(createdVolunteerIdentity);
|
||||||
expect(reloaded.snapshot.nations.find((nation) => nation.id === nationId)?.meta).toMatchObject({ gennum: 4 });
|
expect(reloaded.snapshot.nations.find((nation) => nation.id === nationId)?.meta).toMatchObject({ gennum: 4 });
|
||||||
const reloadedReservedTurns = new InMemoryReservedTurnStore(db, {
|
const reloadedReservedTurns = new InMemoryReservedTurnStore(db, {
|
||||||
maxGeneralTurns: 30,
|
maxGeneralTurns: 30,
|
||||||
|
|||||||
@@ -0,0 +1,1227 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import {
|
||||||
|
GENERAL_TURN_COMMAND_KEYS,
|
||||||
|
NATION_TURN_COMMAND_KEYS,
|
||||||
|
type GeneralTurnCommandKey,
|
||||||
|
type NationTurnCommandKey,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js';
|
||||||
|
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
|
||||||
|
import { createReservedTurnHandler } from '@sammo-ts/game-engine/turn/reservedTurnHandler.js';
|
||||||
|
import { InMemoryReservedTurnStore } from '@sammo-ts/game-engine/turn/reservedTurnStore.js';
|
||||||
|
import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
|
||||||
|
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
||||||
|
import { loadTurnWorldFromDatabase } from '@sammo-ts/game-engine/turn/worldLoader.js';
|
||||||
|
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import {
|
||||||
|
commandDurabilityEvidence,
|
||||||
|
generalCommandDurabilityRisk,
|
||||||
|
nationCommandDurabilityRisk,
|
||||||
|
type CommandDurabilityRisk,
|
||||||
|
} from '../src/turn-differential/commandDurabilityRisk.js';
|
||||||
|
import {
|
||||||
|
canonicalizeTurnCommandArgs,
|
||||||
|
type CanonicalTurnSnapshot,
|
||||||
|
type TurnSnapshotSelector,
|
||||||
|
} from '../src/turn-differential/canonical.js';
|
||||||
|
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
|
||||||
|
import {
|
||||||
|
clearCoreTurnCommandPersistenceFixture,
|
||||||
|
seedCoreTurnCommandPersistenceFixture,
|
||||||
|
} from '../src/turn-differential/coreCommandPersistenceFixture.js';
|
||||||
|
import {
|
||||||
|
buildCoreTurnCommandWorldInput,
|
||||||
|
createCoreTurnCommandProfile,
|
||||||
|
resolveCoreTurnCommandArgs,
|
||||||
|
runCoreTurnCommandTrace,
|
||||||
|
type TurnCommandFixtureRequest,
|
||||||
|
} from '../src/turn-differential/coreCommandTrace.js';
|
||||||
|
import { readCoreDatabaseSnapshot } from '../src/turn-differential/databaseSnapshot.js';
|
||||||
|
import { normalizeStoredTurnLogText, orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js';
|
||||||
|
import { projectSemanticTurnMessages } from '../src/turn-differential/messageProjection.js';
|
||||||
|
import {
|
||||||
|
findTurnDifferentialWorkspaceRoot,
|
||||||
|
runReferenceTurnCommandTraceRequest,
|
||||||
|
} from '../src/turn-differential/referenceSnapshot.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL;
|
||||||
|
const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||||
|
const referenceSourceRoot = workspaceRoot
|
||||||
|
? path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot, 'ref/sam'))
|
||||||
|
: null;
|
||||||
|
const hasReferenceRunner =
|
||||||
|
referenceSourceRoot !== null && fs.existsSync(path.join(referenceSourceRoot, 'hwe/compare/turn_command_trace.php'));
|
||||||
|
const databaseIntegration = describe.skipIf(
|
||||||
|
!databaseUrl || !workspaceRoot || !hasReferenceRunner || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'
|
||||||
|
);
|
||||||
|
const dedicatedSuffix = 'turn_command_durable_matrix';
|
||||||
|
const leaseOwner = 'turn-command-durable-matrix-daemon';
|
||||||
|
const siblingRulerGeneralId = 3;
|
||||||
|
const siblingGeneralTurnRevisionSentinel = {
|
||||||
|
generalId: siblingRulerGeneralId,
|
||||||
|
revision: 37,
|
||||||
|
leaseOwner,
|
||||||
|
leaseExpiresAt: new Date('2099-08-24T12:34:56.789Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSiblingNationTurnRevisionSentinel = (actorNationId: number, actorOfficerLevel: number) => ({
|
||||||
|
nationId: actorOfficerLevel === 5 ? actorNationId : 2,
|
||||||
|
officerLevel: 12,
|
||||||
|
revision: 41,
|
||||||
|
leaseOwner,
|
||||||
|
leaseExpiresAt: new Date('2099-08-24T23:45:01.234Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const readSiblingTurnRevisionSentinels = async (
|
||||||
|
db: GamePrismaClient,
|
||||||
|
siblingNationSentinel: ReturnType<typeof buildSiblingNationTurnRevisionSentinel>
|
||||||
|
) => ({
|
||||||
|
general: await db.generalTurnRevision.findUniqueOrThrow({
|
||||||
|
where: { generalId: siblingGeneralTurnRevisionSentinel.generalId },
|
||||||
|
select: { generalId: true, revision: true, leaseOwner: true, leaseExpiresAt: true },
|
||||||
|
}),
|
||||||
|
nation: await db.nationTurnRevision.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
nationId_officerLevel: {
|
||||||
|
nationId: siblingNationSentinel.nationId,
|
||||||
|
officerLevel: siblingNationSentinel.officerLevel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { nationId: true, officerLevel: true, revision: true, leaseOwner: true, leaseExpiresAt: true },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const assertDedicatedTurnCommandDurableMatrixDatabase = (rawUrl: string): void => {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
const schema = url.searchParams.get('schema');
|
||||||
|
const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
|
||||||
|
if (!schema?.endsWith(dedicatedSuffix) && !databaseName.endsWith(dedicatedSuffix)) {
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to mutate non-dedicated turn command durable matrix database: schema=${schema ?? '(missing)'}, database=${databaseName || '(missing)'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('turn command durability risk manifest', () => {
|
||||||
|
it('classifies the exact 55 general and 35 nation command registries without duplicates', () => {
|
||||||
|
expect(Object.keys(generalCommandDurabilityRisk).sort()).toEqual([...GENERAL_TURN_COMMAND_KEYS].sort());
|
||||||
|
expect(Object.keys(nationCommandDurabilityRisk).sort()).toEqual([...NATION_TURN_COMMAND_KEYS].sort());
|
||||||
|
expect(Object.keys(generalCommandDurabilityRisk)).toHaveLength(55);
|
||||||
|
expect(Object.keys(nationCommandDurabilityRisk)).toHaveLength(35);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the reviewed R1/R2/R3 population stable and explicit', () => {
|
||||||
|
const counts = (values: CommandDurabilityRisk[]) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
(['R1', 'R2', 'R3'] as const).map((risk) => [risk, values.filter((value) => value === risk).length])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(counts(Object.values(generalCommandDurabilityRisk))).toEqual({ R1: 14, R2: 16, R3: 25 });
|
||||||
|
expect(counts(Object.values(nationCommandDurabilityRisk))).toEqual({ R1: 12, R2: 3, R3: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns a dedicated PostgreSQL representative to every scope/risk cell and stronger R3 facet', () => {
|
||||||
|
const matrixCells = new Set(
|
||||||
|
commandDurabilityEvidence
|
||||||
|
.filter((entry) => entry.matrixRepresentative)
|
||||||
|
.map((entry) => `${entry.scope}:${entry.risk}`)
|
||||||
|
);
|
||||||
|
const requiredCells = (['general', 'nation'] as const).flatMap((scope) =>
|
||||||
|
(['R1', 'R2', 'R3'] as const).map((risk) => `${scope}:${risk}`)
|
||||||
|
);
|
||||||
|
expect([...matrixCells].sort()).toEqual(requiredCells.sort());
|
||||||
|
|
||||||
|
const facets = new Set(commandDurabilityEvidence.map((entry) => entry.facet));
|
||||||
|
expect(facets).toEqual(
|
||||||
|
new Set([
|
||||||
|
'single-actor',
|
||||||
|
'local-aggregate',
|
||||||
|
'cross-entity',
|
||||||
|
'placement-topology',
|
||||||
|
'hostile-rng-destructive',
|
||||||
|
'diplomacy-strategy',
|
||||||
|
'entity-creation-fanout',
|
||||||
|
'retirement-archive',
|
||||||
|
'multi-turn-research',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every declared PostgreSQL representative synchronized with its typed risk inventory and executed case', () => {
|
||||||
|
const evidence = commandDurabilityEvidence
|
||||||
|
.filter((entry) => entry.matrixRepresentative)
|
||||||
|
.map(({ scope, risk, command }) => `${scope}:${risk}:${command}`)
|
||||||
|
.sort();
|
||||||
|
const cases = riskMatrixCases.map(({ scope, risk, action }) => `${scope}:${risk}:${action}`).sort();
|
||||||
|
expect(cases).toEqual(evidence);
|
||||||
|
|
||||||
|
for (const entry of riskMatrixCases) {
|
||||||
|
const classifiedRisk =
|
||||||
|
entry.scope === 'general'
|
||||||
|
? generalCommandDurabilityRisk[entry.action]
|
||||||
|
: nationCommandDurabilityRisk[entry.action];
|
||||||
|
expect(entry.risk, `${entry.scope}:${entry.action}`).toBe(classifiedRisk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('turn command durable matrix database guard', () => {
|
||||||
|
it('rejects a shared database and schema before connecting', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertDedicatedTurnCommandDurableMatrixDatabase(
|
||||||
|
'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=public'
|
||||||
|
)
|
||||||
|
).toThrow('Refusing to mutate non-dedicated turn command durable matrix database');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts only an explicitly dedicated schema or database name', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertDedicatedTurnCommandDurableMatrixDatabase(
|
||||||
|
'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=ci_turn_command_durable_matrix'
|
||||||
|
)
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
assertDedicatedTurnCommandDurableMatrixDatabase(
|
||||||
|
'postgresql://fixture:fixture@127.0.0.1:5432/ci_turn_command_durable_matrix'
|
||||||
|
)
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record<string, unknown> => ({
|
||||||
|
id,
|
||||||
|
name: `위험행렬장수${id}`,
|
||||||
|
nationId,
|
||||||
|
cityId,
|
||||||
|
troopId: 0,
|
||||||
|
leadership: 90,
|
||||||
|
strength: 80,
|
||||||
|
intelligence: 70,
|
||||||
|
leadershipExp: 0,
|
||||||
|
strengthExp: 0,
|
||||||
|
intelExp: 0,
|
||||||
|
experience: 1_000,
|
||||||
|
dedication: 1_000,
|
||||||
|
expLevel: 0,
|
||||||
|
officerLevel,
|
||||||
|
officerCityId: officerLevel >= 5 ? cityId : 0,
|
||||||
|
belong: 10,
|
||||||
|
permission: 'normal',
|
||||||
|
injury: 0,
|
||||||
|
age: 30,
|
||||||
|
gold: 100_000,
|
||||||
|
rice: 100_000,
|
||||||
|
crew: 1_000,
|
||||||
|
crewTypeId: 1_100,
|
||||||
|
train: 50,
|
||||||
|
atmos: 50,
|
||||||
|
killTurn: 24,
|
||||||
|
npcState: 0,
|
||||||
|
blockState: 0,
|
||||||
|
personality: 'None',
|
||||||
|
specialDomestic: 'None',
|
||||||
|
specialWar: 'None',
|
||||||
|
itemHorse: 'None',
|
||||||
|
itemWeapon: 'None',
|
||||||
|
itemBook: 'None',
|
||||||
|
itemExtra: 'None',
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RiskMatrixCaseBase {
|
||||||
|
label: string;
|
||||||
|
risk: CommandDurabilityRisk;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
nationPatch?: Record<string, unknown>;
|
||||||
|
cityPatch?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RiskMatrixCase =
|
||||||
|
| (RiskMatrixCaseBase & { scope: 'general'; action: GeneralTurnCommandKey })
|
||||||
|
| (RiskMatrixCaseBase & { scope: 'nation'; action: NationTurnCommandKey });
|
||||||
|
|
||||||
|
const riskMatrixCases: RiskMatrixCase[] = [
|
||||||
|
{ label: 'general actor aggregate', scope: 'general', risk: 'R1', action: 'che_훈련' },
|
||||||
|
{ label: 'general/city cross aggregate', scope: 'general', risk: 'R2', action: 'che_농지개간' },
|
||||||
|
{
|
||||||
|
label: 'general multi-party resource transfer',
|
||||||
|
scope: 'general',
|
||||||
|
risk: 'R3',
|
||||||
|
action: 'che_증여',
|
||||||
|
args: { isGold: true, amount: 100, destGeneralID: 3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'general placement topology',
|
||||||
|
scope: 'general',
|
||||||
|
risk: 'R3',
|
||||||
|
action: 'che_이동',
|
||||||
|
args: { destCityID: 70 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'nation aggregate',
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R1',
|
||||||
|
action: 'che_국호변경',
|
||||||
|
args: { nationName: '위험행렬국' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'nation/capital topology',
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R2',
|
||||||
|
action: 'che_증축',
|
||||||
|
nationPatch: {
|
||||||
|
capitalRevision: 0,
|
||||||
|
turnLastByOfficerLevel: { 12: { command: '증축', arg: {}, term: 5, seq: 0 } },
|
||||||
|
},
|
||||||
|
cityPatch: { level: 7 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'nation diplomacy relationship',
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R3',
|
||||||
|
action: 'che_선전포고',
|
||||||
|
args: { destNationID: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'nation cross-entity material aid',
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R3',
|
||||||
|
action: 'che_물자원조',
|
||||||
|
args: { destNationID: 2, amountList: [100, 200] },
|
||||||
|
nationPatch: {
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: { command: '국호 변경', arg: { nationName: '수뇌보존국' }, term: 7, seq: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'nation multi-turn research completion',
|
||||||
|
scope: 'nation',
|
||||||
|
risk: 'R1',
|
||||||
|
action: 'event_원융노병연구',
|
||||||
|
nationPatch: {
|
||||||
|
turnLastByOfficerLevel: { 12: { command: '원융노병 연구', term: 23 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const buildRiskMatrixRequest = (entry: RiskMatrixCase): TurnCommandFixtureRequest => {
|
||||||
|
const validatesMinimumChiefBoundary = entry.action === 'che_물자원조';
|
||||||
|
const actorOfficerLevel = validatesMinimumChiefBoundary ? 5 : 12;
|
||||||
|
const siblingRulerTurns = validatesMinimumChiefBoundary
|
||||||
|
? Array.from({ length: 12 }, (_, turnIndex) => ({
|
||||||
|
nationId: 1,
|
||||||
|
officerLevel: 12,
|
||||||
|
turnIndex,
|
||||||
|
action: turnIndex === 0 ? 'che_국호변경' : '휴식',
|
||||||
|
args: turnIndex === 0 ? { nationName: '수뇌보존대기국' } : {},
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: entry.scope,
|
||||||
|
actorGeneralId: 1,
|
||||||
|
action: entry.action,
|
||||||
|
...(entry.args ? { args: entry.args } : {}),
|
||||||
|
includeLifecycle: true,
|
||||||
|
setup: {
|
||||||
|
isolateWorld: true,
|
||||||
|
world: {
|
||||||
|
startYear: 180,
|
||||||
|
year: 190,
|
||||||
|
month: 1,
|
||||||
|
hiddenSeed: `turn-command-durable-${entry.scope}-${entry.action}`,
|
||||||
|
freezeClock: true,
|
||||||
|
},
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '아국',
|
||||||
|
color: '#777777',
|
||||||
|
capitalCityId: 3,
|
||||||
|
gold: 1_000_000,
|
||||||
|
rice: 1_000_000,
|
||||||
|
tech: 1_000,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_명가',
|
||||||
|
war: 0,
|
||||||
|
diplomacyLimit: 0,
|
||||||
|
strategicCommandLimit: 0,
|
||||||
|
generalCount: 2,
|
||||||
|
meta: { can_국호변경: 1, can_국기변경: 1, surlimit: 0 },
|
||||||
|
...entry.nationPatch,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '타국',
|
||||||
|
color: '#888888',
|
||||||
|
capitalCityId: 71,
|
||||||
|
gold: 1_000_000,
|
||||||
|
rice: 1_000_000,
|
||||||
|
tech: 1_000,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_명가',
|
||||||
|
war: 0,
|
||||||
|
diplomacyLimit: 0,
|
||||||
|
strategicCommandLimit: 0,
|
||||||
|
generalCount: 1,
|
||||||
|
meta: { surlimit: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
nationId: 1,
|
||||||
|
level: 5,
|
||||||
|
population: 100_000,
|
||||||
|
populationMax: 200_000,
|
||||||
|
agriculture: 1_000,
|
||||||
|
agricultureMax: 2_000,
|
||||||
|
commerce: 1_000,
|
||||||
|
commerceMax: 2_000,
|
||||||
|
security: 1_000,
|
||||||
|
securityMax: 2_000,
|
||||||
|
defence: 1_000,
|
||||||
|
defenceMax: 2_000,
|
||||||
|
wall: 1_000,
|
||||||
|
wallMax: 2_000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
state: 0,
|
||||||
|
term: 0,
|
||||||
|
trust: 80,
|
||||||
|
trade: 100,
|
||||||
|
...entry.cityPatch,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 70,
|
||||||
|
nationId: 1,
|
||||||
|
level: 5,
|
||||||
|
population: 100_000,
|
||||||
|
populationMax: 200_000,
|
||||||
|
agriculture: 1_000,
|
||||||
|
agricultureMax: 2_000,
|
||||||
|
commerce: 1_000,
|
||||||
|
commerceMax: 2_000,
|
||||||
|
security: 1_000,
|
||||||
|
securityMax: 2_000,
|
||||||
|
defence: 1_000,
|
||||||
|
defenceMax: 2_000,
|
||||||
|
wall: 1_000,
|
||||||
|
wallMax: 2_000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 1,
|
||||||
|
state: 0,
|
||||||
|
term: 0,
|
||||||
|
trust: 80,
|
||||||
|
trade: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 71,
|
||||||
|
nationId: 2,
|
||||||
|
level: 5,
|
||||||
|
population: 100_000,
|
||||||
|
populationMax: 200_000,
|
||||||
|
agriculture: 1_000,
|
||||||
|
agricultureMax: 2_000,
|
||||||
|
commerce: 1_000,
|
||||||
|
commerceMax: 2_000,
|
||||||
|
security: 1_000,
|
||||||
|
securityMax: 2_000,
|
||||||
|
defence: 1_000,
|
||||||
|
defenceMax: 2_000,
|
||||||
|
wall: 1_000,
|
||||||
|
wallMax: 2_000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 1,
|
||||||
|
state: 0,
|
||||||
|
term: 0,
|
||||||
|
trust: 80,
|
||||||
|
trade: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generals: [
|
||||||
|
general(1, 1, 3, actorOfficerLevel),
|
||||||
|
general(2, 2, 71, 12),
|
||||||
|
general(siblingRulerGeneralId, 1, 3, validatesMinimumChiefBoundary ? 12 : 1),
|
||||||
|
],
|
||||||
|
diplomacy: [
|
||||||
|
{ fromNationId: 1, toNationId: 2, state: 3, term: 0, dead: 0 },
|
||||||
|
{ fromNationId: 2, toNationId: 1, state: 3, term: 0, dead: 0 },
|
||||||
|
],
|
||||||
|
...(entry.scope === 'general'
|
||||||
|
? {
|
||||||
|
generalTurns: Array.from({ length: 30 }, (_, turnIndex) => ({
|
||||||
|
generalId: 1,
|
||||||
|
turnIndex,
|
||||||
|
action: turnIndex === 0 ? entry.action : '휴식',
|
||||||
|
args: turnIndex === 0 ? (entry.args ?? {}) : {},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
nationTurns: [
|
||||||
|
...Array.from({ length: 12 }, (_, turnIndex) => ({
|
||||||
|
nationId: 1,
|
||||||
|
officerLevel: actorOfficerLevel,
|
||||||
|
turnIndex,
|
||||||
|
action: turnIndex === 0 ? entry.action : '휴식',
|
||||||
|
args: turnIndex === 0 ? (entry.args ?? {}) : {},
|
||||||
|
})),
|
||||||
|
...siblingRulerTurns,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
observe: {
|
||||||
|
allGenerals: true,
|
||||||
|
allCities: true,
|
||||||
|
allNations: true,
|
||||||
|
allTroops: true,
|
||||||
|
generalIds: [1, 2, 3],
|
||||||
|
cityIds: [3, 70, 71],
|
||||||
|
nationIds: [1, 2],
|
||||||
|
troopIds: [],
|
||||||
|
includeRankMirrors: true,
|
||||||
|
logAfterId: 0,
|
||||||
|
messageAfterId: 0,
|
||||||
|
includeNationHistoryLogs: true,
|
||||||
|
includeGlobalHistoryLogs: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const lifecycleIgnoredPaths = [
|
||||||
|
/^generalTurns/,
|
||||||
|
/^nationTurns/,
|
||||||
|
/^logs/,
|
||||||
|
/^messages/,
|
||||||
|
/^world\.turnTime$/,
|
||||||
|
/^world\.gameNow$/,
|
||||||
|
/^world\.lastTurnTick$/,
|
||||||
|
/^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet|turnTick|turnSecond|turnFraction)(?:\.|$)/,
|
||||||
|
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
|
||||||
|
/^nations\[[^\]]+\]\.meta\.(?:turn_last_\d+|next_execute_.+|capset|tech|gennum|power|war|surlimit|strategic_cmd_limit)(?:\.|$)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
const addedReferenceLogs = (
|
||||||
|
before: { watermarks: { logId: number; historyLogId: number } },
|
||||||
|
after: Array<Record<string, unknown>>
|
||||||
|
): Array<Record<string, unknown>> =>
|
||||||
|
after.filter((entry) => {
|
||||||
|
const scope = String(entry.scope).toLowerCase();
|
||||||
|
const category = String(entry.category).toLowerCase();
|
||||||
|
const watermark =
|
||||||
|
scope === 'nation' || (scope === 'system' && category === 'history')
|
||||||
|
? before.watermarks.historyLogId
|
||||||
|
: before.watermarks.logId;
|
||||||
|
return Number(entry.id) > watermark;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isTrailingDefaultGeneralRestLog = (entry: Record<string, unknown>): boolean =>
|
||||||
|
String(entry.scope).toLowerCase() === 'general' &&
|
||||||
|
String(entry.category).toLowerCase() === 'action' &&
|
||||||
|
normalizeStoredTurnLogText(entry.text) === '아무것도 실행하지 않았습니다.';
|
||||||
|
|
||||||
|
const withoutVolatileGameNow = ({ world, ...snapshot }: CanonicalTurnSnapshot) => {
|
||||||
|
const { gameNow: _gameNow, ...stableWorld } = world;
|
||||||
|
return { ...snapshot, world: stableWorld };
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectDatabaseIndependentTurnMessages = (
|
||||||
|
messages: CanonicalTurnSnapshot['messages'],
|
||||||
|
messageAfterId: number
|
||||||
|
) => {
|
||||||
|
const newMessages = messages.filter((message) => Number(message.id) > messageAfterId);
|
||||||
|
const projected = projectSemanticTurnMessages(messages, messageAfterId);
|
||||||
|
const projectedById = new Map(newMessages.map((message, index) => [Number(message.id), projected[index]!]));
|
||||||
|
|
||||||
|
return projected.map((message) => {
|
||||||
|
const option =
|
||||||
|
typeof message.option === 'object' && message.option !== null && !Array.isArray(message.option)
|
||||||
|
? (message.option as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
if (!option || option.receiverMessageID === undefined) return message;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof option.receiverMessageID !== 'number' ||
|
||||||
|
!Number.isSafeInteger(option.receiverMessageID) ||
|
||||||
|
option.receiverMessageID <= 0
|
||||||
|
) {
|
||||||
|
throw new Error(`message receiverMessageID must be a positive safe integer number`);
|
||||||
|
}
|
||||||
|
const receiverMessageId = option.receiverMessageID;
|
||||||
|
const receiverCopy = projectedById.get(receiverMessageId);
|
||||||
|
if (
|
||||||
|
!receiverCopy ||
|
||||||
|
receiverCopy.mailbox !== message.destinationId ||
|
||||||
|
receiverCopy.type !== message.type ||
|
||||||
|
receiverCopy.sourceId !== message.sourceId ||
|
||||||
|
receiverCopy.destinationId !== message.destinationId ||
|
||||||
|
receiverCopy.text !== message.text
|
||||||
|
) {
|
||||||
|
throw new Error(`message receiverMessageID ${String(option.receiverMessageID)} is not its receiver copy`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref MariaDB and the dedicated PostgreSQL schema have independent
|
||||||
|
// sequences. Keep the cross-row link exact, but compare its database-
|
||||||
|
// local numeric key by relation rather than by an impossible shared id.
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
option: { ...option, receiverMessageID: 'receiver-copy' },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const readForbiddenSideEffects = async (db: GamePrismaClient) => ({
|
||||||
|
inputEvents: await db.inputEvent.count(),
|
||||||
|
webPushOutbox: await db.webPushOutbox.count(),
|
||||||
|
events: await db.event.count(),
|
||||||
|
auctions: await db.auction.count(),
|
||||||
|
auctionBids: await db.auctionBid.count(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectNoDirtyWorldChanges = (world: InMemoryTurnWorld): void => {
|
||||||
|
const { realtimeBacklogShiftTicks, ...arrayChanges } = world.peekDirtyState();
|
||||||
|
expect(realtimeBacklogShiftTicks).toBe(0);
|
||||||
|
for (const [changeName, entries] of Object.entries(arrayChanges)) {
|
||||||
|
expect(entries, `world dirty state ${changeName}`).toEqual([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectReloadedGeneralTurns = (store: InMemoryReservedTurnStore, generalId: number) =>
|
||||||
|
store.getGeneralTurns(generalId).map((turn, turnIndex) => ({
|
||||||
|
generalId,
|
||||||
|
turnIndex,
|
||||||
|
action: turn.action,
|
||||||
|
args: turn.args,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const projectReloadedNationTurns = (store: InMemoryReservedTurnStore, nationId: number, officerLevel: number) =>
|
||||||
|
store.getNationTurns(nationId, officerLevel).map((turn, turnIndex) => ({
|
||||||
|
nationId,
|
||||||
|
officerLevel,
|
||||||
|
turnIndex,
|
||||||
|
action: turn.action,
|
||||||
|
args: turn.args,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ReloadedWorldSnapshot = Awaited<ReturnType<typeof loadTurnWorldFromDatabase>>['snapshot'];
|
||||||
|
|
||||||
|
const projectReloadableWorldGraph = (
|
||||||
|
snapshot: Pick<ReloadedWorldSnapshot, 'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'>,
|
||||||
|
selector: TurnSnapshotSelector
|
||||||
|
) => {
|
||||||
|
const selectedIds = (all: boolean | undefined, ids: readonly number[]) => (all ? null : new Set(ids));
|
||||||
|
const generalIds = selectedIds(selector.allGenerals, selector.generalIds);
|
||||||
|
const cityIds = selectedIds(selector.allCities, selector.cityIds);
|
||||||
|
const nationIds = selectedIds(selector.allNations, selector.nationIds);
|
||||||
|
const troopIds = selectedIds(selector.allTroops, selector.troopIds ?? []);
|
||||||
|
const byId = <Row extends { id: number }>(rows: readonly Row[], ids: Set<number> | null) =>
|
||||||
|
structuredClone(rows)
|
||||||
|
.filter((row) => ids === null || ids.has(row.id))
|
||||||
|
.sort((left, right) => left.id - right.id);
|
||||||
|
const generals = byId(snapshot.generals, generalIds).map((general) => {
|
||||||
|
const { itemInventory: _persistedItemInventory, legacyScanOrder: _legacyScanOrder, ...meta } = general.meta;
|
||||||
|
return { ...general, meta };
|
||||||
|
});
|
||||||
|
const nations = byId(snapshot.nations, nationIds).map((nation) => {
|
||||||
|
const { power: _projectedPower, ...meta } = nation.meta;
|
||||||
|
return { ...nation, meta };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// The database loader reconstructs itemInventory from its canonical
|
||||||
|
// top-level field and may materialize projected defaults in meta. Keep
|
||||||
|
// those three storage/fixture duplicates out, while comparing every
|
||||||
|
// command-owned entity field (including top-level itemInventory) exact.
|
||||||
|
generals,
|
||||||
|
cities: byId(snapshot.cities, cityIds),
|
||||||
|
nations,
|
||||||
|
troops: byId(snapshot.troops, troopIds),
|
||||||
|
diplomacy: structuredClone(snapshot.diplomacy)
|
||||||
|
.filter(
|
||||||
|
(entry) => nationIds === null || (nationIds.has(entry.fromNationId) && nationIds.has(entry.toNationId))
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectCanonicalCommandLifecycleState = (
|
||||||
|
snapshot: CanonicalTurnSnapshot,
|
||||||
|
actorGeneralId: number,
|
||||||
|
nationId: number,
|
||||||
|
officerLevel: number
|
||||||
|
) => {
|
||||||
|
const general = snapshot.generals.find((row) => row.id === actorGeneralId);
|
||||||
|
const nation = snapshot.nations.find((row) => row.id === nationId);
|
||||||
|
if (!general) throw new Error(`canonical lifecycle actor is missing: ${actorGeneralId}`);
|
||||||
|
if (!nation) throw new Error(`canonical lifecycle nation is missing: ${nationId}`);
|
||||||
|
if (typeof nation.meta !== 'object' || nation.meta === null || Array.isArray(nation.meta)) {
|
||||||
|
throw new Error(`canonical lifecycle nation meta is invalid: ${nationId}`);
|
||||||
|
}
|
||||||
|
const nationMeta = nation.meta as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
generalLastTurn: general.lastTurn,
|
||||||
|
nationOfficerLastTurn: nationMeta[`turn_last_${officerLevel}`],
|
||||||
|
nationCapitalRevision: nation.capitalRevision,
|
||||||
|
nationCapset: nationMeta.capset,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectDomainCommandLifecycleState = (
|
||||||
|
snapshot: Pick<ReloadedWorldSnapshot, 'generals' | 'nations'>,
|
||||||
|
actorGeneralId: number,
|
||||||
|
nationId: number,
|
||||||
|
officerLevel: number
|
||||||
|
) => {
|
||||||
|
const general = snapshot.generals.find((row) => row.id === actorGeneralId);
|
||||||
|
const nation = snapshot.nations.find((row) => row.id === nationId);
|
||||||
|
if (!general) throw new Error(`domain lifecycle actor is missing: ${actorGeneralId}`);
|
||||||
|
if (!nation) throw new Error(`domain lifecycle nation is missing: ${nationId}`);
|
||||||
|
return {
|
||||||
|
generalLastTurn: general.lastTurn,
|
||||||
|
nationOfficerLastTurn: nation.meta[`turn_last_${officerLevel}`],
|
||||||
|
nationCapitalRevision: nation.meta.capset,
|
||||||
|
nationCapset: nation.meta.capset,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireLastTurnRecord = (value: unknown, label: string): Record<string, unknown> => {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
|
throw new Error(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (typeof record.command !== 'string' || record.command === '') {
|
||||||
|
throw new Error(`${label}.command must be a non-empty string`);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectSemanticLastTurn = (value: unknown, label: string) => {
|
||||||
|
const record = requireLastTurnRecord(value, label);
|
||||||
|
const finiteIntegerOrZero = (candidate: unknown): number =>
|
||||||
|
typeof candidate === 'number' && Number.isFinite(candidate) ? Math.floor(candidate) : 0;
|
||||||
|
const rawArg = record.arg ?? {};
|
||||||
|
return {
|
||||||
|
command: record.command,
|
||||||
|
// PHP's no-argument LastTurn serializes `arg` as [], while Core's typed
|
||||||
|
// command contract uses {}. A non-empty list remains significant.
|
||||||
|
arg: Array.isArray(rawArg) && rawArg.length === 0 ? {} : canonicalizeTurnCommandArgs(rawArg),
|
||||||
|
term: finiteIntegerOrZero(record.term),
|
||||||
|
seq: finiteIntegerOrZero(record.seq),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectReferenceOutcomeLastTurn = (outcome: unknown): unknown => {
|
||||||
|
const record = asRecord(outcome);
|
||||||
|
return record.lastTurn;
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareCanonicalGeneralTurns = (left: Record<string, unknown>, right: Record<string, unknown>): number =>
|
||||||
|
Number(left.generalId) - Number(right.generalId) || Number(left.turnIndex) - Number(right.turnIndex);
|
||||||
|
|
||||||
|
const compareCanonicalNationTurns = (left: Record<string, unknown>, right: Record<string, unknown>): number =>
|
||||||
|
Number(left.nationId) - Number(right.nationId) ||
|
||||||
|
Number(left.officerLevel) - Number(right.officerLevel) ||
|
||||||
|
Number(left.turnIndex) - Number(right.turnIndex);
|
||||||
|
|
||||||
|
databaseIntegration('risk-based command PostgreSQL durability matrix', () => {
|
||||||
|
let db: GamePrismaClient | undefined;
|
||||||
|
let disconnect: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
assertDedicatedTurnCommandDurableMatrixDatabase(databaseUrl!);
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
disconnect = () => connector.disconnect();
|
||||||
|
await clearCoreTurnCommandPersistenceFixture(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
if (db) await clearCoreTurnCommandPersistenceFixture(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
try {
|
||||||
|
if (db) await clearCoreTurnCommandPersistenceFixture(db);
|
||||||
|
} finally {
|
||||||
|
await disconnect?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(riskMatrixCases)(
|
||||||
|
'$scope $risk $action ($label) matches Ref/Core, commits through one flush, and survives a fresh PostgreSQL reload',
|
||||||
|
async (entry) => {
|
||||||
|
if (!db) throw new Error('fixture database is not connected');
|
||||||
|
const request = buildRiskMatrixRequest(entry);
|
||||||
|
const selector = request.observe as TurnSnapshotSelector;
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const expected = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
expect(expected.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: entry.action,
|
||||||
|
actionKey: entry.action,
|
||||||
|
usedFallback: false,
|
||||||
|
});
|
||||||
|
expect(expected.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, expected.before, expected.after, {
|
||||||
|
ignoredPathPatterns: lifecycleIgnoredPaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
const unitSet = await loadUnitSetDefinitionByName('che');
|
||||||
|
const map = await loadMapDefinitionByName('che');
|
||||||
|
const worldInput = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map);
|
||||||
|
const actorBefore = reference.before.generals.find(
|
||||||
|
(generalRow) => generalRow.id === request.actorGeneralId
|
||||||
|
);
|
||||||
|
if (!actorBefore) throw new Error('fixture actor is missing from the reference before snapshot');
|
||||||
|
const actorNationId = Number(actorBefore.nationId);
|
||||||
|
const actorOfficerLevel = Number(actorBefore.officerLevel);
|
||||||
|
if (!Number.isSafeInteger(actorNationId) || !Number.isSafeInteger(actorOfficerLevel)) {
|
||||||
|
throw new Error('fixture actor nation/officer identity is invalid');
|
||||||
|
}
|
||||||
|
const validatesMinimumChiefBoundary = entry.action === 'che_물자원조';
|
||||||
|
if (validatesMinimumChiefBoundary) {
|
||||||
|
expect(actorOfficerLevel).toBe(5);
|
||||||
|
expect(
|
||||||
|
expected.before.generals.find((generalRow) => generalRow.id === siblingRulerGeneralId)
|
||||||
|
).toMatchObject({ nationId: actorNationId, officerLevel: 12 });
|
||||||
|
}
|
||||||
|
const siblingNationTurnRevisionSentinel = buildSiblingNationTurnRevisionSentinel(
|
||||||
|
actorNationId,
|
||||||
|
actorOfficerLevel
|
||||||
|
);
|
||||||
|
const expectedSiblingTurnRevisionSentinels = {
|
||||||
|
general: siblingGeneralTurnRevisionSentinel,
|
||||||
|
nation: siblingNationTurnRevisionSentinel,
|
||||||
|
};
|
||||||
|
const expectedBeforeLifecycle = projectCanonicalCommandLifecycleState(
|
||||||
|
expected.before,
|
||||||
|
request.actorGeneralId,
|
||||||
|
actorNationId,
|
||||||
|
actorOfficerLevel
|
||||||
|
);
|
||||||
|
const expectedAfterLifecycle = projectCanonicalCommandLifecycleState(
|
||||||
|
expected.after,
|
||||||
|
request.actorGeneralId,
|
||||||
|
actorNationId,
|
||||||
|
actorOfficerLevel
|
||||||
|
);
|
||||||
|
requireLastTurnRecord(expectedAfterLifecycle.generalLastTurn, 'expected actor lastTurn');
|
||||||
|
requireLastTurnRecord(expectedAfterLifecycle.nationOfficerLastTurn, 'expected officer turn_last');
|
||||||
|
const referenceAfterLifecycle = projectCanonicalCommandLifecycleState(
|
||||||
|
reference.after,
|
||||||
|
request.actorGeneralId,
|
||||||
|
actorNationId,
|
||||||
|
actorOfficerLevel
|
||||||
|
);
|
||||||
|
if (entry.scope === 'general') {
|
||||||
|
expect(
|
||||||
|
projectSemanticLastTurn(referenceAfterLifecycle.generalLastTurn, 'Ref actor lastTurn')
|
||||||
|
).toStrictEqual(projectSemanticLastTurn(expectedAfterLifecycle.generalLastTurn, 'Core actor lastTurn'));
|
||||||
|
expect(expectedAfterLifecycle.nationOfficerLastTurn).toStrictEqual({ command: '휴식', term: 0 });
|
||||||
|
} else {
|
||||||
|
// Ref snapshots project nation.aux but not nation_env. The trace
|
||||||
|
// outcome is the exact resultTurnRaw value written to
|
||||||
|
// nation_env.turn_last_<officerLevel> by the comparison harness.
|
||||||
|
expect(
|
||||||
|
projectSemanticLastTurn(
|
||||||
|
projectReferenceOutcomeLastTurn(reference.execution.outcome),
|
||||||
|
'Ref officer turn_last outcome'
|
||||||
|
)
|
||||||
|
).toStrictEqual(
|
||||||
|
projectSemanticLastTurn(expectedAfterLifecycle.nationOfficerLastTurn, 'Core officer turn_last')
|
||||||
|
);
|
||||||
|
expect(expectedAfterLifecycle.generalLastTurn).toStrictEqual({ command: '휴식' });
|
||||||
|
}
|
||||||
|
if (entry.action === 'che_증축') {
|
||||||
|
const referenceBeforeLifecycle = projectCanonicalCommandLifecycleState(
|
||||||
|
reference.before,
|
||||||
|
request.actorGeneralId,
|
||||||
|
actorNationId,
|
||||||
|
actorOfficerLevel
|
||||||
|
);
|
||||||
|
// Ref persists capset as nation.capset; Core mirrors that column
|
||||||
|
// into both canonical capitalRevision and the domain meta value.
|
||||||
|
expect(referenceBeforeLifecycle.nationCapitalRevision).toBe(0);
|
||||||
|
expect(expectedBeforeLifecycle).toMatchObject({ nationCapitalRevision: 0, nationCapset: 0 });
|
||||||
|
expect(referenceAfterLifecycle.nationCapitalRevision).toBe(1);
|
||||||
|
expect(expectedAfterLifecycle).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 });
|
||||||
|
}
|
||||||
|
const expectedSiblingRulerLifecycle = validatesMinimumChiefBoundary
|
||||||
|
? projectCanonicalCommandLifecycleState(expected.before, siblingRulerGeneralId, actorNationId, 12)
|
||||||
|
: null;
|
||||||
|
if (expectedSiblingRulerLifecycle) {
|
||||||
|
expect(
|
||||||
|
projectCanonicalCommandLifecycleState(expected.after, siblingRulerGeneralId, actorNationId, 12)
|
||||||
|
).toStrictEqual(expectedSiblingRulerLifecycle);
|
||||||
|
expect(expectedSiblingRulerLifecycle.nationOfficerLastTurn).toStrictEqual({
|
||||||
|
command: '국호 변경',
|
||||||
|
arg: { nationName: '수뇌보존국' },
|
||||||
|
term: 7,
|
||||||
|
seq: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await seedCoreTurnCommandPersistenceFixture(db, {
|
||||||
|
worldInput,
|
||||||
|
scenarioCode: 'turn-command-durable-matrix',
|
||||||
|
generalTurns: reference.before.generalTurns.map((turn) =>
|
||||||
|
entry.scope === 'general' && turn.generalId === request.actorGeneralId && turn.turnIndex === 0
|
||||||
|
? { ...turn, args: resolveCoreTurnCommandArgs(request) }
|
||||||
|
: turn
|
||||||
|
),
|
||||||
|
nationTurns: reference.before.nationTurns.map((turn) =>
|
||||||
|
entry.scope === 'nation' &&
|
||||||
|
turn.nationId === actorBefore.nationId &&
|
||||||
|
turn.officerLevel === actorBefore.officerLevel &&
|
||||||
|
turn.turnIndex === 0
|
||||||
|
? { ...turn, args: resolveCoreTurnCommandArgs(request) }
|
||||||
|
: turn
|
||||||
|
),
|
||||||
|
});
|
||||||
|
// Keep the active daemon owner on both sentinels deliberately. A
|
||||||
|
// flush that releases leases by owner instead of by exact queue
|
||||||
|
// key would corrupt these unrelated command streams.
|
||||||
|
await db.generalTurnRevision.create({ data: siblingGeneralTurnRevisionSentinel });
|
||||||
|
await db.nationTurnRevision.create({ data: siblingNationTurnRevisionSentinel });
|
||||||
|
expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual(
|
||||||
|
expectedSiblingTurnRevisionSentinels
|
||||||
|
);
|
||||||
|
const forbiddenSideEffectsBefore = await readForbiddenSideEffects(db);
|
||||||
|
const databaseBefore = await readCoreDatabaseSnapshot(databaseUrl!, selector);
|
||||||
|
expect(
|
||||||
|
projectCanonicalCommandLifecycleState(
|
||||||
|
databaseBefore,
|
||||||
|
request.actorGeneralId,
|
||||||
|
actorNationId,
|
||||||
|
actorOfficerLevel
|
||||||
|
)
|
||||||
|
).toStrictEqual(expectedBeforeLifecycle);
|
||||||
|
const siblingRulerTurnQueueBefore = validatesMinimumChiefBoundary
|
||||||
|
? databaseBefore.nationTurns.filter(
|
||||||
|
(turn) => turn.nationId === actorNationId && turn.officerLevel === 12
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (expectedSiblingRulerLifecycle) {
|
||||||
|
expect(siblingRulerTurnQueueBefore).toHaveLength(12);
|
||||||
|
expect(
|
||||||
|
projectCanonicalCommandLifecycleState(databaseBefore, siblingRulerGeneralId, actorNationId, 12)
|
||||||
|
).toStrictEqual(expectedSiblingRulerLifecycle);
|
||||||
|
}
|
||||||
|
if (entry.action === 'che_증축') {
|
||||||
|
expect(
|
||||||
|
projectCanonicalCommandLifecycleState(
|
||||||
|
databaseBefore,
|
||||||
|
request.actorGeneralId,
|
||||||
|
actorNationId,
|
||||||
|
actorOfficerLevel
|
||||||
|
)
|
||||||
|
).toMatchObject({ nationCapitalRevision: 0, nationCapset: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||||
|
const reservedTurns = new InMemoryReservedTurnStore(db, {
|
||||||
|
maxGeneralTurns: 30,
|
||||||
|
maxNationTurns: 12,
|
||||||
|
leaseOwner,
|
||||||
|
leaseDurationMs: 60_000,
|
||||||
|
});
|
||||||
|
await reservedTurns.loadAll();
|
||||||
|
const loadedActor = loaded.snapshot.generals.find((generalRow) => generalRow.id === request.actorGeneralId);
|
||||||
|
if (!loadedActor) throw new Error('fixture actor is missing after database load');
|
||||||
|
if (validatesMinimumChiefBoundary) {
|
||||||
|
expect(
|
||||||
|
loaded.snapshot.generals.find((generalRow) => generalRow.id === siblingRulerGeneralId)
|
||||||
|
).toMatchObject({ nationId: actorNationId, officerLevel: 12 });
|
||||||
|
}
|
||||||
|
await reservedTurns.prepareTurnsForExecution(loadedActor.id, {
|
||||||
|
nationId: loadedActor.nationId,
|
||||||
|
officerLevel: loadedActor.officerLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedActions: Array<{ kind: string; requestedAction: string; usedFallback: boolean }> = [];
|
||||||
|
let world: InMemoryTurnWorld | null = null;
|
||||||
|
const gameNow = new Date(String(reference.before.world.gameNow));
|
||||||
|
if (!Number.isFinite(gameNow.getTime())) {
|
||||||
|
throw new Error(`reference world.gameNow is invalid: ${String(reference.before.world.gameNow)}`);
|
||||||
|
}
|
||||||
|
const handler = await createReservedTurnHandler({
|
||||||
|
reservedTurns,
|
||||||
|
scenarioConfig: loaded.snapshot.scenarioConfig,
|
||||||
|
scenarioMeta: loaded.snapshot.scenarioMeta,
|
||||||
|
map: loaded.snapshot.map,
|
||||||
|
unitSet: loaded.snapshot.unitSet,
|
||||||
|
getWorld: () => world,
|
||||||
|
now: () => new Date(gameNow.getTime()),
|
||||||
|
messageSharedIconBaseUrl: request.setup?.world?.messageSharedIconBaseUrl,
|
||||||
|
commandProfile: createCoreTurnCommandProfile(request),
|
||||||
|
onActionResolved: (resolved) => {
|
||||||
|
resolvedActions.push({
|
||||||
|
kind: resolved.kind,
|
||||||
|
requestedAction: resolved.requestedAction,
|
||||||
|
usedFallback: resolved.usedFallback,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||||
|
schedule: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
startMinute: 0,
|
||||||
|
tickMinutes: Math.max(1, Math.round(loaded.state.tickSeconds / 60)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
generalTurnHandler: handler,
|
||||||
|
});
|
||||||
|
const actor = world.getGeneralById(request.actorGeneralId);
|
||||||
|
if (!actor) throw new Error('fixture actor is missing from executable world');
|
||||||
|
world.executeGeneralTurn(actor);
|
||||||
|
expect(resolvedActions).toContainEqual({
|
||||||
|
kind: entry.scope,
|
||||||
|
requestedAction: entry.action,
|
||||||
|
usedFallback: false,
|
||||||
|
});
|
||||||
|
const liveAfterLifecycle = projectDomainCommandLifecycleState(
|
||||||
|
{ generals: world.listGenerals(), nations: world.listNations() },
|
||||||
|
request.actorGeneralId,
|
||||||
|
actor.nationId,
|
||||||
|
actor.officerLevel
|
||||||
|
);
|
||||||
|
expect(liveAfterLifecycle).toStrictEqual(expectedAfterLifecycle);
|
||||||
|
if (expectedSiblingRulerLifecycle) {
|
||||||
|
expect(
|
||||||
|
projectDomainCommandLifecycleState(
|
||||||
|
{ generals: world.listGenerals(), nations: world.listNations() },
|
||||||
|
siblingRulerGeneralId,
|
||||||
|
actor.nationId,
|
||||||
|
12
|
||||||
|
)
|
||||||
|
).toStrictEqual(expectedSiblingRulerLifecycle);
|
||||||
|
}
|
||||||
|
if (entry.action === 'che_증축') {
|
||||||
|
expect(liveAfterLifecycle).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 });
|
||||||
|
}
|
||||||
|
if (entry.action === 'che_훈련') {
|
||||||
|
const expectedActorAfter = expected.after.generals.find(
|
||||||
|
(generalRow) => generalRow.id === request.actorGeneralId
|
||||||
|
);
|
||||||
|
const dirtyActor = world
|
||||||
|
.peekDirtyState()
|
||||||
|
.generals.find((generalRow) => generalRow.id === request.actorGeneralId);
|
||||||
|
expect(dirtyActor?.role.items.weapon).toBe(expectedActorAfter?.itemWeapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeFlush = await readCoreDatabaseSnapshot(databaseUrl!, selector);
|
||||||
|
expect(withoutVolatileGameNow(beforeFlush)).toStrictEqual(withoutVolatileGameNow(databaseBefore));
|
||||||
|
expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual(
|
||||||
|
expectedSiblingTurnRevisionSentinels
|
||||||
|
);
|
||||||
|
|
||||||
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
|
||||||
|
try {
|
||||||
|
if (!hooks.hooks.flushChanges) throw new Error('database turn hooks do not expose flushChanges');
|
||||||
|
await hooks.hooks.flushChanges({
|
||||||
|
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||||
|
processedGenerals: 1,
|
||||||
|
processedTurns: 1,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
const receipt = hooks.takeCommittedReadModelChangeReceipt();
|
||||||
|
if (!receipt) throw new Error('flush did not publish a read-model change receipt');
|
||||||
|
expect(receipt.invalidation.revisions.length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
await db.readModelRevision.findMany({
|
||||||
|
select: { domain: true, entityId: true, revision: true },
|
||||||
|
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
|
||||||
|
})
|
||||||
|
).toEqual(receipt.invalidation.revisions);
|
||||||
|
expect(await db.readModelOutbox.count()).toBe(1);
|
||||||
|
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await hooks.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expectNoDirtyWorldChanges(world);
|
||||||
|
expect(reservedTurns.peekDirtyState()).toStrictEqual({
|
||||||
|
generalIds: [],
|
||||||
|
generalInitializationIds: [],
|
||||||
|
generalLeaseIds: [],
|
||||||
|
nationKeys: [],
|
||||||
|
nationInitializationKeys: [],
|
||||||
|
nationLeaseKeys: [],
|
||||||
|
});
|
||||||
|
expect(await readForbiddenSideEffects(db)).toEqual(forbiddenSideEffectsBefore);
|
||||||
|
expect(await db.inputEvent.count()).toBe(0);
|
||||||
|
expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual(
|
||||||
|
expectedSiblingTurnRevisionSentinels
|
||||||
|
);
|
||||||
|
|
||||||
|
const after = await readCoreDatabaseSnapshot(databaseUrl!, selector);
|
||||||
|
const expectedPersistedGeneralTurns = [
|
||||||
|
...databaseBefore.generalTurns.filter((turn) => turn.generalId !== request.actorGeneralId),
|
||||||
|
...expected.after.generalTurns.filter((turn) => turn.generalId === request.actorGeneralId),
|
||||||
|
].sort(compareCanonicalGeneralTurns);
|
||||||
|
const expectedPersistedNationTurns = [
|
||||||
|
...databaseBefore.nationTurns.filter(
|
||||||
|
(turn) => turn.nationId !== actor.nationId || turn.officerLevel !== actor.officerLevel
|
||||||
|
),
|
||||||
|
...expected.after.nationTurns.filter(
|
||||||
|
(turn) => turn.nationId === actor.nationId && turn.officerLevel === actor.officerLevel
|
||||||
|
),
|
||||||
|
].sort(compareCanonicalNationTurns);
|
||||||
|
expect(after.generalTurns).toStrictEqual(expectedPersistedGeneralTurns);
|
||||||
|
expect(after.nationTurns).toStrictEqual(expectedPersistedNationTurns);
|
||||||
|
if (expectedSiblingRulerLifecycle) {
|
||||||
|
expect(
|
||||||
|
after.nationTurns.filter((turn) => turn.nationId === actor.nationId && turn.officerLevel === 12)
|
||||||
|
).toStrictEqual(siblingRulerTurnQueueBefore);
|
||||||
|
expect(
|
||||||
|
projectCanonicalCommandLifecycleState(after, siblingRulerGeneralId, actor.nationId, 12)
|
||||||
|
).toStrictEqual(expectedSiblingRulerLifecycle);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
projectCanonicalCommandLifecycleState(after, request.actorGeneralId, actor.nationId, actor.officerLevel)
|
||||||
|
).toStrictEqual(expectedAfterLifecycle);
|
||||||
|
if (entry.action === 'che_증축') {
|
||||||
|
expect(
|
||||||
|
projectCanonicalCommandLifecycleState(
|
||||||
|
after,
|
||||||
|
request.actorGeneralId,
|
||||||
|
actor.nationId,
|
||||||
|
actor.officerLevel
|
||||||
|
)
|
||||||
|
).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 });
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
await db.generalTurnRevision.findMany({
|
||||||
|
select: { generalId: true, revision: true, leaseOwner: true, leaseExpiresAt: true },
|
||||||
|
orderBy: { generalId: 'asc' },
|
||||||
|
})
|
||||||
|
).toStrictEqual([
|
||||||
|
{ generalId: request.actorGeneralId, revision: 1, leaseOwner: null, leaseExpiresAt: null },
|
||||||
|
siblingGeneralTurnRevisionSentinel,
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
await db.nationTurnRevision.findMany({
|
||||||
|
select: {
|
||||||
|
nationId: true,
|
||||||
|
officerLevel: true,
|
||||||
|
revision: true,
|
||||||
|
leaseOwner: true,
|
||||||
|
leaseExpiresAt: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }],
|
||||||
|
})
|
||||||
|
).toStrictEqual([
|
||||||
|
{
|
||||||
|
nationId: actor.nationId,
|
||||||
|
officerLevel: actor.officerLevel,
|
||||||
|
revision: 1,
|
||||||
|
leaseOwner: null,
|
||||||
|
leaseExpiresAt: null,
|
||||||
|
},
|
||||||
|
siblingNationTurnRevisionSentinel,
|
||||||
|
]);
|
||||||
|
if (entry.action === 'che_물자원조') {
|
||||||
|
const sourceNation = after.nations.find((nation) => nation.id === 1);
|
||||||
|
const destinationNation = after.nations.find((nation) => nation.id === 2);
|
||||||
|
expect(sourceNation).toMatchObject({ gold: 999_900, rice: 999_800 });
|
||||||
|
expect(destinationNation).toMatchObject({ gold: 1_000_100, rice: 1_000_200 });
|
||||||
|
expect(asRecord(sourceNation?.meta).surlimit).toBe(12);
|
||||||
|
expect(asRecord(asRecord(destinationNation?.meta).recv_assist).n1).toEqual([1, 300]);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, databaseBefore, after, {
|
||||||
|
ignoredPathPatterns: lifecycleIgnoredPaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(expected.before, expected.after, databaseBefore, after, {
|
||||||
|
ignoredPathPatterns: lifecycleIgnoredPaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
const trailingDefaultGeneralRestLogs = after.logs.filter(isTrailingDefaultGeneralRestLog);
|
||||||
|
if (entry.scope === 'nation') {
|
||||||
|
// The production lifecycle always follows an officer's nation command with
|
||||||
|
// that officer's general queue. turn_command_trace.php intentionally executes
|
||||||
|
// only the requested command, so assert and remove this one known harness delta.
|
||||||
|
expect(trailingDefaultGeneralRestLogs).toHaveLength(1);
|
||||||
|
} else {
|
||||||
|
expect(trailingDefaultGeneralRestLogs).toHaveLength(0);
|
||||||
|
}
|
||||||
|
const comparablePersistedLogs = after.logs.filter(
|
||||||
|
(log) => entry.scope !== 'nation' || !isTrailingDefaultGeneralRestLog(log)
|
||||||
|
);
|
||||||
|
expect(orderedSemanticLogStreams(comparablePersistedLogs)).toEqual(
|
||||||
|
orderedSemanticLogStreams(addedReferenceLogs(reference.before, reference.after.logs))
|
||||||
|
);
|
||||||
|
expect(projectDatabaseIndependentTurnMessages(after.messages, 0)).toEqual(
|
||||||
|
projectDatabaseIndependentTurnMessages(reference.after.messages, reference.before.watermarks.messageId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const committedWorldGraph = projectReloadableWorldGraph(
|
||||||
|
{
|
||||||
|
generals: world.listGenerals(),
|
||||||
|
cities: world.listCities(),
|
||||||
|
nations: world.listNations(),
|
||||||
|
troops: world.listTroops(),
|
||||||
|
diplomacy: world.listDiplomacy(),
|
||||||
|
},
|
||||||
|
selector
|
||||||
|
);
|
||||||
|
const reloadedWorld = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||||
|
expect(projectReloadableWorldGraph(reloadedWorld.snapshot, selector)).toStrictEqual(committedWorldGraph);
|
||||||
|
const reloadedLifecycle = projectDomainCommandLifecycleState(
|
||||||
|
reloadedWorld.snapshot,
|
||||||
|
request.actorGeneralId,
|
||||||
|
actor.nationId,
|
||||||
|
actor.officerLevel
|
||||||
|
);
|
||||||
|
expect(reloadedLifecycle).toStrictEqual(expectedAfterLifecycle);
|
||||||
|
if (expectedSiblingRulerLifecycle) {
|
||||||
|
expect(
|
||||||
|
projectDomainCommandLifecycleState(
|
||||||
|
reloadedWorld.snapshot,
|
||||||
|
siblingRulerGeneralId,
|
||||||
|
actor.nationId,
|
||||||
|
12
|
||||||
|
)
|
||||||
|
).toStrictEqual(expectedSiblingRulerLifecycle);
|
||||||
|
}
|
||||||
|
if (entry.action === 'che_증축') {
|
||||||
|
expect(reloadedLifecycle).toMatchObject({ nationCapitalRevision: 1, nationCapset: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 30, maxNationTurns: 12 });
|
||||||
|
await reloadedTurns.loadAll();
|
||||||
|
expect(projectReloadedGeneralTurns(reloadedTurns, request.actorGeneralId)).toStrictEqual(
|
||||||
|
expected.after.generalTurns.filter((turn) => turn.generalId === request.actorGeneralId)
|
||||||
|
);
|
||||||
|
expect(projectReloadedNationTurns(reloadedTurns, actor.nationId, actor.officerLevel)).toStrictEqual(
|
||||||
|
expected.after.nationTurns.filter(
|
||||||
|
(turn) => turn.nationId === actor.nationId && turn.officerLevel === actor.officerLevel
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (expectedSiblingRulerLifecycle) {
|
||||||
|
expect(projectReloadedNationTurns(reloadedTurns, actor.nationId, 12)).toStrictEqual(
|
||||||
|
siblingRulerTurnQueueBefore
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(await readSiblingTurnRevisionSentinels(db, siblingNationTurnRevisionSentinel)).toStrictEqual(
|
||||||
|
expectedSiblingTurnRevisionSentinels
|
||||||
|
);
|
||||||
|
},
|
||||||
|
180_000
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -39,6 +39,13 @@ const databaseSnapshot = (
|
|||||||
cityId: 1,
|
cityId: 1,
|
||||||
troopId: 1,
|
troopId: 1,
|
||||||
userId: 'owner-a',
|
userId: 'owner-a',
|
||||||
|
personalCode: 'che_안전',
|
||||||
|
specialCode: 'che_농업',
|
||||||
|
special2Code: 'che_신산',
|
||||||
|
horseCode: 'che_명마_01_적토마',
|
||||||
|
weaponCode: 'che_무기_07_맥궁',
|
||||||
|
bookCode: 'che_서적_01_손자병법',
|
||||||
|
itemCode: 'che_도구_01_옥새',
|
||||||
meta: commandStateFixture?.generalMeta ?? {},
|
meta: commandStateFixture?.generalMeta ?? {},
|
||||||
penalty: {},
|
penalty: {},
|
||||||
...commandStateFixture?.generalFields,
|
...commandStateFixture?.generalFields,
|
||||||
@@ -92,6 +99,18 @@ const databaseSnapshot = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('turn snapshot canonical blind-spot coverage', () => {
|
describe('turn snapshot canonical blind-spot coverage', () => {
|
||||||
|
it('projects Prisma general role and item column names into canonical fields', () => {
|
||||||
|
expect(databaseSnapshot().generals[0]).toMatchObject({
|
||||||
|
personality: 'che_안전',
|
||||||
|
specialDomestic: 'che_농업',
|
||||||
|
specialWar: 'che_신산',
|
||||||
|
itemHorse: 'che_명마_01_적토마',
|
||||||
|
itemWeapon: 'che_무기_07_맥궁',
|
||||||
|
itemBook: 'che_서적_01_손자병법',
|
||||||
|
itemExtra: 'che_도구_01_옥새',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('projects troop rows and detects a troop mutant', () => {
|
it('projects troop rows and detects a troop mutant', () => {
|
||||||
const reference = databaseSnapshot();
|
const reference = databaseSnapshot();
|
||||||
const core = {
|
const core = {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ node_tag=$(printf '%s' "${CI_NODE_INDEX:-local}" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z
|
|||||||
run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag}
|
run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag}
|
||||||
export CONDITIONAL_INTEGRATION_RUN_ID=$run_id
|
export CONDITIONAL_INTEGRATION_RUN_ID=$run_id
|
||||||
schema_ownership_token="sammo-conditional-integration:$run_id"
|
schema_ownership_token="sammo-conditional-integration:$run_id"
|
||||||
supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_full_lifecycle reference_live_sortie reference_npc_possession select_pool web_push_gateway"
|
supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_command_durable_matrix reference_full_lifecycle reference_live_sortie reference_npc_possession scenario_lifecycle security_transport select_pool web_push_gateway"
|
||||||
term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10}
|
term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10}
|
||||||
case "$term_grace_seconds" in
|
case "$term_grace_seconds" in
|
||||||
''|*[!0-9]*)
|
''|*[!0-9]*)
|
||||||
@@ -55,6 +55,7 @@ if [ "$term_grace_seconds" -lt 1 ] || [ "$term_grace_seconds" -gt 60 ]; then
|
|||||||
fi
|
fi
|
||||||
integration_schema=${CONDITIONAL_INTEGRATION_SCHEMA:-ci_${run_id}_integration}
|
integration_schema=${CONDITIONAL_INTEGRATION_SCHEMA:-ci_${run_id}_integration}
|
||||||
scenario_schema=${SCENARIO_SEED_INTEGRATION_SCHEMA:-ci_${run_id}_scenario_seed}
|
scenario_schema=${SCENARIO_SEED_INTEGRATION_SCHEMA:-ci_${run_id}_scenario_seed}
|
||||||
|
scenario_lifecycle_schema=${SCENARIO_LIFECYCLE_INTEGRATION_SCHEMA:-ci_${run_id}_scenario_lifecycle}
|
||||||
npc_possession_schema=${NPC_POSSESSION_INTEGRATION_SCHEMA:-ci_${run_id}_npc_possession_integration}
|
npc_possession_schema=${NPC_POSSESSION_INTEGRATION_SCHEMA:-ci_${run_id}_npc_possession_integration}
|
||||||
create_general_schema=${CREATE_GENERAL_INTEGRATION_SCHEMA:-ci_${run_id}_create_general_integration}
|
create_general_schema=${CREATE_GENERAL_INTEGRATION_SCHEMA:-ci_${run_id}_create_general_integration}
|
||||||
select_pool_schema=${SELECT_POOL_INTEGRATION_SCHEMA:-ci_${run_id}_select_pool_integration}
|
select_pool_schema=${SELECT_POOL_INTEGRATION_SCHEMA:-ci_${run_id}_select_pool_integration}
|
||||||
@@ -62,13 +63,16 @@ immediate_action_schema=${IMMEDIATE_ACTION_INTEGRATION_SCHEMA:-ci_${run_id}_imme
|
|||||||
gateway_runtime_schema=${GATEWAY_RUNTIME_INTEGRATION_SCHEMA:-ci_${run_id}_gateway_runtime_integration}
|
gateway_runtime_schema=${GATEWAY_RUNTIME_INTEGRATION_SCHEMA:-ci_${run_id}_gateway_runtime_integration}
|
||||||
web_push_gateway_schema=${WEB_PUSH_GATEWAY_INTEGRATION_SCHEMA:-ci_${run_id}_web_push_integration}
|
web_push_gateway_schema=${WEB_PUSH_GATEWAY_INTEGRATION_SCHEMA:-ci_${run_id}_web_push_integration}
|
||||||
read_model_journal_schema=${READ_MODEL_JOURNAL_INTEGRATION_SCHEMA:-ci_${run_id}_read_model_journal_integration}
|
read_model_journal_schema=${READ_MODEL_JOURNAL_INTEGRATION_SCHEMA:-ci_${run_id}_read_model_journal_integration}
|
||||||
|
security_transport_schema=${SECURITY_TRANSPORT_SCHEMA:-ci_${run_id}_security_transport}
|
||||||
npc_possession_differential_schema=${NPC_POSSESSION_DIFFERENTIAL_SCHEMA:-ci_${run_id}_npc_possession_differential}
|
npc_possession_differential_schema=${NPC_POSSESSION_DIFFERENTIAL_SCHEMA:-ci_${run_id}_npc_possession_differential}
|
||||||
live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-ci_${run_id}_live_sortie_persistence}
|
live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-ci_${run_id}_live_sortie_persistence}
|
||||||
|
turn_command_durable_matrix_schema=${TURN_COMMAND_DURABLE_MATRIX_SCHEMA:-ci_${run_id}_turn_command_durable_matrix}
|
||||||
turn_full_lifecycle_schema=${TURN_FULL_LIFECYCLE_PERSISTENCE_SCHEMA:-ci_${run_id}_turn_full_lifecycle_persistence}
|
turn_full_lifecycle_schema=${TURN_FULL_LIFECYCLE_PERSISTENCE_SCHEMA:-ci_${run_id}_turn_full_lifecycle_persistence}
|
||||||
|
|
||||||
for schema in \
|
for schema in \
|
||||||
"$integration_schema" \
|
"$integration_schema" \
|
||||||
"$scenario_schema" \
|
"$scenario_schema" \
|
||||||
|
"$scenario_lifecycle_schema" \
|
||||||
"$npc_possession_schema" \
|
"$npc_possession_schema" \
|
||||||
"$create_general_schema" \
|
"$create_general_schema" \
|
||||||
"$select_pool_schema" \
|
"$select_pool_schema" \
|
||||||
@@ -76,8 +80,10 @@ for schema in \
|
|||||||
"$gateway_runtime_schema" \
|
"$gateway_runtime_schema" \
|
||||||
"$web_push_gateway_schema" \
|
"$web_push_gateway_schema" \
|
||||||
"$read_model_journal_schema" \
|
"$read_model_journal_schema" \
|
||||||
|
"$security_transport_schema" \
|
||||||
"$npc_possession_differential_schema" \
|
"$npc_possession_differential_schema" \
|
||||||
"$live_sortie_schema" \
|
"$live_sortie_schema" \
|
||||||
|
"$turn_command_durable_matrix_schema" \
|
||||||
"$turn_full_lifecycle_schema"; do
|
"$turn_full_lifecycle_schema"; do
|
||||||
case "$schema" in
|
case "$schema" in
|
||||||
''|[!a-z_]*|*[!a-z0-9_]*)
|
''|[!a-z_]*|*[!a-z0-9_]*)
|
||||||
@@ -196,6 +202,7 @@ delete_owned_redis_keys() {
|
|||||||
const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID;
|
const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID;
|
||||||
const patterns = [
|
const patterns = [
|
||||||
`sammo:game:*:che:security-http-${runId}:*`,
|
`sammo:game:*:che:security-http-${runId}:*`,
|
||||||
|
`sammo:che:security-http-${runId}:*`,
|
||||||
`sammo:game:*:che:nation-html-${runId}:*`,
|
`sammo:game:*:che:nation-html-${runId}:*`,
|
||||||
`sammo:che:battle-sim-e2e-${runId}-*:battle-sim:*`,
|
`sammo:che:battle-sim-e2e-${runId}-*:battle-sim:*`,
|
||||||
];
|
];
|
||||||
@@ -443,14 +450,14 @@ run_marked_tests() {
|
|||||||
|
|
||||||
run_redis_only_tests() {
|
run_redis_only_tests() {
|
||||||
package_dir=app/game-api
|
package_dir=app/game-api
|
||||||
database_marker=$1
|
database_markers=$1
|
||||||
test_files=$(
|
test_files=$(
|
||||||
cd "$workspace_root/$package_dir"
|
cd "$workspace_root/$package_dir"
|
||||||
redis_files=$(rg -l 'process\.env\.REDIS_URL' test -g '*.integration.test.ts' | sort)
|
redis_files=$(rg -l 'process\.env\.REDIS_URL' test -g '*.integration.test.ts' | sort)
|
||||||
# Files already selected through a database marker run once in that
|
# Files already selected through a database marker run once in that
|
||||||
# database group, where Redis is also available.
|
# database group, where Redis is also available.
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
rg --files-without-match "$database_marker" $redis_files
|
rg --files-without-match "$database_markers" $redis_files
|
||||||
)
|
)
|
||||||
if [ -z "$test_files" ]; then
|
if [ -z "$test_files" ]; then
|
||||||
echo "no Redis-only integration tests found under $package_dir" >&2
|
echo "no Redis-only integration tests found under $package_dir" >&2
|
||||||
@@ -473,11 +480,14 @@ pnpm --filter @sammo-ts/game-engine build
|
|||||||
|
|
||||||
GATEWAY_MIGRATION_TEST_DATABASE_URL=$base_database_url \
|
GATEWAY_MIGRATION_TEST_DATABASE_URL=$base_database_url \
|
||||||
pnpm --filter @sammo-ts/infra verify:migration:account-icon
|
pnpm --filter @sammo-ts/infra verify:migration:account-icon
|
||||||
|
GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL=$base_database_url \
|
||||||
|
pnpm --filter @sammo-ts/infra verify:migration:outbox-utc
|
||||||
|
|
||||||
cleanup_resources_started=1
|
cleanup_resources_started=1
|
||||||
create_owned_schema "$integration_schema"
|
create_owned_schema "$integration_schema"
|
||||||
create_owned_schema "$npc_possession_schema"
|
create_owned_schema "$npc_possession_schema"
|
||||||
create_owned_schema "$scenario_schema"
|
create_owned_schema "$scenario_schema"
|
||||||
|
create_owned_schema "$scenario_lifecycle_schema"
|
||||||
|
|
||||||
database_url=$(build_database_url "$integration_schema")
|
database_url=$(build_database_url "$integration_schema")
|
||||||
export POSTGRES_SCHEMA=$integration_schema
|
export POSTGRES_SCHEMA=$integration_schema
|
||||||
@@ -503,6 +513,17 @@ run_marked_tests app/game-api "$core_database_markers" "game_api_postgresql"
|
|||||||
run_marked_tests app/game-engine "$core_database_markers" "game_engine_postgresql"
|
run_marked_tests app/game-engine "$core_database_markers" "game_engine_postgresql"
|
||||||
run_marked_tests tools/integration-tests "$core_database_markers" "snapshot_postgresql"
|
run_marked_tests tools/integration-tests "$core_database_markers" "snapshot_postgresql"
|
||||||
|
|
||||||
|
scenario_lifecycle_database_url=$(build_database_url "$scenario_lifecycle_schema")
|
||||||
|
(
|
||||||
|
export POSTGRES_SCHEMA=$scenario_lifecycle_schema
|
||||||
|
export DATABASE_URL=$scenario_lifecycle_database_url
|
||||||
|
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game
|
||||||
|
)
|
||||||
|
export SCENARIO_LIFECYCLE_DATABASE_URL=$scenario_lifecycle_database_url
|
||||||
|
run_marked_tests tools/integration-tests \
|
||||||
|
"$(markers_for_mode scenario_lifecycle)" \
|
||||||
|
"scenario_lifecycle_postgresql"
|
||||||
|
|
||||||
create_owned_schema "$read_model_journal_schema"
|
create_owned_schema "$read_model_journal_schema"
|
||||||
read_model_journal_database_url=$(build_database_url "$read_model_journal_schema")
|
read_model_journal_database_url=$(build_database_url "$read_model_journal_schema")
|
||||||
(
|
(
|
||||||
@@ -518,6 +539,22 @@ run_marked_tests app/game-engine \
|
|||||||
"$(markers_for_mode read_model_journal)" \
|
"$(markers_for_mode read_model_journal)" \
|
||||||
"read_model_journal_engine_postgresql"
|
"read_model_journal_engine_postgresql"
|
||||||
|
|
||||||
|
create_owned_schema "$security_transport_schema"
|
||||||
|
security_transport_database_url=$(build_database_url "$security_transport_schema")
|
||||||
|
(
|
||||||
|
export POSTGRES_SCHEMA=$security_transport_schema
|
||||||
|
export DATABASE_URL=$security_transport_database_url
|
||||||
|
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game
|
||||||
|
)
|
||||||
|
export POSTGRES_SCHEMA=$security_transport_schema
|
||||||
|
export DATABASE_URL=$security_transport_database_url
|
||||||
|
export SECURITY_TRANSPORT_DATABASE_URL=$security_transport_database_url
|
||||||
|
run_marked_tests app/game-api \
|
||||||
|
"$(markers_for_mode security_transport)" \
|
||||||
|
"security_transport_postgresql"
|
||||||
|
export POSTGRES_SCHEMA=$integration_schema
|
||||||
|
export DATABASE_URL=$database_url
|
||||||
|
|
||||||
create_owned_schema "$create_general_schema"
|
create_owned_schema "$create_general_schema"
|
||||||
create_general_database_url=$(build_database_url "$create_general_schema")
|
create_general_database_url=$(build_database_url "$create_general_schema")
|
||||||
(
|
(
|
||||||
@@ -672,6 +709,20 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
|
|||||||
"$(markers_for_mode reference_live_sortie)" \
|
"$(markers_for_mode reference_live_sortie)" \
|
||||||
"live_sortie_postgresql"
|
"live_sortie_postgresql"
|
||||||
|
|
||||||
|
create_owned_schema "$turn_command_durable_matrix_schema"
|
||||||
|
turn_command_durable_matrix_database_url=$(build_database_url "$turn_command_durable_matrix_schema")
|
||||||
|
(
|
||||||
|
export POSTGRES_SCHEMA=$turn_command_durable_matrix_schema
|
||||||
|
export DATABASE_URL=$turn_command_durable_matrix_database_url
|
||||||
|
pnpm --filter @sammo-ts/infra prisma:db:push:game
|
||||||
|
)
|
||||||
|
export POSTGRES_SCHEMA=$turn_command_durable_matrix_schema
|
||||||
|
export DATABASE_URL=$turn_command_durable_matrix_database_url
|
||||||
|
export TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL=$turn_command_durable_matrix_database_url
|
||||||
|
run_marked_tests tools/integration-tests \
|
||||||
|
"$(markers_for_mode reference_command_durable_matrix)" \
|
||||||
|
"turn_command_durable_matrix_postgresql"
|
||||||
|
|
||||||
create_owned_schema "$turn_full_lifecycle_schema"
|
create_owned_schema "$turn_full_lifecycle_schema"
|
||||||
turn_full_lifecycle_database_url=$(build_database_url "$turn_full_lifecycle_schema")
|
turn_full_lifecycle_database_url=$(build_database_url "$turn_full_lifecycle_schema")
|
||||||
(
|
(
|
||||||
@@ -689,7 +740,8 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
|
|||||||
export DATABASE_URL=$database_url
|
export DATABASE_URL=$database_url
|
||||||
fi
|
fi
|
||||||
|
|
||||||
run_redis_only_tests "$core_database_markers"
|
all_database_markers=$(cut -f1 "$validated_registry_file" | paste -sd '|' -)
|
||||||
|
run_redis_only_tests "$all_database_markers"
|
||||||
|
|
||||||
scenario_database_url=$(build_database_url "$scenario_schema")
|
scenario_database_url=$(build_database_url "$scenario_schema")
|
||||||
export POSTGRES_SCHEMA=$scenario_schema
|
export POSTGRES_SCHEMA=$scenario_schema
|
||||||
|
|||||||
Reference in New Issue
Block a user