feat: read model outbox 재시도 dispatcher를 추가

This commit is contained in:
2026-08-16 18:09:04 +00:00
parent 54126cd67f
commit 8278ef5221
9 changed files with 611 additions and 1 deletions
+1
View File
@@ -19,6 +19,7 @@ export * from './realtime/keys.js';
export * from './realtime/types.js';
export * from './realtime/delta.js';
export * from './realtime/changeJournal.js';
export * from './realtime/readModelOutbox.js';
export * from './ranking/types.js';
export * from './ranking/legacyColor.js';
export * from './auth/accountIconProjection.js';
@@ -0,0 +1,135 @@
import {
isReadModelDomain,
READ_MODEL_OUTBOX_PAYLOAD_VERSION,
type ReadModelOutboxPayloadV1,
} from './changeJournal.js';
import { createEmptyRealtimeReadModelChanges, type RealtimeReadModelChanges } from './types.js';
const uniqueSortedIds = (values: Iterable<number>): number[] =>
[...new Set(values)].sort((left, right) => left - right);
export const parseReadModelOutboxPayload = (value: unknown): ReadModelOutboxPayloadV1 | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (record.version !== READ_MODEL_OUTBOX_PAYLOAD_VERSION || !Array.isArray(record.changes)) {
return null;
}
const changes: Array<readonly [string, number, string]> = [];
for (const item of record.changes) {
if (!Array.isArray(item) || item.length !== 3) {
return null;
}
const [domain, entityId, revision] = item;
if (
typeof domain !== 'string' ||
!isReadModelDomain(domain) ||
!Number.isSafeInteger(entityId) ||
(entityId as number) < 0 ||
typeof revision !== 'string' ||
!/^(?:0|[1-9][0-9]*)$/u.test(revision)
) {
return null;
}
try {
BigInt(revision);
} catch {
return null;
}
changes.push([domain, entityId as number, revision]);
}
return { version: READ_MODEL_OUTBOX_PAYLOAD_VERSION, changes } as ReadModelOutboxPayloadV1;
};
/**
* Converts the durable internal domain envelope back into the legacy internal
* invalidation shape. The result still contains entity IDs and must be
* viewer-filtered before it crosses the public SSE boundary.
*/
export const readModelOutboxPayloadToChanges = (
payload: ReadModelOutboxPayloadV1
): RealtimeReadModelChanges => {
const changes = createEmptyRealtimeReadModelChanges();
const generalIds: number[] = [];
const cityIds: number[] = [];
const nationIds: number[] = [];
const mapGeneralIds: number[] = [];
const frontStatusNationIds: number[] = [];
const frontStatusActorIds: number[] = [];
const lobbyGeneralIds: number[] = [];
const reservedGeneralIds: number[] = [];
const recordGeneralIds: number[] = [];
for (const [domain, entityId] of payload.changes) {
switch (domain) {
case 'general.content':
generalIds.push(entityId);
break;
case 'city.content':
cityIds.push(entityId);
break;
case 'nation.content':
nationIds.push(entityId);
break;
case 'world.content':
changes.worldChanged = true;
break;
case 'map.world':
changes.mapChanged = true;
break;
case 'map.general':
mapGeneralIds.push(entityId);
break;
case 'records.general':
recordGeneralIds.push(entityId);
break;
case 'records.global':
changes.globalRecordsChanged = true;
break;
case 'records.history':
changes.worldHistoryChanged = true;
break;
case 'front.general':
frontStatusActorIds.push(entityId);
break;
case 'front.nation':
frontStatusNationIds.push(entityId);
break;
case 'front.global':
changes.frontStatusChanged = true;
break;
case 'lobby.world':
changes.lobbyChanged = true;
break;
case 'lobby.general':
lobbyGeneralIds.push(entityId);
break;
case 'contacts.world':
changes.contactsChanged = true;
break;
case 'reserved.general':
reservedGeneralIds.push(entityId);
break;
case 'access.general':
case 'tournament':
case 'betting':
// These domains have no browser-wide dashboard invalidation.
break;
}
}
return {
...changes,
generalIds: uniqueSortedIds(generalIds),
cityIds: uniqueSortedIds(cityIds),
nationIds: uniqueSortedIds(nationIds),
mapGeneralIds: uniqueSortedIds(mapGeneralIds),
frontStatusNationIds: uniqueSortedIds(frontStatusNationIds),
frontStatusActorIds: uniqueSortedIds(frontStatusActorIds),
lobbyGeneralIds: uniqueSortedIds(lobbyGeneralIds),
reservedGeneralIds: uniqueSortedIds(reservedGeneralIds),
recordGeneralIds: uniqueSortedIds(recordGeneralIds),
};
};
+5 -1
View File
@@ -30,6 +30,8 @@ export interface RealtimeReadModelChanges {
contactsChanged: boolean;
/** A global front-status source such as the active survey changed. */
frontStatusChanged?: boolean;
/** Shared base-map projection changed without implying other world slices. */
mapChanged?: boolean;
lobbyChanged?: boolean;
}
@@ -125,7 +127,7 @@ export const resolveRealtimeReadModelInvalidation = (
return {
context: entityContextChanged,
lobby: changes.worldChanged || lobbyChanged || ownLobbyGeneralChanged,
map: changes.worldChanged || mapEntitiesChanged || ownGeneralMapChanged,
map: changes.worldChanged || Boolean(changes.mapChanged) || mapEntitiesChanged || ownGeneralMapChanged,
commands: changes.worldChanged || commandEntitiesChanged || ownGeneralChanged,
contacts: changes.contactsChanged,
boardAccess: ownGeneralChanged || ownNationChanged,
@@ -160,6 +162,7 @@ export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges
worldHistoryChanged: false,
contactsChanged: false,
frontStatusChanged: false,
mapChanged: false,
lobbyChanged: false,
});
@@ -175,6 +178,7 @@ export const hasRealtimeReadModelChanges = (changes: RealtimeReadModelChanges):
changes.worldHistoryChanged ||
changes.contactsChanged ||
Boolean(changes.frontStatusChanged) ||
Boolean(changes.mapChanged) ||
Boolean(changes.lobbyChanged);
export interface TurnCompletedEvent {