merge: recover realtime dashboard delta cloning

This commit is contained in:
2026-08-11 15:12:25 +00:00
8 changed files with 518 additions and 74 deletions
@@ -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: [],
+177 -12
View File
@@ -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(
+32 -18
View File
@@ -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]);
});
+38 -6
View File
@@ -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 {
+41 -1
View File
@@ -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);
});
});