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);
|
||||
};
|
||||
|
||||
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([
|
||||
loadMapDefinitionByName(mapName),
|
||||
db.city.findMany({
|
||||
@@ -152,6 +152,7 @@ const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds
|
||||
data: { frontState: patch.frontState },
|
||||
});
|
||||
}
|
||||
return patches.map((patch) => patch.id);
|
||||
};
|
||||
|
||||
const buildFailureLog = (generalId: number, reason: string, actionName: string, response: boolean): LogEntryDraft[] => {
|
||||
@@ -167,6 +168,9 @@ export interface DiplomaticMessageResponseResult {
|
||||
result: boolean;
|
||||
reason: string;
|
||||
affectedMailboxes: number[];
|
||||
affectedGeneralRecordIds: number[];
|
||||
affectedNationIds: number[];
|
||||
affectedCityIds: number[];
|
||||
}
|
||||
|
||||
export const respondToDiplomaticMessage = async (options: {
|
||||
@@ -201,7 +205,14 @@ export const respondToDiplomaticMessage = async (options: {
|
||||
world.currentMonth,
|
||||
now
|
||||
);
|
||||
return { result: false, reason, affectedMailboxes: [] };
|
||||
return {
|
||||
result: false,
|
||||
reason,
|
||||
affectedMailboxes: [],
|
||||
affectedGeneralRecordIds: [actor.id],
|
||||
affectedNationIds: [],
|
||||
affectedCityIds: [],
|
||||
};
|
||||
};
|
||||
|
||||
const actorNationId = actor.nationId;
|
||||
@@ -266,6 +277,9 @@ export const respondToDiplomaticMessage = async (options: {
|
||||
result: true,
|
||||
reason: 'success',
|
||||
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);
|
||||
let affectedCityIds: number[] = [];
|
||||
if (resolution.refreshFront) {
|
||||
const worldConfig = asRecord(world.config);
|
||||
const environment = asRecord(worldConfig.environment);
|
||||
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;
|
||||
@@ -415,5 +430,8 @@ export const respondToDiplomaticMessage = async (options: {
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId,
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE + proposerNationId,
|
||||
],
|
||||
affectedGeneralRecordIds: [actor.id, proposerGeneralId],
|
||||
affectedNationIds: [actorNationId, proposerNationId],
|
||||
affectedCityIds,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
readModelOutboxPayloadToChanges,
|
||||
readModelOutboxPayloadToMessageMailboxes,
|
||||
type ReadModelDomain,
|
||||
} from '@sammo-ts/common';
|
||||
import {
|
||||
@@ -11,13 +12,14 @@ import {
|
||||
type RedisConnector,
|
||||
} 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
|
||||
// betting still have separate Redis-owned source revisions. None of the three
|
||||
// should wake the legacy dashboard channel solely because its outbox row ran.
|
||||
// Source-only/access keys and separately owned tournament/betting state must
|
||||
// not wake the legacy dashboard channel solely because an outbox row ran.
|
||||
const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
|
||||
'access.general',
|
||||
'dashboard.global',
|
||||
'messages.mailbox',
|
||||
'tournament',
|
||||
'betting',
|
||||
]);
|
||||
@@ -83,11 +85,14 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
|
||||
const result = await dispatchReadModelOutboxBatch(
|
||||
this.db,
|
||||
async (payload) => {
|
||||
if (payload.changes.every(([domain]) => NON_DASHBOARD_DOMAINS.has(domain))) {
|
||||
return;
|
||||
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
||||
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,
|
||||
|
||||
@@ -62,8 +62,9 @@ export const toPublicRealtimeEvent = (
|
||||
const viewers = uniqueIdentities(
|
||||
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
||||
);
|
||||
if (event.type === 'messageCreated') {
|
||||
return viewers.some((identity) => isMailboxRelevant(event.mailbox, identity))
|
||||
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
||||
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
|
||||
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
||||
? { type: 'messagesInvalidated' }
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -30,3 +30,15 @@ export const publishRealtimeReadModelChanges = async (
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
ctx.changeJournal?.mark('general.content', general.id);
|
||||
ctx.changeJournal?.mark('betting');
|
||||
return { result: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { asRecord } from '@sammo-ts/common';
|
||||
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import {
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
insertMessage,
|
||||
type MessageView,
|
||||
} from '../../messages/store.js';
|
||||
import { publishRealtimeEvent } from '../../realtime/publisher.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
import { resolveNationPermission } from '../nation/shared.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';
|
||||
};
|
||||
|
||||
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({
|
||||
getRecent: accessLimitAuthedInputProcedure(
|
||||
z.object({
|
||||
@@ -298,6 +307,15 @@ export const messagesRouter = router({
|
||||
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
||||
];
|
||||
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 };
|
||||
}),
|
||||
respond: authedProcedure
|
||||
@@ -316,21 +334,21 @@ export const messagesRouter = router({
|
||||
messageId: input.messageId,
|
||||
response: input.response,
|
||||
});
|
||||
if (result.result) {
|
||||
for (const mailbox of result.affectedMailboxes) {
|
||||
try {
|
||||
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
||||
type: 'messageCreated',
|
||||
at: new Date().toISOString(),
|
||||
mailbox,
|
||||
msgType: 'diplomacy',
|
||||
messageId: input.messageId,
|
||||
senderId: general.id,
|
||||
});
|
||||
} catch {
|
||||
// 실시간 알림 실패는 외교 응답 실패로 취급하지 않는다.
|
||||
}
|
||||
}
|
||||
markMessageMailboxes(ctx, result.affectedMailboxes);
|
||||
for (const generalId of result.affectedGeneralRecordIds) {
|
||||
ctx.changeJournal?.mark('records.general', generalId);
|
||||
}
|
||||
for (const nationId of result.affectedNationIds) {
|
||||
ctx.changeJournal?.mark('nation.content', nationId);
|
||||
}
|
||||
for (const cityId of result.affectedCityIds) {
|
||||
ctx.changeJournal?.mark('city.content', cityId);
|
||||
}
|
||||
if (result.affectedCityIds.length > 0) {
|
||||
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 };
|
||||
}),
|
||||
@@ -522,18 +540,13 @@ export const messagesRouter = router({
|
||||
draft
|
||||
);
|
||||
|
||||
try {
|
||||
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
||||
type: 'messageCreated',
|
||||
at: now.toISOString(),
|
||||
mailbox: receiverMailbox,
|
||||
msgType,
|
||||
messageId: result.receiverId,
|
||||
senderId: general.id,
|
||||
});
|
||||
} catch {
|
||||
// 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다.
|
||||
}
|
||||
const senderMailbox =
|
||||
result.senderId === undefined
|
||||
? null
|
||||
: msgType === 'private'
|
||||
? general.id
|
||||
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
||||
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]);
|
||||
|
||||
return { msgType, msgId: result.receiverId };
|
||||
}),
|
||||
|
||||
@@ -35,5 +35,6 @@ export const setNotice = authedProcedure
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
ctx.changeJournal?.mark('front.nation', me.nationId);
|
||||
return { ok: true, msg };
|
||||
});
|
||||
|
||||
@@ -429,7 +429,7 @@ export const assertNationEditable = (
|
||||
};
|
||||
|
||||
export const updateNationMeta = async (
|
||||
ctx: Pick<GameApiContext, 'turnDaemon'>,
|
||||
ctx: Pick<GameApiContext, 'turnDaemon' | 'changeJournal'>,
|
||||
nationId: number,
|
||||
updates: Record<string, unknown>,
|
||||
currentMeta: Record<string, unknown>
|
||||
@@ -453,6 +453,8 @@ export const updateNationMeta = async (
|
||||
}
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
ctx.changeJournal?.mark('nation.content', nationId);
|
||||
ctx.changeJournal?.mark('dashboard.global');
|
||||
return {
|
||||
...currentMeta,
|
||||
...updates,
|
||||
|
||||
@@ -346,6 +346,7 @@ export const turnsRouter = router({
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
|
||||
);
|
||||
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||
return { ok: true, ...snapshot };
|
||||
}),
|
||||
shiftGeneral: authedProcedure
|
||||
@@ -362,6 +363,7 @@ export const turnsRouter = router({
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
||||
);
|
||||
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||
return { ok: true, ...snapshot };
|
||||
}),
|
||||
repeatGeneral: authedProcedure
|
||||
@@ -377,6 +379,7 @@ export const turnsRouter = router({
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
||||
);
|
||||
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||
return { ok: true, ...snapshot };
|
||||
}),
|
||||
setGeneralBulk: authedProcedure
|
||||
@@ -403,6 +406,7 @@ export const turnsRouter = router({
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
||||
);
|
||||
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||
return { ok: true, ...snapshot };
|
||||
}),
|
||||
setNation: authedProcedure
|
||||
|
||||
Reference in New Issue
Block a user