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
@@ -2,6 +2,7 @@ export const READ_MODEL_DOMAINS = [
'general.content',
'city.content',
'nation.content',
'dashboard.global',
'world.content',
'map.world',
'map.general',
@@ -16,6 +17,7 @@ export const READ_MODEL_DOMAINS = [
'lobby.general',
'contacts.world',
'reserved.general',
'messages.mailbox',
'tournament',
'betting',
] as const;
@@ -8,6 +8,13 @@ import { createEmptyRealtimeReadModelChanges, type RealtimeReadModelChanges } fr
const uniqueSortedIds = (values: Iterable<number>): number[] =>
[...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 => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
@@ -73,6 +80,8 @@ export const readModelOutboxPayloadToChanges = (
case 'nation.content':
nationIds.push(entityId);
break;
case 'dashboard.global':
break;
case 'world.content':
changes.worldChanged = true;
break;
@@ -113,6 +122,7 @@ export const readModelOutboxPayloadToChanges = (
reservedGeneralIds.push(entityId);
break;
case 'access.general':
case 'messages.mailbox':
case 'tournament':
case 'betting':
// These domains have no browser-wide dashboard invalidation.
+7 -1
View File
@@ -206,6 +206,12 @@ export interface MessageCreatedEvent {
senderId: number;
}
/** Durable mailbox wake-up derived from committed read-model outbox rows. */
export interface MessagesChangedEvent {
type: 'messagesChanged';
mailboxes: number[];
}
export interface ReadModelInvalidatedEvent {
type: 'readModelInvalidated';
invalidation: RealtimeReadModelInvalidation;
@@ -218,4 +224,4 @@ export interface MessagesInvalidatedEvent {
/** Events safe to expose to an authenticated browser over SSE. */
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent;
export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent;
+8 -1
View File
@@ -11,7 +11,14 @@ import {
describe('ChangeJournal', () => {
it('keeps viewer-filtering semantics as distinct durable domains', () => {
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',
])
);
});
+18 -1
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
hasRealtimeReadModelChanges,
parseReadModelOutboxPayload,
readModelOutboxPayloadToMessageMailboxes,
readModelOutboxPayloadToChanges,
resolveRealtimeReadModelInvalidation,
} 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({
version: 1,
changes: [
['access.general', 7, '1'],
['dashboard.global', 0, '1'],
['tournament', 0, '1'],
['betting', 0, '1'],
],
@@ -83,4 +85,19 @@ describe('read-model outbox payload', () => {
if (!payload) throw new Error('valid payload rejected');
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);
});
});