86개 API mutation을 분류하고 메시지 mailbox, 베팅, 국가 설정, 예약 명령의 revision/outbox 표식을 소유 transaction에 연결한다. 공개 SSE는 식별자 없는 invalidation만 노출한다.
146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
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 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;
|
|
}
|
|
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 'dashboard.global':
|
|
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 'messages.mailbox':
|
|
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),
|
|
};
|
|
};
|