feat: 직접 writer를 내구성 변화 저널에 연결
86개 API mutation을 분류하고 메시지 mailbox, 베팅, 국가 설정, 예약 명령의 revision/outbox 표식을 소유 transaction에 연결한다. 공개 SSE는 식별자 없는 invalidation만 노출한다.
This commit is contained in:
@@ -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 { 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 },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user