fix(realtime): redact browser event details
This commit is contained in:
@@ -68,60 +68,40 @@ const operationInput = (route: Route, index: number): DashboardBundleInput => {
|
||||
return entry.json ?? (entry as DashboardBundleInput);
|
||||
};
|
||||
|
||||
const readModelChanges = (
|
||||
const readModelInvalidation = (
|
||||
overrides: Partial<{
|
||||
generalIds: number[];
|
||||
cityIds: number[];
|
||||
nationIds: number[];
|
||||
mapGeneralIds: number[];
|
||||
mapCityIds: number[];
|
||||
mapNationIds: number[];
|
||||
frontStatusGeneralIds: number[];
|
||||
frontStatusNationIds: number[];
|
||||
frontStatusActorIds: number[];
|
||||
frontStatusChanged: boolean;
|
||||
lobbyGeneralIds: number[];
|
||||
lobbyChanged: boolean;
|
||||
reservedGeneralIds: number[];
|
||||
recordGeneralIds: number[];
|
||||
worldChanged: boolean;
|
||||
globalRecordsChanged: boolean;
|
||||
worldHistoryChanged: boolean;
|
||||
contactsChanged: boolean;
|
||||
context: boolean;
|
||||
lobby: boolean;
|
||||
map: boolean;
|
||||
commands: boolean;
|
||||
contacts: boolean;
|
||||
boardAccess: boolean;
|
||||
reservedTurns: boolean;
|
||||
records: boolean;
|
||||
frontStatus: boolean;
|
||||
}>
|
||||
) => ({
|
||||
generalIds: [],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
context: false,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: false,
|
||||
contacts: false,
|
||||
boardAccess: false,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const emitReadModelChanges = (page: Page, changes: ReturnType<typeof readModelChanges>) =>
|
||||
const emitReadModelInvalidation = (page: Page, invalidation: ReturnType<typeof readModelInvalidation>) =>
|
||||
page.evaluate((payload) => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime(
|
||||
'readModelChanged',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
revision: Date.now(),
|
||||
changes: payload,
|
||||
invalidation: payload,
|
||||
}
|
||||
);
|
||||
}, changes);
|
||||
}, invalidation);
|
||||
|
||||
const commandTableFixture = (large: boolean, blockedCount = 0) => ({
|
||||
general: large
|
||||
@@ -1279,26 +1259,6 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
|
||||
const callsBeforeRefresh = state.generalMeCalls;
|
||||
const operationsBeforeClockOnly = state.operations.length;
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'turnCompleted',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
changes: {
|
||||
generalIds: [],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(state.operations.slice(operationsBeforeClockOnly)).toEqual([]);
|
||||
|
||||
@@ -1308,28 +1268,17 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
|
||||
.__emitMainRealtime;
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
emit('turnCompleted', {
|
||||
at: new Date().toISOString(),
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
changes: {
|
||||
generalIds: [7],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
emit('readModelInvalidated', {
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1374,29 +1323,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
const operationsBeforeSurvey = state.operations.length;
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'readModelChanged',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
revision: 42,
|
||||
changes: {
|
||||
generalIds: [],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: true,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
invalidation: {
|
||||
context: false,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: false,
|
||||
contacts: false,
|
||||
boardAccess: false,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1471,7 +1409,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
{ op: 'replace', path: '/general/0/values/0/possible', value: false },
|
||||
{ op: 'replace', path: '/general/0/values/0/status', value: 'blocked' },
|
||||
];
|
||||
await emitReadModelChanges(page, readModelChanges({ cityIds: [1], mapCityIds: [] }));
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true }));
|
||||
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeDefence + 1);
|
||||
await expect(page.locator('[data-city-progress="수비"] .city-progress__text')).toHaveText('900 / 2,000');
|
||||
|
||||
@@ -1485,7 +1423,10 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
{ op: 'replace', path: '/general/0/values/1/possible', value: false },
|
||||
{ op: 'replace', path: '/general/0/values/1/status', value: 'blocked' },
|
||||
];
|
||||
await emitReadModelChanges(page, readModelChanges({ nationIds: [1], mapNationIds: [], frontStatusNationIds: [] }));
|
||||
await emitReadModelInvalidation(
|
||||
page,
|
||||
readModelInvalidation({ context: true, commands: true, boardAccess: true })
|
||||
);
|
||||
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1);
|
||||
|
||||
const callsBeforeCityState = state.generalMeCalls;
|
||||
@@ -1499,7 +1440,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
{ op: 'replace', path: '/general/0/values/2/possible', value: false },
|
||||
{ op: 'replace', path: '/general/0/values/2/status', value: 'blocked' },
|
||||
];
|
||||
await emitReadModelChanges(page, readModelChanges({ cityIds: [1], mapCityIds: [1] }));
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, map: true, commands: true }));
|
||||
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeCityState + 1);
|
||||
await expect(page.locator('.city-base .city-state img')).toHaveAttribute('src', /event5\.gif$/u);
|
||||
expect(state.operations.slice(operationsBeforeCityState).sort()).toEqual(
|
||||
@@ -1512,7 +1453,10 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
state.contextRevision = 'O'.repeat(22);
|
||||
state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }];
|
||||
state.commandTableOperations = [];
|
||||
await emitReadModelChanges(page, readModelChanges({ generalIds: [7] }));
|
||||
await emitReadModelInvalidation(
|
||||
page,
|
||||
readModelInvalidation({ context: true, commands: true, boardAccess: true })
|
||||
);
|
||||
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2);
|
||||
expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1);
|
||||
await expect(page.locator('.general-title')).toContainText('snapshot복구장수');
|
||||
@@ -1541,29 +1485,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
const callsAfterLeavingMain = state.generalMeCalls;
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'turnCompleted',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
changes: {
|
||||
generalIds: [7],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1597,7 +1530,7 @@ test('global activity, world history, and a month boundary refresh their visible
|
||||
{ id: 3, text: '장수 동향 기록' },
|
||||
];
|
||||
const operationsBeforeGlobal = state.operations.length;
|
||||
await emitReadModelChanges(page, readModelChanges({ globalRecordsChanged: true }));
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
|
||||
await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향');
|
||||
expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']);
|
||||
|
||||
@@ -1606,24 +1539,22 @@ test('global activity, world history, and a month boundary refresh their visible
|
||||
{ id: 1, text: '중원 정세 기록' },
|
||||
];
|
||||
const operationsBeforeHistory = state.operations.length;
|
||||
await emitReadModelChanges(page, readModelChanges({ worldHistoryChanged: true }));
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
|
||||
await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세');
|
||||
expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']);
|
||||
|
||||
state.currentMonth = 2;
|
||||
const operationsBeforeMonth = state.operations.length;
|
||||
await page.evaluate(
|
||||
(changes) => {
|
||||
(invalidation) => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'turnCompleted',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
changes,
|
||||
invalidation,
|
||||
}
|
||||
);
|
||||
},
|
||||
readModelChanges({ worldChanged: true })
|
||||
readModelInvalidation({ lobby: true, map: true, commands: true })
|
||||
);
|
||||
await expect(page.getByText('현재: 185년 2월')).toBeVisible();
|
||||
await expect(page.locator('.map-viewer')).toContainText('185年 2月');
|
||||
@@ -1689,29 +1620,18 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
|
||||
state.generalName = '탭공유갱신장수';
|
||||
await leaderPage.evaluate(() => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'readModelChanged',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
revision: 100,
|
||||
changes: {
|
||||
generalIds: [7],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1727,29 +1647,18 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
|
||||
state.generalName = '리더만갱신장수';
|
||||
await leaderPage.evaluate(() => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'readModelChanged',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
revision: 101,
|
||||
changes: {
|
||||
generalIds: [7],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType
|
||||
import {
|
||||
applyReadModelDelta,
|
||||
cloneReadModelJson,
|
||||
type RealtimeEvent,
|
||||
type RealtimeReadModelChanges,
|
||||
type PublicRealtimeEvent,
|
||||
type RealtimeReadModelInvalidation,
|
||||
} from '@sammo-ts/common';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { useMapViewerStore } from './mapViewer';
|
||||
@@ -13,7 +13,7 @@ import { useSessionStore } from './session';
|
||||
import { createLatestRefreshQueue } from '../utils/latestRefreshQueue';
|
||||
import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue';
|
||||
import { structurallyShare } from '../utils/structuralShare';
|
||||
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel';
|
||||
import { createMergedReadModelRefreshQueue } from '../utils/dashboardReadModel';
|
||||
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
|
||||
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
|
||||
|
||||
@@ -612,16 +612,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
);
|
||||
|
||||
const refreshChangedReadModels = async (changes: RealtimeReadModelChanges) => {
|
||||
const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation) => {
|
||||
const id = generalId.value;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const plan = resolveDashboardRefreshPlan(changes, {
|
||||
generalId: id,
|
||||
cityId: city.value?.id ?? null,
|
||||
nationId: nation.value?.id ?? null,
|
||||
});
|
||||
if (!Object.values(plan).some(Boolean)) {
|
||||
return;
|
||||
}
|
||||
@@ -944,12 +939,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const parseRealtimePayload = (raw: MessageEvent): RealtimeEvent | null => {
|
||||
const parseRealtimePayload = (raw: MessageEvent): PublicRealtimeEvent | null => {
|
||||
if (!raw.data || typeof raw.data !== 'string') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw.data) as RealtimeEvent;
|
||||
const parsed = JSON.parse(raw.data) as PublicRealtimeEvent;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
@@ -962,21 +957,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isMailboxRelevant = (mailbox: number): boolean => {
|
||||
if (mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
||||
return true;
|
||||
}
|
||||
const currentGeneralId = generalId.value;
|
||||
if (currentGeneralId && mailbox === currentGeneralId) {
|
||||
return true;
|
||||
}
|
||||
const currentNationId = nationId.value;
|
||||
if (currentNationId && mailbox === MESSAGE_MAILBOX_NATIONAL_BASE + currentNationId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const closeRealtimeSource = () => {
|
||||
if (!realtimeSource) {
|
||||
return;
|
||||
@@ -1095,36 +1075,34 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
|
||||
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
|
||||
});
|
||||
source.addEventListener('turnCompleted', (event) => {
|
||||
source.addEventListener('readModelInvalidated', (event) => {
|
||||
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
|
||||
const payload = parseRealtimePayload(event);
|
||||
if (!payload || payload.type !== 'turnCompleted') {
|
||||
if (!payload || payload.type !== 'readModelInvalidated') {
|
||||
return;
|
||||
}
|
||||
if (!payload.changes) {
|
||||
// Rolling deployment fallback for an older daemon.
|
||||
readModelRefreshQueue.request(payload.invalidation);
|
||||
});
|
||||
source.addEventListener('messagesInvalidated', (event) => {
|
||||
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
|
||||
const payload = parseRealtimePayload(event);
|
||||
if (!payload || payload.type !== 'messagesInvalidated') {
|
||||
return;
|
||||
}
|
||||
void refreshMessages();
|
||||
});
|
||||
|
||||
// Rolling deployment fallback: an older API may still expose internal
|
||||
// events. Do not inspect their payload; use the bounded full refresh.
|
||||
for (const legacyEventType of ['turnCompleted', 'readModelChanged'] as const) {
|
||||
source.addEventListener(legacyEventType, () => {
|
||||
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
|
||||
realtimeRefreshQueue.request();
|
||||
return;
|
||||
}
|
||||
readModelRefreshQueue.request(payload.changes);
|
||||
});
|
||||
source.addEventListener('readModelChanged', (event) => {
|
||||
});
|
||||
}
|
||||
source.addEventListener('messageCreated', () => {
|
||||
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
|
||||
const payload = parseRealtimePayload(event);
|
||||
if (!payload || payload.type !== 'readModelChanged') {
|
||||
return;
|
||||
}
|
||||
readModelRefreshQueue.request(payload.changes);
|
||||
});
|
||||
source.addEventListener('messageCreated', (event) => {
|
||||
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
|
||||
const payload = parseRealtimePayload(event);
|
||||
if (!payload || payload.type !== 'messageCreated') {
|
||||
return;
|
||||
}
|
||||
if (isMailboxRelevant(payload.mailbox)) {
|
||||
void refreshMessages();
|
||||
}
|
||||
void refreshMessages();
|
||||
});
|
||||
source.addEventListener('ping', () => {
|
||||
if (realtimeEnabled.value) {
|
||||
|
||||
@@ -1,85 +1,29 @@
|
||||
import {
|
||||
createEmptyRealtimeReadModelChanges,
|
||||
mergeRealtimeReadModelChanges,
|
||||
createEmptyRealtimeReadModelInvalidation,
|
||||
mergeRealtimeReadModelInvalidations,
|
||||
resolveRealtimeReadModelInvalidation,
|
||||
type RealtimeReadModelChanges,
|
||||
type RealtimeReadModelInvalidation,
|
||||
type RealtimeViewerIdentity,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
export interface DashboardReadModelIdentity {
|
||||
generalId: number | null;
|
||||
cityId: number | null;
|
||||
nationId: number | null;
|
||||
}
|
||||
|
||||
export interface DashboardRefreshPlan {
|
||||
context: boolean;
|
||||
lobby: boolean;
|
||||
map: boolean;
|
||||
commands: boolean;
|
||||
contacts: boolean;
|
||||
boardAccess: boolean;
|
||||
reservedTurns: boolean;
|
||||
records: boolean;
|
||||
frontStatus: boolean;
|
||||
}
|
||||
|
||||
const contains = (ids: readonly number[], id: number | null): boolean => id !== null && ids.includes(id);
|
||||
export type DashboardReadModelIdentity = RealtimeViewerIdentity;
|
||||
export type DashboardRefreshPlan = RealtimeReadModelInvalidation;
|
||||
|
||||
export const resolveDashboardRefreshPlan = (
|
||||
changes: RealtimeReadModelChanges,
|
||||
identity: DashboardReadModelIdentity
|
||||
): DashboardRefreshPlan => {
|
||||
const ownGeneralChanged = contains(changes.generalIds, identity.generalId);
|
||||
const ownCityChanged = contains(changes.cityIds, identity.cityId);
|
||||
const ownNationChanged = contains(changes.nationIds, identity.nationId);
|
||||
const ownFrontStatusNationChanged = contains(
|
||||
changes.frontStatusNationIds ?? changes.nationIds,
|
||||
identity.nationId
|
||||
);
|
||||
const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId);
|
||||
const frontStatusGeneralChanged =
|
||||
changes.frontStatusGeneralIds !== undefined
|
||||
? changes.frontStatusGeneralIds.length > 0
|
||||
: changes.contactsChanged;
|
||||
const ownFrontStatusActorChanged = contains(changes.frontStatusActorIds ?? [], identity.generalId);
|
||||
const ownLobbyGeneralChanged = contains(changes.lobbyGeneralIds ?? changes.generalIds, identity.generalId);
|
||||
const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged;
|
||||
const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged;
|
||||
const mapEntitiesChanged =
|
||||
(changes.mapCityIds ?? changes.cityIds).length > 0 ||
|
||||
(changes.mapNationIds ?? changes.nationIds).length > 0;
|
||||
const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0;
|
||||
|
||||
return {
|
||||
context: entityContextChanged,
|
||||
lobby: changes.worldChanged || lobbyChanged || ownLobbyGeneralChanged,
|
||||
map: changes.worldChanged || mapEntitiesChanged || ownGeneralMapChanged,
|
||||
commands: changes.worldChanged || commandEntitiesChanged || ownGeneralChanged,
|
||||
contacts: changes.contactsChanged,
|
||||
boardAccess: ownGeneralChanged || ownNationChanged,
|
||||
reservedTurns: contains(changes.reservedGeneralIds, identity.generalId),
|
||||
records:
|
||||
changes.globalRecordsChanged ||
|
||||
changes.worldHistoryChanged ||
|
||||
contains(changes.recordGeneralIds, identity.generalId),
|
||||
// lastTurnTime is intentionally excluded. This slice contains the
|
||||
// nation notice/vote/presence model and only follows related changes.
|
||||
frontStatus:
|
||||
Boolean(changes.frontStatusChanged) ||
|
||||
frontStatusGeneralChanged ||
|
||||
ownFrontStatusNationChanged ||
|
||||
ownFrontStatusActorChanged,
|
||||
};
|
||||
};
|
||||
): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity);
|
||||
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
export interface MergedReadModelRefreshQueue {
|
||||
request(changes: RealtimeReadModelChanges): void;
|
||||
request(invalidation: RealtimeReadModelInvalidation): void;
|
||||
cancelPending(): void;
|
||||
}
|
||||
|
||||
export const createMergedReadModelRefreshQueue = (
|
||||
refresh: (changes: RealtimeReadModelChanges) => Promise<void>,
|
||||
refresh: (invalidation: RealtimeReadModelInvalidation) => Promise<void>,
|
||||
options: {
|
||||
minIntervalMs?: number;
|
||||
now?: () => number;
|
||||
@@ -91,7 +35,7 @@ export const createMergedReadModelRefreshQueue = (
|
||||
const now = options.now ?? Date.now;
|
||||
const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
||||
let pending = createEmptyRealtimeReadModelChanges();
|
||||
let pending = createEmptyRealtimeReadModelInvalidation();
|
||||
let hasPending = false;
|
||||
let running = false;
|
||||
let timer: TimerHandle | null = null;
|
||||
@@ -108,7 +52,7 @@ export const createMergedReadModelRefreshQueue = (
|
||||
return;
|
||||
}
|
||||
const next = pending;
|
||||
pending = createEmptyRealtimeReadModelChanges();
|
||||
pending = createEmptyRealtimeReadModelInvalidation();
|
||||
hasPending = false;
|
||||
running = true;
|
||||
lastStartedAt = now();
|
||||
@@ -120,14 +64,14 @@ export const createMergedReadModelRefreshQueue = (
|
||||
};
|
||||
|
||||
return {
|
||||
request: (changes) => {
|
||||
pending = hasPending ? mergeRealtimeReadModelChanges(pending, changes) : changes;
|
||||
request: (invalidation) => {
|
||||
pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation;
|
||||
hasPending = true;
|
||||
schedule();
|
||||
},
|
||||
cancelPending: () => {
|
||||
hasPending = false;
|
||||
pending = createEmptyRealtimeReadModelChanges();
|
||||
pending = createEmptyRealtimeReadModelInvalidation();
|
||||
if (timer !== null) {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createEmptyRealtimeReadModelChanges } from '@sammo-ts/common';
|
||||
import { createEmptyRealtimeReadModelChanges, createEmptyRealtimeReadModelInvalidation } from '@sammo-ts/common';
|
||||
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../src/utils/dashboardReadModel.ts';
|
||||
|
||||
void test('last-turn-time-only events do not schedule any dashboard query', () => {
|
||||
@@ -170,14 +170,14 @@ void test('targets a submitted survey projection to its own general', () => {
|
||||
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false);
|
||||
});
|
||||
|
||||
void test('merges burst payloads without losing entity ids and starts at most once per interval', async () => {
|
||||
void test('merges browser-safe boolean invalidations and starts at most once per interval', async () => {
|
||||
let nowMs = 0;
|
||||
let nextTimerId = 1;
|
||||
const timers = new Map<number, { callback: () => void; at: number }>();
|
||||
const observed: number[][] = [];
|
||||
const observed: Array<{ context: boolean; records: boolean }> = [];
|
||||
const queue = createMergedReadModelRefreshQueue(
|
||||
async (changes) => {
|
||||
observed.push(changes.generalIds);
|
||||
async (invalidation) => {
|
||||
observed.push({ context: invalidation.context, records: invalidation.records });
|
||||
},
|
||||
{
|
||||
minIntervalMs: 1_000,
|
||||
@@ -199,18 +199,21 @@ void test('merges burst payloads without losing entity ids and starts at most on
|
||||
}
|
||||
};
|
||||
|
||||
queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [7] });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true });
|
||||
runDueTimers();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(observed, [[7]]);
|
||||
assert.deepEqual(observed, [{ context: true, records: false }]);
|
||||
|
||||
queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [9] });
|
||||
queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [8, 9] });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true });
|
||||
nowMs = 999;
|
||||
runDueTimers();
|
||||
assert.equal(observed.length, 1);
|
||||
nowMs = 1_000;
|
||||
runDueTimers();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(observed, [[7], [8, 9]]);
|
||||
assert.deepEqual(observed, [
|
||||
{ context: true, records: false },
|
||||
{ context: true, records: true },
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user