feat: 직접 writer를 내구성 변화 저널에 연결

86개 API mutation을 분류하고 메시지 mailbox, 베팅, 국가 설정, 예약 명령의 revision/outbox 표식을 소유 transaction에 연결한다. 공개 SSE는 식별자 없는 invalidation만 노출한다.
This commit is contained in:
2026-08-16 18:38:08 +00:00
parent e4ac2a60b1
commit dd4645e4d0
22 changed files with 485 additions and 55 deletions
@@ -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,
};
};
+13 -8
View File
@@ -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,
+3 -2
View File
@@ -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;
}
+12
View File
@@ -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),
});
};
+2
View File
@@ -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 };
}),
});
+41 -28
View File
@@ -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 };
});
+3 -1
View File
@@ -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,
+4
View File
@@ -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
@@ -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);
});
});
+37 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { appRouter } from '../src/router.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']));
});
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 () => {
const ambassador = {
...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 });
@@ -467,6 +484,10 @@ describe('messages router missing-flow compatibility', () => {
where: { id: { in: [21, 22] } },
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 () => {
@@ -671,6 +692,7 @@ describe('messages router missing-flow compatibility', () => {
const logCreateMany = vi.fn(async () => ({ count: 1 }));
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
const cityUpdate = vi.fn(async () => ({}));
const changeJournal = new ChangeJournal();
const { caller } = buildContext({
general: {
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
@@ -748,7 +770,7 @@ describe('messages router missing-flow compatibility', () => {
logEntry: { createMany: logCreateMany },
message: { updateMany: messageUpdateMany },
$queryRaw: queryRaw,
});
}, { changeJournal });
return {
caller,
actor,
@@ -759,6 +781,7 @@ describe('messages router missing-flow compatibility', () => {
logCreateMany,
messageUpdateMany,
cityUpdate,
changeJournal,
};
};
@@ -841,6 +864,18 @@ describe('messages router missing-flow compatibility', () => {
where: { id: 9 },
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 },
]);
}
});
+9 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
@@ -70,6 +71,7 @@ const auth: GameSessionTokenPayload = {
};
const buildContext = () => {
const changeJournal = new ChangeJournal();
const requestCommand = vi.fn(async (command: unknown) => ({
type: 'setNationMeta',
ok: true,
@@ -95,6 +97,7 @@ const buildContext = () => {
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
changeJournal,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -102,7 +105,7 @@ const buildContext = () => {
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { caller: appRouter.createCaller(context), requestCommand };
return { caller: appRouter.createCaller(context), requestCommand, changeJournal };
};
describe('nation HTML API boundary', () => {
@@ -135,6 +138,11 @@ describe('nation HTML API boundary', () => {
},
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)(
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
@@ -70,6 +71,7 @@ const createContext = (
requestCommand?: ReturnType<typeof vi.fn>;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
changeJournal?: ChangeJournal;
} = {}
): GameApiContext => {
const requestCommand = options.requestCommand ?? vi.fn();
@@ -86,6 +88,7 @@ const createContext = (
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
...(options.changeJournal ? { changeJournal: options.changeJournal } : {}),
...(options.requestId ? { requestId: options.requestId } : {}),
uploadDir: 'uploads',
uploadPath: '/uploads',
@@ -242,10 +245,11 @@ describe('nation personnel router', () => {
}));
const headCommand = makeCommand();
const changeJournal = new ChangeJournal();
await expect(
appRouter.createCaller(createContext({ db: nationDb, requestCommand: headCommand })).nation.setRate({
amount: 20,
})
appRouter
.createCaller(createContext({ db: nationDb, requestCommand: headCommand, changeJournal }))
.nation.setRate({ amount: 20 })
).resolves.toEqual({ ok: true });
expect(headCommand).toHaveBeenCalledWith({
type: 'setNationMeta',
@@ -253,6 +257,10 @@ describe('nation personnel router', () => {
updates: { rate: 20 },
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 ambassador = {
@@ -126,6 +126,23 @@ describe('public realtime event privacy boundary', () => {
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', () => {
expect(
shouldReloadRealtimeViewerIdentity(
@@ -5,9 +5,11 @@ import type { ReadModelOutboxDatabase } from '@sammo-ts/infra';
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,
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[]) => {
@@ -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',
async (domain) => {
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 () => {
let releaseFirst: (() => void) | undefined;
const first = new Promise<readonly object[]>((resolve) => {
+6 -1
View File
@@ -15,6 +15,7 @@ import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { appRouter } from '../src/router.js';
import { ChangeJournal } from '@sammo-ts/common';
import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
const profile: GameProfile = {
@@ -118,6 +119,7 @@ const buildContext = (options?: {
accountIconGet?: (userId: string) => Promise<unknown>;
accessTokenStore?: RedisAccessTokenStore;
worldStateReads?: { count: number };
changeJournal?: ChangeJournal;
}): GameApiContext => {
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport();
@@ -227,6 +229,7 @@ const buildContext = (options?: {
battleSim,
profile,
auth,
...(options?.changeJournal ? { changeJournal: options.changeJournal } : {}),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -868,8 +871,9 @@ describe('appRouter', () => {
it('validates and persists general command arguments from the authenticated owner', async () => {
const general = buildGeneralRow({ id: 13 });
const writes: unknown[] = [];
const changeJournal = new ChangeJournal();
const caller = appRouter.createCaller(
buildContext({ state: buildWorldState(), general, generalTurnWrites: writes })
buildContext({ state: buildWorldState(), general, generalTurnWrites: writes, changeJournal })
);
const response = await caller.turns.reserved.setGeneral({
@@ -890,6 +894,7 @@ describe('appRouter', () => {
actionCode: 'che_화계',
arg: { destCityId: 7 },
});
expect(changeJournal.snapshot()).toEqual([{ domain: 'reserved.general', entityId: 13 }]);
await expect(
caller.turns.reserved.setGeneral({