feat: read model outbox 재시도 dispatcher를 추가
This commit is contained in:
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasRealtimeReadModelChanges,
|
||||
parseReadModelOutboxPayload,
|
||||
readModelOutboxPayloadToChanges,
|
||||
resolveRealtimeReadModelInvalidation,
|
||||
} from '../src/index.js';
|
||||
|
||||
describe('read-model outbox payload', () => {
|
||||
it('validates the version, domain, entity ID, and bigint revision', () => {
|
||||
expect(
|
||||
parseReadModelOutboxPayload({
|
||||
version: 1,
|
||||
changes: [
|
||||
['general.content', 7, '12'],
|
||||
['map.world', 0, '3'],
|
||||
],
|
||||
})
|
||||
).toEqual({
|
||||
version: 1,
|
||||
changes: [
|
||||
['general.content', 7, '12'],
|
||||
['map.world', 0, '3'],
|
||||
],
|
||||
});
|
||||
expect(parseReadModelOutboxPayload({ version: 2, changes: [] })).toBeNull();
|
||||
expect(parseReadModelOutboxPayload({ version: 1, changes: [['unknown', 0, '1']] })).toBeNull();
|
||||
expect(parseReadModelOutboxPayload({ version: 1, changes: [['map.world', -1, '1']] })).toBeNull();
|
||||
expect(parseReadModelOutboxPayload({ version: 1, changes: [['map.world', 0, '-1']] })).toBeNull();
|
||||
});
|
||||
|
||||
it('reconstructs an idempotent internal invalidation without broadening map-only changes', () => {
|
||||
const payload = parseReadModelOutboxPayload({
|
||||
version: 1,
|
||||
changes: [
|
||||
['general.content', 7, '2'],
|
||||
['map.general', 7, '2'],
|
||||
['map.world', 0, '9'],
|
||||
['front.general', 7, '3'],
|
||||
['front.nation', 4, '5'],
|
||||
['records.general', 7, '8'],
|
||||
['reserved.general', 7, '4'],
|
||||
['lobby.general', 7, '2'],
|
||||
['contacts.world', 0, '6'],
|
||||
],
|
||||
});
|
||||
if (!payload) throw new Error('valid payload rejected');
|
||||
|
||||
const changes = readModelOutboxPayloadToChanges(payload);
|
||||
expect(changes).toMatchObject({
|
||||
generalIds: [7],
|
||||
mapGeneralIds: [7],
|
||||
mapChanged: true,
|
||||
frontStatusActorIds: [7],
|
||||
frontStatusNationIds: [4],
|
||||
recordGeneralIds: [7],
|
||||
reservedGeneralIds: [7],
|
||||
lobbyGeneralIds: [7],
|
||||
contactsChanged: true,
|
||||
worldChanged: false,
|
||||
});
|
||||
expect(hasRealtimeReadModelChanges(changes)).toBe(true);
|
||||
expect(resolveRealtimeReadModelInvalidation(changes, { generalId: 99, cityId: 2, nationId: 9 })).toMatchObject(
|
||||
{
|
||||
map: true,
|
||||
context: false,
|
||||
commands: false,
|
||||
lobby: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps access-only, tournament, and betting domains off the dashboard channel', () => {
|
||||
const payload = parseReadModelOutboxPayload({
|
||||
version: 1,
|
||||
changes: [
|
||||
['access.general', 7, '1'],
|
||||
['tournament', 0, '1'],
|
||||
['betting', 0, '1'],
|
||||
],
|
||||
});
|
||||
if (!payload) throw new Error('valid payload rejected');
|
||||
expect(hasRealtimeReadModelChanges(readModelOutboxPayloadToChanges(payload))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user