merge: recover realtime dashboard delta cloning
This commit is contained in:
@@ -89,12 +89,8 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
||||
penalty: {},
|
||||
},
|
||||
],
|
||||
listCities: () => [
|
||||
{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1, population: 100 },
|
||||
],
|
||||
listNations: () => [
|
||||
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, gold: 100 },
|
||||
],
|
||||
listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1, population: 100 }],
|
||||
listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, gold: 100 }],
|
||||
} as unknown as InMemoryTurnWorld);
|
||||
const changes = {
|
||||
generals: [
|
||||
@@ -153,6 +149,107 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies defence, nation policy, and current-city state changes by canonical projection', () => {
|
||||
const baseline = createRealtimeReadModelBaseline({
|
||||
listGenerals: () => [],
|
||||
listCities: () => [
|
||||
{
|
||||
id: 3,
|
||||
name: '업',
|
||||
level: 8,
|
||||
nationId: 2,
|
||||
state: 0,
|
||||
supplyState: 1,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
},
|
||||
],
|
||||
listNations: () => [
|
||||
{
|
||||
id: 2,
|
||||
name: '위',
|
||||
color: '#008000',
|
||||
capitalCityId: 3,
|
||||
meta: { rate: 20, bill: 100 },
|
||||
},
|
||||
],
|
||||
} as unknown as InMemoryTurnWorld);
|
||||
const emptyChanges = {
|
||||
generals: [],
|
||||
createdGenerals: [],
|
||||
deletedGenerals: [],
|
||||
createdNations: [],
|
||||
deletedNations: [],
|
||||
deletedNationSnapshots: [],
|
||||
lifecycleEvents: [],
|
||||
logs: [],
|
||||
};
|
||||
|
||||
const defenceChanges = {
|
||||
...emptyChanges,
|
||||
cities: [
|
||||
{
|
||||
id: 3,
|
||||
name: '업',
|
||||
level: 8,
|
||||
nationId: 2,
|
||||
state: 0,
|
||||
supplyState: 1,
|
||||
defence: 900,
|
||||
defenceMax: 2_000,
|
||||
},
|
||||
],
|
||||
nations: [],
|
||||
} as unknown as TurnWorldChanges;
|
||||
expect(summarizeRealtimeReadModelChanges(defenceChanges, undefined, baseline)).toMatchObject({
|
||||
cityIds: [3],
|
||||
mapCityIds: [],
|
||||
nationIds: [],
|
||||
});
|
||||
|
||||
const policyChanges = {
|
||||
...emptyChanges,
|
||||
cities: [],
|
||||
nations: [
|
||||
{
|
||||
id: 2,
|
||||
name: '위',
|
||||
color: '#008000',
|
||||
capitalCityId: 3,
|
||||
meta: { rate: 25, bill: 120 },
|
||||
},
|
||||
],
|
||||
} as unknown as TurnWorldChanges;
|
||||
expect(summarizeRealtimeReadModelChanges(policyChanges, undefined, baseline)).toMatchObject({
|
||||
cityIds: [],
|
||||
nationIds: [2],
|
||||
mapNationIds: [],
|
||||
frontStatusNationIds: [],
|
||||
});
|
||||
|
||||
const stateChanges = {
|
||||
...emptyChanges,
|
||||
cities: [
|
||||
{
|
||||
id: 3,
|
||||
name: '업',
|
||||
level: 8,
|
||||
nationId: 2,
|
||||
state: 5,
|
||||
supplyState: 1,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
},
|
||||
],
|
||||
nations: [],
|
||||
} as unknown as TurnWorldChanges;
|
||||
expect(summarizeRealtimeReadModelChanges(stateChanges, undefined, baseline)).toMatchObject({
|
||||
cityIds: [3],
|
||||
mapCityIds: [3],
|
||||
nationIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('detects map, contact, and front-status fields independently', () => {
|
||||
const baseline = createRealtimeReadModelBaseline({
|
||||
listGenerals: () => [
|
||||
@@ -168,9 +265,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
||||
},
|
||||
],
|
||||
listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1 }],
|
||||
listNations: () => [
|
||||
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } },
|
||||
],
|
||||
listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } }],
|
||||
} as unknown as InMemoryTurnWorld);
|
||||
const changes = {
|
||||
generals: [
|
||||
@@ -188,9 +283,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
||||
createdGenerals: [],
|
||||
deletedGenerals: [],
|
||||
cities: [{ id: 1, level: 2, nationId: 1, state: 0, supplyState: 1 }],
|
||||
nations: [
|
||||
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '새 방침' } },
|
||||
],
|
||||
nations: [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '새 방침' } }],
|
||||
createdNations: [],
|
||||
deletedNations: [],
|
||||
deletedNationSnapshots: [],
|
||||
@@ -229,9 +322,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
||||
},
|
||||
],
|
||||
listCities: () => [],
|
||||
listNations: () => [
|
||||
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } },
|
||||
],
|
||||
listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } }],
|
||||
} as unknown as InMemoryTurnWorld);
|
||||
const changes = {
|
||||
generals: [
|
||||
@@ -249,9 +340,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
||||
createdGenerals: [],
|
||||
deletedGenerals: [],
|
||||
cities: [],
|
||||
nations: [
|
||||
{ id: 1, name: '촉', color: '#008000', capitalCityId: 1, meta: { notice: '새 공지' } },
|
||||
],
|
||||
nations: [{ id: 1, name: '촉', color: '#008000', capitalCityId: 1, meta: { notice: '새 공지' } }],
|
||||
createdNations: [],
|
||||
deletedNations: [],
|
||||
deletedNationSnapshots: [],
|
||||
|
||||
@@ -20,6 +20,15 @@ type NavigationFixture = {
|
||||
generalMeCalls: number;
|
||||
operations: string[];
|
||||
generalName?: string;
|
||||
cityDefence?: number;
|
||||
cityState?: number;
|
||||
nationRate?: number;
|
||||
contextRevision?: string;
|
||||
contextOperations?: JsonPatchOperation[];
|
||||
commandTableRevision?: string;
|
||||
commandTableOperations?: JsonPatchOperation[];
|
||||
commandBlockedCount?: number;
|
||||
forceSnapshotCalls?: number;
|
||||
refreshDelayMs?: number;
|
||||
largeCommandTable?: boolean;
|
||||
dashboardResponses?: Array<{
|
||||
@@ -30,6 +39,12 @@ type NavigationFixture = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type JsonPatchOperation = {
|
||||
op: 'add' | 'remove' | 'replace';
|
||||
path: string;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
type DashboardBundleInput = {
|
||||
include?: { context?: boolean; commandTable?: boolean; boardAccess?: boolean };
|
||||
known?: { context?: string; commandTable?: string; boardAccess?: string };
|
||||
@@ -44,7 +59,62 @@ const operationInput = (route: Route, index: number): DashboardBundleInput => {
|
||||
return entry.json ?? (entry as DashboardBundleInput);
|
||||
};
|
||||
|
||||
const commandTableFixture = (large: boolean) => ({
|
||||
const readModelChanges = (
|
||||
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;
|
||||
}>
|
||||
) => ({
|
||||
generalIds: [],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const emitReadModelChanges = (page: Page, changes: ReturnType<typeof readModelChanges>) =>
|
||||
page.evaluate((payload) => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime(
|
||||
'readModelChanged',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
revision: Date.now(),
|
||||
changes: payload,
|
||||
}
|
||||
);
|
||||
}, changes);
|
||||
|
||||
const commandTableFixture = (large: boolean, blockedCount = 0) => ({
|
||||
general: large
|
||||
? [
|
||||
{
|
||||
@@ -53,8 +123,8 @@ const commandTableFixture = (large: boolean) => ({
|
||||
key: `command-${index}`,
|
||||
name: `명령 ${index}`,
|
||||
reqArg: index % 2 === 0,
|
||||
possible: true,
|
||||
status: 'available',
|
||||
possible: index >= blockedCount,
|
||||
status: index >= blockedCount ? 'available' : 'blocked',
|
||||
inputFields: [
|
||||
{
|
||||
key: 'amount',
|
||||
@@ -87,6 +157,7 @@ const COMMAND_TABLE_REVISION = 'CCCCCCCCCCCCCCCCCCCCCC';
|
||||
const BOARD_ACCESS_REVISION = 'DDDDDDDDDDDDDDDDDDDDDD';
|
||||
|
||||
const contextRevision = (state: NavigationFixture) => {
|
||||
if (state.contextRevision) return state.contextRevision;
|
||||
const name = state.generalName ?? '메뉴검증장수';
|
||||
if (name === '메뉴검증장수') return CONTEXT_INITIAL_REVISION;
|
||||
if (name === '부드럽게갱신된장수') return 'EEEEEEEEEEEEEEEEEEEEEE';
|
||||
@@ -95,14 +166,20 @@ const contextRevision = (state: NavigationFixture) => {
|
||||
return 'HHHHHHHHHHHHHHHHHHHHHH';
|
||||
};
|
||||
|
||||
const deltaSlice = <T>(value: T, revision: string, known: string | undefined, forceSnapshot: boolean) => {
|
||||
const deltaSlice = <T>(
|
||||
value: T,
|
||||
revision: string,
|
||||
known: string | undefined,
|
||||
forceSnapshot: boolean,
|
||||
operations?: JsonPatchOperation[]
|
||||
) => {
|
||||
if (forceSnapshot || !known) return { kind: 'snapshot' as const, revision, data: value };
|
||||
if (known === revision) return { kind: 'unchanged' as const, revision };
|
||||
return {
|
||||
kind: 'patch' as const,
|
||||
baseRevision: known,
|
||||
revision,
|
||||
operations: [
|
||||
operations: operations ?? [
|
||||
{
|
||||
op: 'replace' as const,
|
||||
path: '/general/name',
|
||||
@@ -169,12 +246,13 @@ const generalContext = (state: NavigationFixture) => ({
|
||||
securityMax: 2_000,
|
||||
trust: 80,
|
||||
trade: 100,
|
||||
defence: 1_000,
|
||||
defence: state.cityDefence ?? 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
state: state.cityState ?? 0,
|
||||
},
|
||||
nation: {
|
||||
id: 1,
|
||||
@@ -184,7 +262,7 @@ const generalContext = (state: NavigationFixture) => ({
|
||||
gold: 10_000,
|
||||
rice: 20_000,
|
||||
tech: 100,
|
||||
rate: 20,
|
||||
rate: state.nationRate ?? 20,
|
||||
bill: 100,
|
||||
capitalCityId: 1,
|
||||
typeCode: 'che_유가',
|
||||
@@ -241,18 +319,33 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
const input = operationInput(route, index);
|
||||
const include = input.include ?? {};
|
||||
const forceSnapshot = input.forceSnapshot === true;
|
||||
if (forceSnapshot) state.forceSnapshotCalls = (state.forceSnapshotCalls ?? 0) + 1;
|
||||
const revision = contextRevision(state);
|
||||
const context = include.context
|
||||
? deltaSlice(generalContext(state), revision, input.known?.context, forceSnapshot)
|
||||
? deltaSlice(
|
||||
generalContext(state),
|
||||
revision,
|
||||
input.known?.context,
|
||||
forceSnapshot,
|
||||
state.contextOperations
|
||||
)
|
||||
: undefined;
|
||||
const currentCommandTableRevision = state.commandTableRevision ?? COMMAND_TABLE_REVISION;
|
||||
const commandTable = include.commandTable
|
||||
? forceSnapshot || !input.known?.commandTable
|
||||
? {
|
||||
kind: 'snapshot' as const,
|
||||
revision: COMMAND_TABLE_REVISION,
|
||||
data: commandTableFixture(state.largeCommandTable === true),
|
||||
revision: currentCommandTableRevision,
|
||||
data: commandTableFixture(state.largeCommandTable === true, state.commandBlockedCount),
|
||||
}
|
||||
: { kind: 'unchanged' as const, revision: COMMAND_TABLE_REVISION }
|
||||
: input.known.commandTable === currentCommandTableRevision
|
||||
? { kind: 'unchanged' as const, revision: currentCommandTableRevision }
|
||||
: {
|
||||
kind: 'patch' as const,
|
||||
baseRevision: input.known.commandTable,
|
||||
revision: currentCommandTableRevision,
|
||||
operations: state.commandTableOperations ?? [],
|
||||
}
|
||||
: undefined;
|
||||
const boardAccess = include.boardAccess
|
||||
? forceSnapshot || !input.known?.boardAccess
|
||||
@@ -297,7 +390,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
startYear: 180,
|
||||
year: 185,
|
||||
month: 1,
|
||||
cityList: [[1, 8, 0, 1, 1, 1]],
|
||||
cityList: [[1, 8, state.cityState ?? 0, 1, 1, 1]],
|
||||
nationList: [[1, '위', '#008000', 1]],
|
||||
spyList: {},
|
||||
shownByGeneralList: [],
|
||||
@@ -1002,6 +1095,78 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
]);
|
||||
}
|
||||
|
||||
const callsBeforeDefence = state.generalMeCalls;
|
||||
state.cityDefence = 900;
|
||||
state.contextRevision = 'I'.repeat(22);
|
||||
state.contextOperations = [{ op: 'replace', path: '/city/defence', value: 900 }];
|
||||
state.commandTableRevision = 'J'.repeat(22);
|
||||
state.commandBlockedCount = 1;
|
||||
state.commandTableOperations = [
|
||||
{ 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 expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeDefence + 1);
|
||||
await expect(page.locator('[data-city-progress="수비"] .city-progress__text')).toHaveText('900 / 2,000');
|
||||
|
||||
const callsBeforeTax = state.generalMeCalls;
|
||||
state.nationRate = 25;
|
||||
state.contextRevision = 'K'.repeat(22);
|
||||
state.contextOperations = [{ op: 'replace', path: '/nation/rate', value: 25 }];
|
||||
state.commandTableRevision = 'L'.repeat(22);
|
||||
state.commandBlockedCount = 2;
|
||||
state.commandTableOperations = [
|
||||
{ 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 expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1);
|
||||
|
||||
const callsBeforeCityState = state.generalMeCalls;
|
||||
const operationsBeforeCityState = state.operations.length;
|
||||
state.cityState = 5;
|
||||
state.contextRevision = 'M'.repeat(22);
|
||||
state.contextOperations = [{ op: 'replace', path: '/city/state', value: 5 }];
|
||||
state.commandTableRevision = 'N'.repeat(22);
|
||||
state.commandBlockedCount = 3;
|
||||
state.commandTableOperations = [
|
||||
{ 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 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(
|
||||
['dashboard.getContextBundleDelta', 'world.getMap'].sort()
|
||||
);
|
||||
|
||||
const callsBeforeFallback = state.generalMeCalls;
|
||||
const forcedBeforeFallback = state.forceSnapshotCalls ?? 0;
|
||||
state.generalName = 'snapshot복구장수';
|
||||
state.contextRevision = 'O'.repeat(22);
|
||||
state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }];
|
||||
state.commandTableOperations = [];
|
||||
await emitReadModelChanges(page, readModelChanges({ generalIds: [7] }));
|
||||
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복구장수');
|
||||
await expect(page.locator('.game-feedback')).toHaveCount(0);
|
||||
await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0);
|
||||
await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0);
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const probe = (
|
||||
window as unknown as {
|
||||
__mainRefreshProbe: { general: Element; city: Element };
|
||||
}
|
||||
).__mainRefreshProbe;
|
||||
return {
|
||||
generalMounted: probe.general === document.querySelector('[data-main-target="general"]'),
|
||||
cityMounted: probe.city === document.querySelector('[data-main-target="city"]'),
|
||||
};
|
||||
})
|
||||
).toEqual({ generalMounted: true, cityMounted: true });
|
||||
|
||||
await page.locator(`a[href="${basePath}/board"]`).first().click();
|
||||
await page.waitForURL(`**${basePath}/board`);
|
||||
expect(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineStore } from 'pinia';
|
||||
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
|
||||
import {
|
||||
applyReadModelDelta,
|
||||
ReadModelDeltaMismatchError,
|
||||
cloneReadModelJson,
|
||||
type RealtimeEvent,
|
||||
type RealtimeReadModelChanges,
|
||||
} from '@sammo-ts/common';
|
||||
@@ -15,6 +15,7 @@ import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'
|
||||
import { structurallyShare } from '../utils/structuralShare';
|
||||
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel';
|
||||
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
|
||||
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
|
||||
|
||||
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
|
||||
|
||||
@@ -103,6 +104,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
let recordGeneralId: number | null = null;
|
||||
let initialized = false;
|
||||
let contextSnapshot: GeneralContext | undefined;
|
||||
let commandTableSnapshot: CommandTable | undefined;
|
||||
let boardAccessSnapshot: BoardAccess | undefined;
|
||||
let contextRevision: string | null = null;
|
||||
let commandTableRevision: string | null = null;
|
||||
let boardAccessRevision: string | null = null;
|
||||
@@ -319,6 +322,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
const applyDashboardPatch = (patch: DashboardReadModelPatch) => {
|
||||
if (patch.contextSnapshot === null) {
|
||||
contextSnapshot = null;
|
||||
commandTableSnapshot = undefined;
|
||||
boardAccessSnapshot = undefined;
|
||||
general.value = null;
|
||||
city.value = null;
|
||||
nation.value = null;
|
||||
@@ -337,6 +342,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
if (patch.general === null) {
|
||||
contextSnapshot = null;
|
||||
commandTableSnapshot = undefined;
|
||||
boardAccessSnapshot = undefined;
|
||||
general.value = null;
|
||||
city.value = null;
|
||||
nation.value = null;
|
||||
@@ -357,14 +364,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (patch.worldMap !== undefined) worldMap.value = structurallyShare(worldMap.value, patch.worldMap);
|
||||
if (patch.mapLayout !== undefined) mapLayout.value = structurallyShare(mapLayout.value, patch.mapLayout);
|
||||
if (patch.commandTable !== undefined) {
|
||||
commandTableSnapshot = patch.commandTable ?? undefined;
|
||||
commandTable.value = structurallyShare(commandTable.value, patch.commandTable);
|
||||
}
|
||||
if (patch.messages !== undefined) messages.value = structurallyShare(messages.value, patch.messages);
|
||||
if (patch.messageContacts !== undefined) {
|
||||
messageContacts.value = structurallyShare(messageContacts.value, patch.messageContacts);
|
||||
}
|
||||
if (patch.boardAccess !== undefined)
|
||||
if (patch.boardAccess !== undefined) {
|
||||
boardAccessSnapshot = patch.boardAccess ?? undefined;
|
||||
boardAccess.value = structurallyShare(boardAccess.value, patch.boardAccess);
|
||||
}
|
||||
if (patch.reservedGeneralTurns !== undefined) {
|
||||
reservedGeneralTurns.value = structurallyShare<unknown>(
|
||||
reservedGeneralTurns.value,
|
||||
@@ -409,10 +419,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
patch.lobbyInfo = toRaw(lobbyInfo.value);
|
||||
patch.worldMap = toRaw(worldMap.value);
|
||||
patch.mapLayout = toRaw(mapLayout.value);
|
||||
patch.commandTable = toRaw(commandTable.value);
|
||||
patch.commandTable = commandTableSnapshot ?? null;
|
||||
patch.messages = toRaw(messages.value);
|
||||
patch.messageContacts = toRaw(messageContacts.value);
|
||||
patch.boardAccess = toRaw(boardAccess.value);
|
||||
patch.boardAccess = boardAccessSnapshot ?? null;
|
||||
patch.reservedGeneralTurns = toRaw(reservedGeneralTurns.value as unknown) as ReservedTurnView[] | null;
|
||||
patch.reservedGeneralRevision = reservedGeneralRevision.value;
|
||||
patch.globalRecords = toRaw(globalRecords.value);
|
||||
@@ -433,16 +443,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
}
|
||||
if (bundle.commandTable) {
|
||||
const current = commandTable.value === null ? undefined : toRaw(commandTable.value);
|
||||
const applied = applyReadModelDelta(current, commandTableRevision, bundle.commandTable);
|
||||
const applied = applyReadModelDelta(commandTableSnapshot, commandTableRevision, bundle.commandTable);
|
||||
patch.commandTableRevision = applied.revision;
|
||||
if (bundle.commandTable.kind !== 'unchanged') {
|
||||
patch.commandTable = applied.data;
|
||||
}
|
||||
}
|
||||
if (bundle.boardAccess) {
|
||||
const current = boardAccess.value === null ? undefined : toRaw(boardAccess.value);
|
||||
const applied = applyReadModelDelta(current, boardAccessRevision, bundle.boardAccess);
|
||||
const applied = applyReadModelDelta(boardAccessSnapshot, boardAccessRevision, bundle.boardAccess);
|
||||
patch.boardAccessRevision = applied.revision;
|
||||
if (bundle.boardAccess.kind !== 'unchanged') {
|
||||
patch.boardAccess = applied.data;
|
||||
@@ -469,15 +477,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
forceSnapshot: force || undefined,
|
||||
});
|
||||
|
||||
const bundle = await request(forceSnapshot);
|
||||
try {
|
||||
return resolveContextBundlePatch(bundle);
|
||||
} catch (error) {
|
||||
if (forceSnapshot || !(error instanceof ReadModelDeltaMismatchError)) {
|
||||
throw error;
|
||||
}
|
||||
return resolveContextBundlePatch(await request(true));
|
||||
}
|
||||
return resolveWithReadModelSnapshotFallback({
|
||||
request,
|
||||
resolve: resolveContextBundlePatch,
|
||||
forceSnapshot,
|
||||
});
|
||||
};
|
||||
|
||||
const refreshMainData = async () => {
|
||||
@@ -576,7 +580,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
let realtimeCoordinatorScope: string | null = null;
|
||||
|
||||
const publishDashboardPatch = (patch: DashboardReadModelPatch) => {
|
||||
realtimeCoordinator?.postFromLeader({ kind: 'patch', patch });
|
||||
if (!realtimeCoordinator) {
|
||||
return;
|
||||
}
|
||||
// BroadcastChannel uses the structured-clone algorithm. Normalize the
|
||||
// full payload so nested Vue proxies never reach that browser boundary.
|
||||
try {
|
||||
realtimeCoordinator.postFromLeader({ kind: 'patch', patch: cloneReadModelJson(patch) });
|
||||
} catch {
|
||||
// Same-account fan-out is best effort; the leader's local refresh
|
||||
// and the next visibility/full-snapshot recovery remain valid.
|
||||
}
|
||||
};
|
||||
|
||||
const realtimeRefreshQueue = createRateLimitedRefreshQueue(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Resolves one delta response and retries once with a full snapshot when the
|
||||
* client baseline cannot be reconstructed. The retry intentionally covers all
|
||||
* local application failures, including browser DataCloneError variants.
|
||||
*/
|
||||
export const resolveWithReadModelSnapshotFallback = async <Response, Result>(options: {
|
||||
request: (forceSnapshot: boolean) => Promise<Response>;
|
||||
resolve: (response: Response) => Result;
|
||||
forceSnapshot?: boolean;
|
||||
}): Promise<Result> => {
|
||||
const forceSnapshot = options.forceSnapshot === true;
|
||||
const response = await options.request(forceSnapshot);
|
||||
try {
|
||||
return options.resolve(response);
|
||||
} catch (error) {
|
||||
if (forceSnapshot) {
|
||||
throw error;
|
||||
}
|
||||
return options.resolve(await options.request(true));
|
||||
}
|
||||
};
|
||||
@@ -2,10 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createEmptyRealtimeReadModelChanges } from '@sammo-ts/common';
|
||||
import {
|
||||
createMergedReadModelRefreshQueue,
|
||||
resolveDashboardRefreshPlan,
|
||||
} from '../src/utils/dashboardReadModel.ts';
|
||||
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../src/utils/dashboardReadModel.ts';
|
||||
|
||||
void test('last-turn-time-only events do not schedule any dashboard query', () => {
|
||||
const plan = resolveDashboardRefreshPlan(createEmptyRealtimeReadModelChanges(), {
|
||||
@@ -55,10 +52,63 @@ void test('refreshes the map only for map-projection changes', () => {
|
||||
mapGeneralIds: [7],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map,
|
||||
true
|
||||
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, true);
|
||||
});
|
||||
|
||||
void test('routes defence, tax-rate, and current-city-state events to their exact dashboard slices', () => {
|
||||
const identity = { generalId: 7, cityId: 3, nationId: 2 };
|
||||
const defence = resolveDashboardRefreshPlan(
|
||||
{
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
cityIds: [3],
|
||||
mapCityIds: [],
|
||||
},
|
||||
identity
|
||||
);
|
||||
assert.deepEqual(defence, {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: false,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
});
|
||||
|
||||
const taxRate = resolveDashboardRefreshPlan(
|
||||
{
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
nationIds: [2],
|
||||
mapNationIds: [],
|
||||
frontStatusNationIds: [],
|
||||
},
|
||||
identity
|
||||
);
|
||||
assert.deepEqual(taxRate, {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
});
|
||||
|
||||
const cityState = resolveDashboardRefreshPlan(
|
||||
{
|
||||
...createEmptyRealtimeReadModelChanges(),
|
||||
cityIds: [3],
|
||||
mapCityIds: [3],
|
||||
},
|
||||
identity
|
||||
);
|
||||
assert.equal(cityState.context, true);
|
||||
assert.equal(cityState.commands, true);
|
||||
assert.equal(cityState.map, true);
|
||||
});
|
||||
|
||||
void test('keeps conservative map behavior for rolling-deploy payloads without projections', () => {
|
||||
@@ -74,10 +124,7 @@ void test('keeps conservative map behavior for rolling-deploy payloads without p
|
||||
contactsChanged: false,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map,
|
||||
true
|
||||
);
|
||||
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, true);
|
||||
});
|
||||
|
||||
void test('does not refresh front status for contact-only permission changes', () => {
|
||||
@@ -119,14 +166,8 @@ void test('targets a submitted survey projection to its own general', () => {
|
||||
frontStatusActorIds: [7],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).frontStatus,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus,
|
||||
false
|
||||
);
|
||||
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).frontStatus, true);
|
||||
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 () => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveWithReadModelSnapshotFallback } from '../src/utils/readModelDeltaRecovery.ts';
|
||||
|
||||
void test('retries any local delta application failure once with a forced snapshot', async () => {
|
||||
const requests: boolean[] = [];
|
||||
const result = await resolveWithReadModelSnapshotFallback({
|
||||
request: async (forceSnapshot) => {
|
||||
requests.push(forceSnapshot);
|
||||
return forceSnapshot ? { kind: 'snapshot', value: 25 } : { kind: 'patch', value: 20 };
|
||||
},
|
||||
resolve: (response) => {
|
||||
if (response.kind === 'patch') {
|
||||
throw new DOMException('[object Object] could not be cloned.', 'DataCloneError');
|
||||
}
|
||||
return response.value;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, 25);
|
||||
assert.deepEqual(requests, [false, true]);
|
||||
});
|
||||
|
||||
void test('does not retry a forced snapshot application failure', async () => {
|
||||
const requests: boolean[] = [];
|
||||
|
||||
await assert.rejects(
|
||||
resolveWithReadModelSnapshotFallback({
|
||||
forceSnapshot: true,
|
||||
request: async (forceSnapshot) => {
|
||||
requests.push(forceSnapshot);
|
||||
return { kind: 'snapshot' };
|
||||
},
|
||||
resolve: () => {
|
||||
throw new Error('snapshot failure');
|
||||
},
|
||||
}),
|
||||
/snapshot failure/u
|
||||
);
|
||||
assert.deepEqual(requests, [true]);
|
||||
});
|
||||
@@ -31,12 +31,37 @@ export class ReadModelDeltaMismatchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ReadModelDeltaApplyError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = 'ReadModelDeltaApplyError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppliedReadModelDelta<T> {
|
||||
data: T;
|
||||
revision: string;
|
||||
}
|
||||
|
||||
const cloneJsonValue = <T>(value: T): T => structuredClone(value);
|
||||
/**
|
||||
* Read-model deltas operate on JSON documents received over tRPC. Serializing
|
||||
* through JSON also unwraps Vue's nested reactive proxies, which a root-level
|
||||
* `toRaw()` does not remove and `structuredClone()` cannot clone.
|
||||
*/
|
||||
export const cloneReadModelJson = <T>(value: T): T => {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) {
|
||||
throw new TypeError('The read-model value is not a JSON document.');
|
||||
}
|
||||
return JSON.parse(serialized) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof ReadModelDeltaApplyError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ReadModelDeltaApplyError('Failed to clone the read-model JSON document.', { cause: error });
|
||||
}
|
||||
};
|
||||
|
||||
export const createJsonPatch = (current: unknown, next: unknown): JsonPatchOperation[] => createPatch(current, next);
|
||||
|
||||
@@ -74,11 +99,18 @@ export const applyReadModelDelta = <T>(
|
||||
);
|
||||
}
|
||||
|
||||
const next = cloneJsonValue(current);
|
||||
const errors = applyPatch(next, delta.operations as Operation[]);
|
||||
const failure = errors.find((error) => error !== null);
|
||||
if (failure) {
|
||||
throw new ReadModelDeltaMismatchError(`JSON Patch application failed: ${failure.message}`);
|
||||
const next = cloneReadModelJson(current);
|
||||
try {
|
||||
const errors = applyPatch(next, delta.operations as Operation[]);
|
||||
const failure = errors.find((error) => error !== null);
|
||||
if (failure) {
|
||||
throw new ReadModelDeltaApplyError(`JSON Patch application failed: ${failure.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ReadModelDeltaApplyError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ReadModelDeltaApplyError('JSON Patch application failed.', { cause: error });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyReadModelDelta, ReadModelDeltaMismatchError } from '../src/realtime/delta.js';
|
||||
import { applyReadModelDelta, ReadModelDeltaApplyError, ReadModelDeltaMismatchError } from '../src/realtime/delta.js';
|
||||
|
||||
describe('applyReadModelDelta', () => {
|
||||
it('applies a JSON Patch without mutating the previous snapshot', () => {
|
||||
@@ -46,4 +46,44 @@ describe('applyReadModelDelta', () => {
|
||||
})
|
||||
).toThrow(ReadModelDeltaMismatchError);
|
||||
});
|
||||
|
||||
it('unwraps nested reactive-style proxies before applying a later patch', () => {
|
||||
const proxiedStableBranch = new Proxy(
|
||||
{
|
||||
values: [{ key: '이동', possible: true, status: 'available' }],
|
||||
},
|
||||
{}
|
||||
);
|
||||
const current = {
|
||||
general: [
|
||||
{ category: '일반', values: [{ key: '휴식', possible: false, status: 'blocked' }] },
|
||||
proxiedStableBranch,
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => structuredClone(current)).toThrow();
|
||||
const applied = applyReadModelDelta(current, 'revision-1', {
|
||||
kind: 'patch',
|
||||
baseRevision: 'revision-1',
|
||||
revision: 'revision-2',
|
||||
operations: [{ op: 'replace', path: '/general/1/values/0/possible', value: false }],
|
||||
});
|
||||
|
||||
expect(applied.data.general[1]?.values[0]?.possible).toBe(false);
|
||||
expect(applied.data.general[1]).not.toBe(proxiedStableBranch);
|
||||
});
|
||||
|
||||
it('reports a non-JSON baseline as a recoverable delta application error', () => {
|
||||
const current: { value: number; self?: unknown } = { value: 1 };
|
||||
current.self = current;
|
||||
|
||||
expect(() =>
|
||||
applyReadModelDelta(current, 'revision-1', {
|
||||
kind: 'patch',
|
||||
baseRevision: 'revision-1',
|
||||
revision: 'revision-2',
|
||||
operations: [{ op: 'replace', path: '/value', value: 2 }],
|
||||
})
|
||||
).toThrow(ReadModelDeltaApplyError);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user