feat: 직접 writer를 내구성 변화 저널에 연결
86개 API mutation을 분류하고 메시지 mailbox, 베팅, 국가 설정, 예약 명령의 revision/outbox 표식을 소유 transaction에 연결한다. 공개 SSE는 식별자 없는 invalidation만 노출한다.
This commit is contained in:
@@ -124,7 +124,7 @@ const persistEffects = async (
|
|||||||
await persistLogs(db, logs, year, month, at);
|
await persistLogs(db, logs, year, month, at);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<void> => {
|
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
|
||||||
const [map, cities, diplomacy] = await Promise.all([
|
const [map, cities, diplomacy] = await Promise.all([
|
||||||
loadMapDefinitionByName(mapName),
|
loadMapDefinitionByName(mapName),
|
||||||
db.city.findMany({
|
db.city.findMany({
|
||||||
@@ -152,6 +152,7 @@ const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds
|
|||||||
data: { frontState: patch.frontState },
|
data: { frontState: patch.frontState },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return patches.map((patch) => patch.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildFailureLog = (generalId: number, reason: string, actionName: string, response: boolean): LogEntryDraft[] => {
|
const buildFailureLog = (generalId: number, reason: string, actionName: string, response: boolean): LogEntryDraft[] => {
|
||||||
@@ -167,6 +168,9 @@ export interface DiplomaticMessageResponseResult {
|
|||||||
result: boolean;
|
result: boolean;
|
||||||
reason: string;
|
reason: string;
|
||||||
affectedMailboxes: number[];
|
affectedMailboxes: number[];
|
||||||
|
affectedGeneralRecordIds: number[];
|
||||||
|
affectedNationIds: number[];
|
||||||
|
affectedCityIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const respondToDiplomaticMessage = async (options: {
|
export const respondToDiplomaticMessage = async (options: {
|
||||||
@@ -201,7 +205,14 @@ export const respondToDiplomaticMessage = async (options: {
|
|||||||
world.currentMonth,
|
world.currentMonth,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
return { result: false, reason, affectedMailboxes: [] };
|
return {
|
||||||
|
result: false,
|
||||||
|
reason,
|
||||||
|
affectedMailboxes: [],
|
||||||
|
affectedGeneralRecordIds: [actor.id],
|
||||||
|
affectedNationIds: [],
|
||||||
|
affectedCityIds: [],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const actorNationId = actor.nationId;
|
const actorNationId = actor.nationId;
|
||||||
@@ -266,6 +277,9 @@ export const respondToDiplomaticMessage = async (options: {
|
|||||||
result: true,
|
result: true,
|
||||||
reason: 'success',
|
reason: 'success',
|
||||||
affectedMailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId],
|
affectedMailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId],
|
||||||
|
affectedGeneralRecordIds: [actor.id, proposerGeneralId],
|
||||||
|
affectedNationIds: [],
|
||||||
|
affectedCityIds: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,11 +383,12 @@ export const respondToDiplomaticMessage = async (options: {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now);
|
await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now);
|
||||||
|
let affectedCityIds: number[] = [];
|
||||||
if (resolution.refreshFront) {
|
if (resolution.refreshFront) {
|
||||||
const worldConfig = asRecord(world.config);
|
const worldConfig = asRecord(world.config);
|
||||||
const environment = asRecord(worldConfig.environment);
|
const environment = asRecord(worldConfig.environment);
|
||||||
const mapName = typeof environment.mapName === 'string' ? environment.mapName : 'che';
|
const mapName = typeof environment.mapName === 'string' ? environment.mapName : 'che';
|
||||||
await refreshFrontStates(db, mapName, [actorNationId, proposerNationId]);
|
affectedCityIds = await refreshFrontStates(db, mapName, [actorNationId, proposerNationId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const proposerMessageNationName = message.payload.src.nationName;
|
const proposerMessageNationName = message.payload.src.nationName;
|
||||||
@@ -415,5 +430,8 @@ export const respondToDiplomaticMessage = async (options: {
|
|||||||
MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId,
|
MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId,
|
||||||
MESSAGE_MAILBOX_NATIONAL_BASE + proposerNationId,
|
MESSAGE_MAILBOX_NATIONAL_BASE + proposerNationId,
|
||||||
],
|
],
|
||||||
|
affectedGeneralRecordIds: [actor.id, proposerGeneralId],
|
||||||
|
affectedNationIds: [actorNationId, proposerNationId],
|
||||||
|
affectedCityIds,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
readModelOutboxPayloadToChanges,
|
readModelOutboxPayloadToChanges,
|
||||||
|
readModelOutboxPayloadToMessageMailboxes,
|
||||||
type ReadModelDomain,
|
type ReadModelDomain,
|
||||||
} from '@sammo-ts/common';
|
} from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
@@ -11,13 +12,14 @@ import {
|
|||||||
type RedisConnector,
|
type RedisConnector,
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { publishRealtimeReadModelChanges } from './publisher.js';
|
import { publishRealtimeMessageChanges, publishRealtimeReadModelChanges } from './publisher.js';
|
||||||
|
|
||||||
// access.general is an authoritative DB-only source revision. Tournament and
|
// Source-only/access keys and separately owned tournament/betting state must
|
||||||
// betting still have separate Redis-owned source revisions. None of the three
|
// not wake the legacy dashboard channel solely because an outbox row ran.
|
||||||
// should wake the legacy dashboard channel solely because its outbox row ran.
|
|
||||||
const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
|
const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
|
||||||
'access.general',
|
'access.general',
|
||||||
|
'dashboard.global',
|
||||||
|
'messages.mailbox',
|
||||||
'tournament',
|
'tournament',
|
||||||
'betting',
|
'betting',
|
||||||
]);
|
]);
|
||||||
@@ -83,11 +85,14 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
|
|||||||
const result = await dispatchReadModelOutboxBatch(
|
const result = await dispatchReadModelOutboxBatch(
|
||||||
this.db,
|
this.db,
|
||||||
async (payload) => {
|
async (payload) => {
|
||||||
if (payload.changes.every(([domain]) => NON_DASHBOARD_DOMAINS.has(domain))) {
|
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
||||||
return;
|
if (mailboxes.length > 0) {
|
||||||
|
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes);
|
||||||
|
}
|
||||||
|
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
|
||||||
|
const changes = readModelOutboxPayloadToChanges(payload);
|
||||||
|
await publishRealtimeReadModelChanges(this.redis, this.profileName, changes);
|
||||||
}
|
}
|
||||||
const changes = readModelOutboxPayloadToChanges(payload);
|
|
||||||
await publishRealtimeReadModelChanges(this.redis, this.profileName, changes);
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
owner: this.owner,
|
owner: this.owner,
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ export const toPublicRealtimeEvent = (
|
|||||||
const viewers = uniqueIdentities(
|
const viewers = uniqueIdentities(
|
||||||
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
||||||
);
|
);
|
||||||
if (event.type === 'messageCreated') {
|
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
||||||
return viewers.some((identity) => isMailboxRelevant(event.mailbox, identity))
|
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
|
||||||
|
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
||||||
? { type: 'messagesInvalidated' }
|
? { type: 'messagesInvalidated' }
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,3 +30,15 @@ export const publishRealtimeReadModelChanges = async (
|
|||||||
});
|
});
|
||||||
return revision;
|
return revision;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const publishRealtimeMessageChanges = async (
|
||||||
|
redis: RedisConnector['client'],
|
||||||
|
profileName: string,
|
||||||
|
mailboxes: readonly number[]
|
||||||
|
): Promise<void> => {
|
||||||
|
if (mailboxes.length === 0) return;
|
||||||
|
await publishRealtimeEvent(redis, profileName, {
|
||||||
|
type: 'messagesChanged',
|
||||||
|
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ export const bettingRouter = router({
|
|||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
ctx.changeJournal?.mark('general.content', general.id);
|
||||||
|
ctx.changeJournal?.mark('betting');
|
||||||
return { result: true };
|
return { result: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { asRecord } from '@sammo-ts/common';
|
|||||||
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||||
|
|
||||||
|
import type { GameApiContext } from '../../context.js';
|
||||||
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||||
import {
|
import {
|
||||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||||
@@ -22,7 +23,6 @@ import {
|
|||||||
insertMessage,
|
insertMessage,
|
||||||
type MessageView,
|
type MessageView,
|
||||||
} from '../../messages/store.js';
|
} from '../../messages/store.js';
|
||||||
import { publishRealtimeEvent } from '../../realtime/publisher.js';
|
|
||||||
import { getOwnedGeneral } from '../shared/general.js';
|
import { getOwnedGeneral } from '../shared/general.js';
|
||||||
import { resolveNationPermission } from '../nation/shared.js';
|
import { resolveNationPermission } from '../nation/shared.js';
|
||||||
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
||||||
@@ -72,6 +72,15 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
|
|||||||
return value === true || value === 1 || value === '1';
|
return value === true || value === 1 || value === '1';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const markMessageMailboxes = (
|
||||||
|
ctx: Pick<GameApiContext, 'changeJournal'>,
|
||||||
|
mailboxes: Iterable<number>
|
||||||
|
): void => {
|
||||||
|
for (const mailbox of mailboxes) {
|
||||||
|
ctx.changeJournal?.mark('messages.mailbox', mailbox);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const messagesRouter = router({
|
export const messagesRouter = router({
|
||||||
getRecent: accessLimitAuthedInputProcedure(
|
getRecent: accessLimitAuthedInputProcedure(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -298,6 +307,15 @@ export const messagesRouter = router({
|
|||||||
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
||||||
];
|
];
|
||||||
await invalidateMessages(ctx.db, ids);
|
await invalidateMessages(ctx.db, ids);
|
||||||
|
const receiverMailbox =
|
||||||
|
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
|
||||||
|
? message.payload.dest.generalId
|
||||||
|
: shouldDeleteReceiverCopy &&
|
||||||
|
typeof receiverMessageId === 'number' &&
|
||||||
|
message.msgType === 'national'
|
||||||
|
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
||||||
|
: null;
|
||||||
|
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
||||||
return { ok: true, deletedIds: ids };
|
return { ok: true, deletedIds: ids };
|
||||||
}),
|
}),
|
||||||
respond: authedProcedure
|
respond: authedProcedure
|
||||||
@@ -316,21 +334,21 @@ export const messagesRouter = router({
|
|||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
response: input.response,
|
response: input.response,
|
||||||
});
|
});
|
||||||
if (result.result) {
|
markMessageMailboxes(ctx, result.affectedMailboxes);
|
||||||
for (const mailbox of result.affectedMailboxes) {
|
for (const generalId of result.affectedGeneralRecordIds) {
|
||||||
try {
|
ctx.changeJournal?.mark('records.general', generalId);
|
||||||
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
}
|
||||||
type: 'messageCreated',
|
for (const nationId of result.affectedNationIds) {
|
||||||
at: new Date().toISOString(),
|
ctx.changeJournal?.mark('nation.content', nationId);
|
||||||
mailbox,
|
}
|
||||||
msgType: 'diplomacy',
|
for (const cityId of result.affectedCityIds) {
|
||||||
messageId: input.messageId,
|
ctx.changeJournal?.mark('city.content', cityId);
|
||||||
senderId: general.id,
|
}
|
||||||
});
|
if (result.affectedCityIds.length > 0) {
|
||||||
} catch {
|
ctx.changeJournal?.mark('map.world');
|
||||||
// 실시간 알림 실패는 외교 응답 실패로 취급하지 않는다.
|
}
|
||||||
}
|
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
|
||||||
}
|
ctx.changeJournal?.mark('dashboard.global');
|
||||||
}
|
}
|
||||||
return { result: result.result, reason: result.reason };
|
return { result: result.result, reason: result.reason };
|
||||||
}),
|
}),
|
||||||
@@ -522,18 +540,13 @@ export const messagesRouter = router({
|
|||||||
draft
|
draft
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
const senderMailbox =
|
||||||
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
result.senderId === undefined
|
||||||
type: 'messageCreated',
|
? null
|
||||||
at: now.toISOString(),
|
: msgType === 'private'
|
||||||
mailbox: receiverMailbox,
|
? general.id
|
||||||
msgType,
|
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
||||||
messageId: result.receiverId,
|
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]);
|
||||||
senderId: general.id,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다.
|
|
||||||
}
|
|
||||||
|
|
||||||
return { msgType, msgId: result.receiverId };
|
return { msgType, msgId: result.receiverId };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ export const setNotice = authedProcedure
|
|||||||
},
|
},
|
||||||
nationMeta
|
nationMeta
|
||||||
);
|
);
|
||||||
|
ctx.changeJournal?.mark('front.nation', me.nationId);
|
||||||
return { ok: true, msg };
|
return { ok: true, msg };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ export const assertNationEditable = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateNationMeta = async (
|
export const updateNationMeta = async (
|
||||||
ctx: Pick<GameApiContext, 'turnDaemon'>,
|
ctx: Pick<GameApiContext, 'turnDaemon' | 'changeJournal'>,
|
||||||
nationId: number,
|
nationId: number,
|
||||||
updates: Record<string, unknown>,
|
updates: Record<string, unknown>,
|
||||||
currentMeta: Record<string, unknown>
|
currentMeta: Record<string, unknown>
|
||||||
@@ -453,6 +453,8 @@ export const updateNationMeta = async (
|
|||||||
}
|
}
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||||
}
|
}
|
||||||
|
ctx.changeJournal?.mark('nation.content', nationId);
|
||||||
|
ctx.changeJournal?.mark('dashboard.global');
|
||||||
return {
|
return {
|
||||||
...currentMeta,
|
...currentMeta,
|
||||||
...updates,
|
...updates,
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ export const turnsRouter = router({
|
|||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
|
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
|
||||||
);
|
);
|
||||||
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
shiftGeneral: authedProcedure
|
shiftGeneral: authedProcedure
|
||||||
@@ -362,6 +363,7 @@ export const turnsRouter = router({
|
|||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
||||||
);
|
);
|
||||||
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
repeatGeneral: authedProcedure
|
repeatGeneral: authedProcedure
|
||||||
@@ -377,6 +379,7 @@ export const turnsRouter = router({
|
|||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
||||||
);
|
);
|
||||||
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
setGeneralBulk: authedProcedure
|
setGeneralBulk: authedProcedure
|
||||||
@@ -403,6 +406,7 @@ export const turnsRouter = router({
|
|||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
||||||
);
|
);
|
||||||
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
setNation: authedProcedure
|
setNation: authedProcedure
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const classifications = {
|
||||||
|
durableJournal: [
|
||||||
|
'betting.bet',
|
||||||
|
'messages.delete',
|
||||||
|
'messages.respond',
|
||||||
|
'messages.send',
|
||||||
|
'nation.setBill',
|
||||||
|
'nation.setBlockScout',
|
||||||
|
'nation.setBlockWar',
|
||||||
|
'nation.setNotice',
|
||||||
|
'nation.setRate',
|
||||||
|
'nation.setScoutMsg',
|
||||||
|
'nation.setSecretLimit',
|
||||||
|
'npc.setGeneralPriority',
|
||||||
|
'npc.setNationPolicy',
|
||||||
|
'npc.setNationPriority',
|
||||||
|
'turns.repeatGeneral',
|
||||||
|
'turns.setGeneral',
|
||||||
|
'turns.setGeneralBulk',
|
||||||
|
'turns.shiftGeneral',
|
||||||
|
'vote.closePoll',
|
||||||
|
'vote.createPoll',
|
||||||
|
'vote.submitVote',
|
||||||
|
'vote.updatePoll',
|
||||||
|
],
|
||||||
|
separateAccessJournal: ['public.recordAccess'],
|
||||||
|
explicitNoRealtimeConsumer: [
|
||||||
|
'board.writeArticle',
|
||||||
|
'board.writeComment',
|
||||||
|
'diplomacy.destroyLetter',
|
||||||
|
'diplomacy.respondLetter',
|
||||||
|
'diplomacy.rollbackLetter',
|
||||||
|
'diplomacy.sendLetter',
|
||||||
|
'inherit.checkOwner',
|
||||||
|
'join.getSelectionPool',
|
||||||
|
'join.listPossessCandidates',
|
||||||
|
'messages.readLatest',
|
||||||
|
'turns.repeatNation',
|
||||||
|
'turns.setNation',
|
||||||
|
'turns.setNationBulk',
|
||||||
|
'turns.shiftNation',
|
||||||
|
'vote.addComment',
|
||||||
|
],
|
||||||
|
engineOwned: [
|
||||||
|
'auction.bidBuyRice',
|
||||||
|
'auction.bidSellRice',
|
||||||
|
'auction.bidUnique',
|
||||||
|
'auction.openBuyRice',
|
||||||
|
'auction.openSellRice',
|
||||||
|
'auction.openUnique',
|
||||||
|
'general.adjustIcon',
|
||||||
|
'general.buildNationCandidate',
|
||||||
|
'general.dieOnPrestart',
|
||||||
|
'general.dropItem',
|
||||||
|
'general.ensureDieOnPrestartStatus',
|
||||||
|
'general.instantRetreat',
|
||||||
|
'general.setMySetting',
|
||||||
|
'general.vacation',
|
||||||
|
'inherit.openUniqueAuction',
|
||||||
|
'join.createGeneral',
|
||||||
|
'join.possessGeneral',
|
||||||
|
'join.reselectPoolGeneral',
|
||||||
|
'join.selectPoolGeneral',
|
||||||
|
'nation.appoint',
|
||||||
|
'nation.changePermission',
|
||||||
|
'nation.kick',
|
||||||
|
'troop.create',
|
||||||
|
'troop.exit',
|
||||||
|
'troop.join',
|
||||||
|
'troop.kick',
|
||||||
|
'troop.rename',
|
||||||
|
],
|
||||||
|
mixedSaga: [
|
||||||
|
'inherit.buyHiddenBuff',
|
||||||
|
'inherit.buyRandomUnique',
|
||||||
|
'inherit.resetSpecialWar',
|
||||||
|
'inherit.resetStat',
|
||||||
|
'inherit.resetTurnTime',
|
||||||
|
'inherit.setNextSpecialWar',
|
||||||
|
'tournament.cancel',
|
||||||
|
'tournament.join',
|
||||||
|
'tournament.placeBet',
|
||||||
|
],
|
||||||
|
redisProjection: [
|
||||||
|
'tournament.patchState',
|
||||||
|
'tournament.seedParticipants',
|
||||||
|
'tournament.setBettingEntries',
|
||||||
|
'tournament.setMatches',
|
||||||
|
'tournament.setParticipants',
|
||||||
|
'tournament.setState',
|
||||||
|
],
|
||||||
|
operational: ['turnDaemon.pause', 'turnDaemon.resume', 'turnDaemon.run'],
|
||||||
|
externalUpload: ['board.uploadImage'],
|
||||||
|
readOnlyMutationTransport: ['battle.simulate'],
|
||||||
|
sessionOnly: ['auth.exchangeGatewayToken'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const routerRoot = fileURLToPath(new URL('../src/router/', import.meta.url));
|
||||||
|
|
||||||
|
const listTypeScriptFiles = (directory: string): string[] =>
|
||||||
|
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const target = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) return listTypeScriptFiles(target);
|
||||||
|
return entry.isFile() && entry.name.endsWith('.ts') ? [target] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const extractMutationNames = (file: string): string[] => {
|
||||||
|
const source = readFileSync(file, 'utf8');
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const mutation of source.matchAll(/\.mutation\s*\(/gu)) {
|
||||||
|
const prefix = source.slice(0, mutation.index);
|
||||||
|
const propertyCandidates = [...prefix.matchAll(/^ {4,8}([A-Za-z][A-Za-z0-9]*):/gmu)];
|
||||||
|
const exportedCandidates = [...prefix.matchAll(/^export const ([A-Za-z][A-Za-z0-9]*)\s*=/gmu)];
|
||||||
|
const property = propertyCandidates.at(-1);
|
||||||
|
const exported = exportedCandidates.at(-1);
|
||||||
|
const propertyIndex = property?.index ?? -1;
|
||||||
|
const exportedIndex = exported?.index ?? -1;
|
||||||
|
const name = propertyIndex > exportedIndex ? property?.[1] : exported?.[1];
|
||||||
|
if (!name) throw new Error(`Could not resolve mutation name in ${file}`);
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
};
|
||||||
|
|
||||||
|
const routePrefix = (file: string): string => {
|
||||||
|
const relative = path.relative(routerRoot, file);
|
||||||
|
const [top] = relative.split(path.sep);
|
||||||
|
if (!top) throw new Error(`Could not resolve router prefix for ${file}`);
|
||||||
|
return top.endsWith('.ts') ? path.basename(top, '.ts') : top;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('game-api direct mutation journal inventory', () => {
|
||||||
|
it('requires every router mutation to retain an explicit ownership and realtime classification', () => {
|
||||||
|
const actual = listTypeScriptFiles(routerRoot)
|
||||||
|
.flatMap((file) => extractMutationNames(file).map((name) => `${routePrefix(file)}.${name}`))
|
||||||
|
.sort();
|
||||||
|
const classified = Object.values(classifications).flat().sort();
|
||||||
|
|
||||||
|
expect(new Set(classified).size).toBe(classified.length);
|
||||||
|
expect(classified).toHaveLength(86);
|
||||||
|
expect(actual).toEqual(classified);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ChangeJournal } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
import type { GameApiContext, GeneralRow } from '../src/context.js';
|
import type { GameApiContext, GeneralRow } from '../src/context.js';
|
||||||
@@ -210,6 +211,21 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('journals committed message mailbox copies instead of publishing before commit', async () => {
|
||||||
|
const changeJournal = new ChangeJournal();
|
||||||
|
const queryRaw = vi.fn(async () => [{ id: 51 }]);
|
||||||
|
const { caller, redis } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
|
||||||
|
|
||||||
|
await caller.messages.send({
|
||||||
|
generalId: general.id,
|
||||||
|
mailbox: 9999,
|
||||||
|
text: '공개 메시지',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(changeJournal.snapshot()).toEqual([{ domain: 'messages.mailbox', entityId: 9999 }]);
|
||||||
|
expect(redis.publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
|
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
|
||||||
const ambassador = {
|
const ambassador = {
|
||||||
...general,
|
...general,
|
||||||
@@ -458,7 +474,8 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
|
const changeJournal = new ChangeJournal();
|
||||||
|
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
|
||||||
|
|
||||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
|
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
|
||||||
|
|
||||||
@@ -467,6 +484,10 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
where: { id: { in: [21, 22] } },
|
where: { id: { in: [21, 22] } },
|
||||||
data: { validUntil: expect.any(Date) },
|
data: { validUntil: expect.any(Date) },
|
||||||
});
|
});
|
||||||
|
expect(changeJournal.snapshot()).toEqual([
|
||||||
|
{ domain: 'messages.mailbox', entityId: 7 },
|
||||||
|
{ domain: 'messages.mailbox', entityId: 8 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
||||||
@@ -671,6 +692,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
const logCreateMany = vi.fn(async () => ({ count: 1 }));
|
const logCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||||
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
|
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||||
const cityUpdate = vi.fn(async () => ({}));
|
const cityUpdate = vi.fn(async () => ({}));
|
||||||
|
const changeJournal = new ChangeJournal();
|
||||||
const { caller } = buildContext({
|
const { caller } = buildContext({
|
||||||
general: {
|
general: {
|
||||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||||
@@ -748,7 +770,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
logEntry: { createMany: logCreateMany },
|
logEntry: { createMany: logCreateMany },
|
||||||
message: { updateMany: messageUpdateMany },
|
message: { updateMany: messageUpdateMany },
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
});
|
}, { changeJournal });
|
||||||
return {
|
return {
|
||||||
caller,
|
caller,
|
||||||
actor,
|
actor,
|
||||||
@@ -759,6 +781,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
logCreateMany,
|
logCreateMany,
|
||||||
messageUpdateMany,
|
messageUpdateMany,
|
||||||
cityUpdate,
|
cityUpdate,
|
||||||
|
changeJournal,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -841,6 +864,18 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
where: { id: 9 },
|
where: { id: 9 },
|
||||||
data: { frontState: 0 },
|
data: { frontState: 0 },
|
||||||
});
|
});
|
||||||
|
expect(setup.changeJournal.snapshot()).toEqual([
|
||||||
|
{ domain: 'city.content', entityId: 1 },
|
||||||
|
{ domain: 'city.content', entityId: 9 },
|
||||||
|
{ domain: 'dashboard.global', entityId: 0 },
|
||||||
|
{ domain: 'map.world', entityId: 0 },
|
||||||
|
{ domain: 'messages.mailbox', entityId: 9001 },
|
||||||
|
{ domain: 'messages.mailbox', entityId: 9002 },
|
||||||
|
{ domain: 'nation.content', entityId: 1 },
|
||||||
|
{ domain: 'nation.content', entityId: 2 },
|
||||||
|
{ domain: 'records.general', entityId: 7 },
|
||||||
|
{ domain: 'records.general', entityId: 8 },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ChangeJournal } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ const auth: GameSessionTokenPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildContext = () => {
|
const buildContext = () => {
|
||||||
|
const changeJournal = new ChangeJournal();
|
||||||
const requestCommand = vi.fn(async (command: unknown) => ({
|
const requestCommand = vi.fn(async (command: unknown) => ({
|
||||||
type: 'setNationMeta',
|
type: 'setNationMeta',
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -95,6 +97,7 @@ const buildContext = () => {
|
|||||||
battleSim: {} as GameApiContext['battleSim'],
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
auth,
|
auth,
|
||||||
|
changeJournal,
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
uploadPublicUrl: null,
|
||||||
@@ -102,7 +105,7 @@ const buildContext = () => {
|
|||||||
flushStore: new InMemoryFlushStore(),
|
flushStore: new InMemoryFlushStore(),
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
};
|
};
|
||||||
return { caller: appRouter.createCaller(context), requestCommand };
|
return { caller: appRouter.createCaller(context), requestCommand, changeJournal };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('nation HTML API boundary', () => {
|
describe('nation HTML API boundary', () => {
|
||||||
@@ -135,6 +138,11 @@ describe('nation HTML API boundary', () => {
|
|||||||
},
|
},
|
||||||
expectedUpdatedAt: undefined,
|
expectedUpdatedAt: undefined,
|
||||||
});
|
});
|
||||||
|
expect(fixture.changeJournal.snapshot()).toEqual([
|
||||||
|
{ domain: 'dashboard.global', entityId: 0 },
|
||||||
|
...(procedure === 'setNotice' ? [{ domain: 'front.nation' as const, entityId: 1 }] : []),
|
||||||
|
{ domain: 'nation.content', entityId: 1 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['setNotice', 'setScoutMsg'] as const)(
|
it.each(['setNotice', 'setScoutMsg'] as const)(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ChangeJournal } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ const createContext = (
|
|||||||
requestCommand?: ReturnType<typeof vi.fn>;
|
requestCommand?: ReturnType<typeof vi.fn>;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
transaction?: ReturnType<typeof vi.fn>;
|
transaction?: ReturnType<typeof vi.fn>;
|
||||||
|
changeJournal?: ChangeJournal;
|
||||||
} = {}
|
} = {}
|
||||||
): GameApiContext => {
|
): GameApiContext => {
|
||||||
const requestCommand = options.requestCommand ?? vi.fn();
|
const requestCommand = options.requestCommand ?? vi.fn();
|
||||||
@@ -86,6 +88,7 @@ const createContext = (
|
|||||||
battleSim: {} as GameApiContext['battleSim'],
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
auth,
|
auth,
|
||||||
|
...(options.changeJournal ? { changeJournal: options.changeJournal } : {}),
|
||||||
...(options.requestId ? { requestId: options.requestId } : {}),
|
...(options.requestId ? { requestId: options.requestId } : {}),
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
@@ -242,10 +245,11 @@ describe('nation personnel router', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const headCommand = makeCommand();
|
const headCommand = makeCommand();
|
||||||
|
const changeJournal = new ChangeJournal();
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(createContext({ db: nationDb, requestCommand: headCommand })).nation.setRate({
|
appRouter
|
||||||
amount: 20,
|
.createCaller(createContext({ db: nationDb, requestCommand: headCommand, changeJournal }))
|
||||||
})
|
.nation.setRate({ amount: 20 })
|
||||||
).resolves.toEqual({ ok: true });
|
).resolves.toEqual({ ok: true });
|
||||||
expect(headCommand).toHaveBeenCalledWith({
|
expect(headCommand).toHaveBeenCalledWith({
|
||||||
type: 'setNationMeta',
|
type: 'setNationMeta',
|
||||||
@@ -253,6 +257,10 @@ describe('nation personnel router', () => {
|
|||||||
updates: { rate: 20 },
|
updates: { rate: 20 },
|
||||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
expect(changeJournal.snapshot()).toEqual([
|
||||||
|
{ domain: 'dashboard.global', entityId: 0 },
|
||||||
|
{ domain: 'nation.content', entityId: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
const ambassadorCommand = makeCommand();
|
const ambassadorCommand = makeCommand();
|
||||||
const ambassador = {
|
const ambassador = {
|
||||||
|
|||||||
@@ -126,6 +126,23 @@ describe('public realtime event privacy boundary', () => {
|
|||||||
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
|
||||||
|
const event: RealtimeEvent = {
|
||||||
|
type: 'messagesChanged',
|
||||||
|
mailboxes: [7, MESSAGE_MAILBOX_NATIONAL_BASE + 8],
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicEvent = toPublicRealtimeEvent(event, [viewer]);
|
||||||
|
expect(publicEvent).toEqual({ type: 'messagesInvalidated' });
|
||||||
|
expect(JSON.stringify(publicEvent)).not.toMatch(/7|9008|mailbox|revision|time/u);
|
||||||
|
expect(
|
||||||
|
toPublicRealtimeEvent(
|
||||||
|
{ type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] },
|
||||||
|
[viewer]
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('requests an identity refresh only when the viewer general may have changed', () => {
|
it('requests an identity refresh only when the viewer general may have changed', () => {
|
||||||
expect(
|
expect(
|
||||||
shouldReloadRealtimeViewerIdentity(
|
shouldReloadRealtimeViewerIdentity(
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import type { ReadModelOutboxDatabase } from '@sammo-ts/infra';
|
|||||||
|
|
||||||
import { ReadModelOutboxWorker } from '../src/realtime/outboxWorker.js';
|
import { ReadModelOutboxWorker } from '../src/realtime/outboxWorker.js';
|
||||||
|
|
||||||
const payload = (domain: 'front.general' | 'access.general' | 'tournament' | 'betting') => ({
|
const payload = (
|
||||||
|
domain: 'front.general' | 'access.general' | 'dashboard.global' | 'messages.mailbox' | 'tournament' | 'betting'
|
||||||
|
) => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
changes: [[domain, domain === 'front.general' || domain === 'access.general' ? 7 : 0, '1']],
|
changes: [[domain, domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0, '1']],
|
||||||
});
|
});
|
||||||
|
|
||||||
const createFixture = (rows: readonly object[]) => {
|
const createFixture = (rows: readonly object[]) => {
|
||||||
@@ -47,7 +49,7 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['access.general', 'tournament', 'betting'] as const)(
|
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
|
||||||
'marks a %s-only envelope delivered without dashboard Redis publish',
|
'marks a %s-only envelope delivered without dashboard Redis publish',
|
||||||
async (domain) => {
|
async (domain) => {
|
||||||
const fixture = createFixture([{ id: 12n, payload: payload(domain), attempts: 1 }]);
|
const fixture = createFixture([{ id: 12n, payload: payload(domain), attempts: 1 }]);
|
||||||
@@ -65,6 +67,24 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('publishes a durable mailbox wake-up without the legacy dashboard revision', async () => {
|
||||||
|
const fixture = createFixture([{ id: 14n, payload: payload('messages.mailbox'), attempts: 1 }]);
|
||||||
|
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
|
||||||
|
owner: 'worker-test',
|
||||||
|
intervalMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.start();
|
||||||
|
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||||
|
await worker.stop();
|
||||||
|
|
||||||
|
expect(fixture.incr).not.toHaveBeenCalled();
|
||||||
|
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({
|
||||||
|
type: 'messagesChanged',
|
||||||
|
mailboxes: [9999],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
|
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
|
||||||
let releaseFirst: (() => void) | undefined;
|
let releaseFirst: (() => void) | undefined;
|
||||||
const first = new Promise<readonly object[]>((resolve) => {
|
const first = new Promise<readonly object[]>((resolve) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'
|
|||||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
import { ChangeJournal } from '@sammo-ts/common';
|
||||||
import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
|
||||||
const profile: GameProfile = {
|
const profile: GameProfile = {
|
||||||
@@ -118,6 +119,7 @@ const buildContext = (options?: {
|
|||||||
accountIconGet?: (userId: string) => Promise<unknown>;
|
accountIconGet?: (userId: string) => Promise<unknown>;
|
||||||
accessTokenStore?: RedisAccessTokenStore;
|
accessTokenStore?: RedisAccessTokenStore;
|
||||||
worldStateReads?: { count: number };
|
worldStateReads?: { count: number };
|
||||||
|
changeJournal?: ChangeJournal;
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
|
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
|
||||||
const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport();
|
const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport();
|
||||||
@@ -227,6 +229,7 @@ const buildContext = (options?: {
|
|||||||
battleSim,
|
battleSim,
|
||||||
profile,
|
profile,
|
||||||
auth,
|
auth,
|
||||||
|
...(options?.changeJournal ? { changeJournal: options.changeJournal } : {}),
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
uploadPath: '/uploads',
|
uploadPath: '/uploads',
|
||||||
uploadPublicUrl: null,
|
uploadPublicUrl: null,
|
||||||
@@ -868,8 +871,9 @@ describe('appRouter', () => {
|
|||||||
it('validates and persists general command arguments from the authenticated owner', async () => {
|
it('validates and persists general command arguments from the authenticated owner', async () => {
|
||||||
const general = buildGeneralRow({ id: 13 });
|
const general = buildGeneralRow({ id: 13 });
|
||||||
const writes: unknown[] = [];
|
const writes: unknown[] = [];
|
||||||
|
const changeJournal = new ChangeJournal();
|
||||||
const caller = appRouter.createCaller(
|
const caller = appRouter.createCaller(
|
||||||
buildContext({ state: buildWorldState(), general, generalTurnWrites: writes })
|
buildContext({ state: buildWorldState(), general, generalTurnWrites: writes, changeJournal })
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await caller.turns.reserved.setGeneral({
|
const response = await caller.turns.reserved.setGeneral({
|
||||||
@@ -890,6 +894,7 @@ describe('appRouter', () => {
|
|||||||
actionCode: 'che_화계',
|
actionCode: 'che_화계',
|
||||||
arg: { destCityId: 7 },
|
arg: { destCityId: 7 },
|
||||||
});
|
});
|
||||||
|
expect(changeJournal.snapshot()).toEqual([{ domain: 'reserved.general', entityId: 13 }]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
caller.turns.reserved.setGeneral({
|
caller.turns.reserved.setGeneral({
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# game-api 직접 mutation / change journal inventory
|
||||||
|
|
||||||
|
## 범위와 판정 규칙
|
||||||
|
|
||||||
|
`app/game-api/src/router/**`에서 `.mutation()`으로 선언한 86개 route를 2026-08-16
|
||||||
|
기준으로 전수 분류한다. 이 목록은 “mutation transport를 사용한다”와 “game DB를
|
||||||
|
변경한다”를 구분한다. 신규 route가 추가되면
|
||||||
|
`app/game-api/test/directMutationJournalInventory.test.ts`가 실패하므로 소유권과
|
||||||
|
실시간 소비자를 먼저 정해야 한다.
|
||||||
|
|
||||||
|
분류 기준은 다음과 같다.
|
||||||
|
|
||||||
|
- `durable journal`: API가 성공한 DB mutation의 dashboard/message dependency를
|
||||||
|
request-local `ChangeJournal`에 표시하고 같은 API transaction에서 revision/outbox를
|
||||||
|
쓴다.
|
||||||
|
- `separate access journal`: gameplay input-event와 분리된 접속 계측 transaction이
|
||||||
|
`access.general`을 직접 쓴다. public fan-out은 없다.
|
||||||
|
- `engine owned`: 실제 game mutation은 ENGINE `input_event` transaction이 소유한다.
|
||||||
|
API에서 같은 표식을 중복 생성하지 않는다.
|
||||||
|
- `mixed saga`: ENGINE DB 변경과 API DB/Redis 변경이 하나의 atomic transaction이
|
||||||
|
아니다. 현 상태를 atomic journal coverage로 오인하지 않는다.
|
||||||
|
- `explicit no realtime consumer`: 저장값은 바뀌지만 현재 SSE 자동 갱신 consumer가
|
||||||
|
없다. 존재하지 않는 browser fan-out을 만들지 않는다.
|
||||||
|
- `Redis projection`: 토너먼트의 authoritative state가 현재 Redis이고 PostgreSQL
|
||||||
|
journal과 원자적이지 않다.
|
||||||
|
|
||||||
|
`coverageVersion`은 이 inventory가 존재한다는 이유로 올리지 않으며 계속 `0`이다.
|
||||||
|
특히 mixed saga, Redis tournament state와 ENGINE writer inventory의 reconciliation이
|
||||||
|
끝나기 전 revision equality fast path를 활성화하지 않는다.
|
||||||
|
|
||||||
|
## 전수 목록
|
||||||
|
|
||||||
|
| 분류 | 수 | route |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| durable journal | 22 | `betting.bet`; `messages.delete`, `messages.respond`, `messages.send`; `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `turns.repeatGeneral`, `turns.setGeneral`, `turns.setGeneralBulk`, `turns.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` |
|
||||||
|
| separate access journal | 1 | `public.recordAccess` |
|
||||||
|
| explicit no realtime consumer | 15 | `board.writeArticle`, `board.writeComment`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `inherit.checkOwner`; `join.getSelectionPool`, `join.listPossessCandidates`; `messages.readLatest`; `turns.repeatNation`, `turns.setNation`, `turns.setNationBulk`, `turns.shiftNation`; `vote.addComment` |
|
||||||
|
| engine owned | 27 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` |
|
||||||
|
| mixed saga | 9 | `inherit.buyHiddenBuff`, `inherit.buyRandomUnique`, `inherit.resetSpecialWar`, `inherit.resetStat`, `inherit.resetTurnTime`, `inherit.setNextSpecialWar`; `tournament.cancel`, `tournament.join`, `tournament.placeBet` |
|
||||||
|
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
|
||||||
|
| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` |
|
||||||
|
| external upload | 1 | `board.uploadImage` |
|
||||||
|
| read-only mutation transport | 1 | `battle.simulate` |
|
||||||
|
| session only | 1 | `auth.exchangeGatewayToken` |
|
||||||
|
|
||||||
|
합계는 86개다.
|
||||||
|
|
||||||
|
## durable journal dependency 매핑
|
||||||
|
|
||||||
|
| writer | durable key | public wake-up | 근거와 경계 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `betting.bet` | `general.content:<actor>`, `betting:0` | 없음 | 본인 베팅/유산 지출과 베팅 aggregate source가 바뀐다. `betting`은 현재 별도 화면 source이고 main dashboard fan-out을 만들지 않는다. |
|
||||||
|
| `messages.send` | 생성된 수신/송신 복사본의 `messages.mailbox:<mailbox>` | 해당 mailbox viewer에게 ID 없는 `messagesInvalidated` | 기존 pre-commit Redis `messageCreated`를 제거했다. outbox publish 뒤에도 browser에는 mailbox/message/sender/time/revision이 노출되지 않는다. |
|
||||||
|
| `messages.delete` | 실제로 만료한 송신/수신 mailbox | 동일 | sender copy만 지우는 수동 외교 메시지는 그 mailbox만 표시한다. |
|
||||||
|
| `messages.respond` | 영향 mailbox, `records.general`, 실제 외교 변경 국가의 `nation.content`, front-state patch 도시의 `city.content`, 필요 시 `map.world`, transitive aggregate용 `dashboard.global` | mailbox boolean 및 해당 dashboard slice | 실패 로그도 commit되면 actor 개인 기록을 표시한다. 외교 수락이 실제 diplomacy/city/nation dependency를 바꿀 때만 broad source key를 표시한다. |
|
||||||
|
| nation metadata 7개 및 NPC policy 3개 | `nation.content:<nation>`, `dashboard.global:0`; notice만 추가로 `front.nation:<nation>` | 해당 국가 context/command/board, notice front status | `updateNationMeta()` 성공 뒤 공통 표식을 사용한다. 다만 현재 mutation 자체는 ENGINE `setNationMeta`이고 API journal transaction과 단일 DB transaction이 아닌 기존 saga다. coverage 활성화 전에 ENGINE command 소유로 합쳐야 한다. |
|
||||||
|
| general reserved turn 4개 | `reserved.general:<general>` | 본인 reserved-turn slice | queue row와 CAS revision을 쓴 같은 API transaction에서 표시한다. nation reserved turns는 main SSE consumer가 없어 명시적 no-op이다. |
|
||||||
|
| vote 4개 | 기존 `front.general`/`front.global` | front-status boolean | vote producer 작업에서 pre-commit publish를 journal로 이미 치환했다. 댓글은 active survey 제목을 바꾸지 않아 별도 화면 no-op이다. |
|
||||||
|
| `public.recordAccess` | `access.general:<general>` | 없음 | Ref 순서상 gameplay transaction 밖의 별도 access transaction에 저장한다. |
|
||||||
|
|
||||||
|
`dashboard.global`은 context/command source vector가 general/city/nation/troop/
|
||||||
|
diplomacy aggregate에 간접 의존하는 점을 위한 DB/source-only key다. dispatcher는 이를
|
||||||
|
public dashboard event로 내보내지 않는다. browser wake-up은 정밀 entity/domain key가
|
||||||
|
담당하고, source equality 검사만 이 보수적 key를 사용한다.
|
||||||
|
|
||||||
|
## 의도적으로 fan-out하지 않는 저장값
|
||||||
|
|
||||||
|
- 게시글/댓글은 현재 게시판 화면에서 사용자 action 뒤 직접 다시 읽으며 main SSE
|
||||||
|
listener가 없다. `board.*` domain을 임의로 추가하지 않는다.
|
||||||
|
- 외교 문서(`diplomacyLetter`)는 외교 문서 화면 전용이고 현재 SSE consumer가 없다.
|
||||||
|
전쟁/불가침 상태를 실제 변경하는 `messages.respond`와 구분한다.
|
||||||
|
- `messages.readLatest`는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고
|
||||||
|
있으므로 자기 자신에게 다시 wake-up을 보내지 않는다.
|
||||||
|
- nation reserved turn, selection-pool reservation, possession 후보, inheritance owner
|
||||||
|
확인은 각각 전용 화면/request response가 최신 상태를 소유한다.
|
||||||
|
- image upload는 외부 content store write이며 game PostgreSQL read model이 아니다.
|
||||||
|
- battle simulation은 호환상 mutation transport를 쓰지만 read-only 계산이다.
|
||||||
|
|
||||||
|
## 남은 원자성/coverage gap
|
||||||
|
|
||||||
|
1. nation metadata/NPC policy는 ENGINE DB commit 뒤 API input-event transaction이
|
||||||
|
journal을 쓴다. API rollback과 ENGINE commit이 분리되는 기존 gap이 남는다.
|
||||||
|
2. inheritance와 tournament는 보상 가능한 saga지만 단일 transaction이 아니다.
|
||||||
|
3. Redis tournament state에는 PostgreSQL revision과 원자적인 Lua/MULTI revision이
|
||||||
|
아직 없다.
|
||||||
|
4. ENGINE에서 생성하는 message row도 `messages.mailbox`에 연결해야 전체 mailbox
|
||||||
|
producer coverage가 된다.
|
||||||
|
5. 위 gap과 초기 reconciliation이 끝나기 전 `read_model_revision_meta.coverage_version`
|
||||||
|
은 반드시 `0`으로 유지한다.
|
||||||
@@ -2,6 +2,7 @@ export const READ_MODEL_DOMAINS = [
|
|||||||
'general.content',
|
'general.content',
|
||||||
'city.content',
|
'city.content',
|
||||||
'nation.content',
|
'nation.content',
|
||||||
|
'dashboard.global',
|
||||||
'world.content',
|
'world.content',
|
||||||
'map.world',
|
'map.world',
|
||||||
'map.general',
|
'map.general',
|
||||||
@@ -16,6 +17,7 @@ export const READ_MODEL_DOMAINS = [
|
|||||||
'lobby.general',
|
'lobby.general',
|
||||||
'contacts.world',
|
'contacts.world',
|
||||||
'reserved.general',
|
'reserved.general',
|
||||||
|
'messages.mailbox',
|
||||||
'tournament',
|
'tournament',
|
||||||
'betting',
|
'betting',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import { createEmptyRealtimeReadModelChanges, type RealtimeReadModelChanges } fr
|
|||||||
const uniqueSortedIds = (values: Iterable<number>): number[] =>
|
const uniqueSortedIds = (values: Iterable<number>): number[] =>
|
||||||
[...new Set(values)].sort((left, right) => left - right);
|
[...new Set(values)].sort((left, right) => left - right);
|
||||||
|
|
||||||
|
export const readModelOutboxPayloadToMessageMailboxes = (
|
||||||
|
payload: ReadModelOutboxPayloadV1
|
||||||
|
): readonly number[] =>
|
||||||
|
uniqueSortedIds(
|
||||||
|
payload.changes.flatMap(([domain, entityId]) => (domain === 'messages.mailbox' ? [entityId] : []))
|
||||||
|
);
|
||||||
|
|
||||||
export const parseReadModelOutboxPayload = (value: unknown): ReadModelOutboxPayloadV1 | null => {
|
export const parseReadModelOutboxPayload = (value: unknown): ReadModelOutboxPayloadV1 | null => {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -73,6 +80,8 @@ export const readModelOutboxPayloadToChanges = (
|
|||||||
case 'nation.content':
|
case 'nation.content':
|
||||||
nationIds.push(entityId);
|
nationIds.push(entityId);
|
||||||
break;
|
break;
|
||||||
|
case 'dashboard.global':
|
||||||
|
break;
|
||||||
case 'world.content':
|
case 'world.content':
|
||||||
changes.worldChanged = true;
|
changes.worldChanged = true;
|
||||||
break;
|
break;
|
||||||
@@ -113,6 +122,7 @@ export const readModelOutboxPayloadToChanges = (
|
|||||||
reservedGeneralIds.push(entityId);
|
reservedGeneralIds.push(entityId);
|
||||||
break;
|
break;
|
||||||
case 'access.general':
|
case 'access.general':
|
||||||
|
case 'messages.mailbox':
|
||||||
case 'tournament':
|
case 'tournament':
|
||||||
case 'betting':
|
case 'betting':
|
||||||
// These domains have no browser-wide dashboard invalidation.
|
// These domains have no browser-wide dashboard invalidation.
|
||||||
|
|||||||
@@ -206,6 +206,12 @@ export interface MessageCreatedEvent {
|
|||||||
senderId: number;
|
senderId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Durable mailbox wake-up derived from committed read-model outbox rows. */
|
||||||
|
export interface MessagesChangedEvent {
|
||||||
|
type: 'messagesChanged';
|
||||||
|
mailboxes: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReadModelInvalidatedEvent {
|
export interface ReadModelInvalidatedEvent {
|
||||||
type: 'readModelInvalidated';
|
type: 'readModelInvalidated';
|
||||||
invalidation: RealtimeReadModelInvalidation;
|
invalidation: RealtimeReadModelInvalidation;
|
||||||
@@ -218,4 +224,4 @@ export interface MessagesInvalidatedEvent {
|
|||||||
/** Events safe to expose to an authenticated browser over SSE. */
|
/** Events safe to expose to an authenticated browser over SSE. */
|
||||||
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
|
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
|
||||||
|
|
||||||
export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent;
|
export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent;
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import {
|
|||||||
describe('ChangeJournal', () => {
|
describe('ChangeJournal', () => {
|
||||||
it('keeps viewer-filtering semantics as distinct durable domains', () => {
|
it('keeps viewer-filtering semantics as distinct durable domains', () => {
|
||||||
expect(READ_MODEL_DOMAINS).toEqual(
|
expect(READ_MODEL_DOMAINS).toEqual(
|
||||||
expect.arrayContaining(['map.general', 'lobby.general', 'contacts.world', 'reserved.general'])
|
expect.arrayContaining([
|
||||||
|
'map.general',
|
||||||
|
'lobby.general',
|
||||||
|
'contacts.world',
|
||||||
|
'reserved.general',
|
||||||
|
'messages.mailbox',
|
||||||
|
'dashboard.global',
|
||||||
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
hasRealtimeReadModelChanges,
|
hasRealtimeReadModelChanges,
|
||||||
parseReadModelOutboxPayload,
|
parseReadModelOutboxPayload,
|
||||||
|
readModelOutboxPayloadToMessageMailboxes,
|
||||||
readModelOutboxPayloadToChanges,
|
readModelOutboxPayloadToChanges,
|
||||||
resolveRealtimeReadModelInvalidation,
|
resolveRealtimeReadModelInvalidation,
|
||||||
} from '../src/index.js';
|
} from '../src/index.js';
|
||||||
@@ -71,11 +72,12 @@ describe('read-model outbox payload', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps access-only, tournament, and betting domains off the dashboard channel', () => {
|
it('keeps source-only, access-only, tournament, and betting domains off the dashboard channel', () => {
|
||||||
const payload = parseReadModelOutboxPayload({
|
const payload = parseReadModelOutboxPayload({
|
||||||
version: 1,
|
version: 1,
|
||||||
changes: [
|
changes: [
|
||||||
['access.general', 7, '1'],
|
['access.general', 7, '1'],
|
||||||
|
['dashboard.global', 0, '1'],
|
||||||
['tournament', 0, '1'],
|
['tournament', 0, '1'],
|
||||||
['betting', 0, '1'],
|
['betting', 0, '1'],
|
||||||
],
|
],
|
||||||
@@ -83,4 +85,19 @@ describe('read-model outbox payload', () => {
|
|||||||
if (!payload) throw new Error('valid payload rejected');
|
if (!payload) throw new Error('valid payload rejected');
|
||||||
expect(hasRealtimeReadModelChanges(readModelOutboxPayloadToChanges(payload))).toBe(false);
|
expect(hasRealtimeReadModelChanges(readModelOutboxPayloadToChanges(payload))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps durable mailbox revisions separate from dashboard invalidations', () => {
|
||||||
|
const payload = parseReadModelOutboxPayload({
|
||||||
|
version: 1,
|
||||||
|
changes: [
|
||||||
|
['messages.mailbox', 9999, '3'],
|
||||||
|
['messages.mailbox', 7, '2'],
|
||||||
|
['messages.mailbox', 7, '2'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!payload) throw new Error('valid payload rejected');
|
||||||
|
|
||||||
|
expect(readModelOutboxPayloadToMessageMailboxes(payload)).toEqual([7, 9999]);
|
||||||
|
expect(hasRealtimeReadModelChanges(readModelOutboxPayloadToChanges(payload))).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user