Merge branch 'main' into feature/profile-operation-progress-20260811
This commit is contained in:
@@ -89,12 +89,8 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
|||||||
penalty: {},
|
penalty: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
listCities: () => [
|
listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1, population: 100 }],
|
||||||
{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1, population: 100 },
|
listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, gold: 100 }],
|
||||||
],
|
|
||||||
listNations: () => [
|
|
||||||
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, gold: 100 },
|
|
||||||
],
|
|
||||||
} as unknown as InMemoryTurnWorld);
|
} as unknown as InMemoryTurnWorld);
|
||||||
const changes = {
|
const changes = {
|
||||||
generals: [
|
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', () => {
|
it('detects map, contact, and front-status fields independently', () => {
|
||||||
const baseline = createRealtimeReadModelBaseline({
|
const baseline = createRealtimeReadModelBaseline({
|
||||||
listGenerals: () => [
|
listGenerals: () => [
|
||||||
@@ -168,9 +265,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1 }],
|
listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1 }],
|
||||||
listNations: () => [
|
listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } }],
|
||||||
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } },
|
|
||||||
],
|
|
||||||
} as unknown as InMemoryTurnWorld);
|
} as unknown as InMemoryTurnWorld);
|
||||||
const changes = {
|
const changes = {
|
||||||
generals: [
|
generals: [
|
||||||
@@ -188,9 +283,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
|||||||
createdGenerals: [],
|
createdGenerals: [],
|
||||||
deletedGenerals: [],
|
deletedGenerals: [],
|
||||||
cities: [{ id: 1, level: 2, nationId: 1, state: 0, supplyState: 1 }],
|
cities: [{ id: 1, level: 2, nationId: 1, state: 0, supplyState: 1 }],
|
||||||
nations: [
|
nations: [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '새 방침' } }],
|
||||||
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '새 방침' } },
|
|
||||||
],
|
|
||||||
createdNations: [],
|
createdNations: [],
|
||||||
deletedNations: [],
|
deletedNations: [],
|
||||||
deletedNationSnapshots: [],
|
deletedNationSnapshots: [],
|
||||||
@@ -229,9 +322,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
listCities: () => [],
|
listCities: () => [],
|
||||||
listNations: () => [
|
listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } }],
|
||||||
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } },
|
|
||||||
],
|
|
||||||
} as unknown as InMemoryTurnWorld);
|
} as unknown as InMemoryTurnWorld);
|
||||||
const changes = {
|
const changes = {
|
||||||
generals: [
|
generals: [
|
||||||
@@ -249,9 +340,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
|
|||||||
createdGenerals: [],
|
createdGenerals: [],
|
||||||
deletedGenerals: [],
|
deletedGenerals: [],
|
||||||
cities: [],
|
cities: [],
|
||||||
nations: [
|
nations: [{ id: 1, name: '촉', color: '#008000', capitalCityId: 1, meta: { notice: '새 공지' } }],
|
||||||
{ id: 1, name: '촉', color: '#008000', capitalCityId: 1, meta: { notice: '새 공지' } },
|
|
||||||
],
|
|
||||||
createdNations: [],
|
createdNations: [],
|
||||||
deletedNations: [],
|
deletedNations: [],
|
||||||
deletedNationSnapshots: [],
|
deletedNationSnapshots: [],
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ type NavigationFixture = {
|
|||||||
generalMeCalls: number;
|
generalMeCalls: number;
|
||||||
operations: string[];
|
operations: string[];
|
||||||
generalName?: string;
|
generalName?: string;
|
||||||
|
cityDefence?: number;
|
||||||
|
cityState?: number;
|
||||||
|
nationRate?: number;
|
||||||
|
contextRevision?: string;
|
||||||
|
contextOperations?: JsonPatchOperation[];
|
||||||
|
commandTableRevision?: string;
|
||||||
|
commandTableOperations?: JsonPatchOperation[];
|
||||||
|
commandBlockedCount?: number;
|
||||||
|
forceSnapshotCalls?: number;
|
||||||
refreshDelayMs?: number;
|
refreshDelayMs?: number;
|
||||||
largeCommandTable?: boolean;
|
largeCommandTable?: boolean;
|
||||||
dashboardResponses?: Array<{
|
dashboardResponses?: Array<{
|
||||||
@@ -30,6 +39,12 @@ type NavigationFixture = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type JsonPatchOperation = {
|
||||||
|
op: 'add' | 'remove' | 'replace';
|
||||||
|
path: string;
|
||||||
|
value?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
type DashboardBundleInput = {
|
type DashboardBundleInput = {
|
||||||
include?: { context?: boolean; commandTable?: boolean; boardAccess?: boolean };
|
include?: { context?: boolean; commandTable?: boolean; boardAccess?: boolean };
|
||||||
known?: { context?: string; commandTable?: string; boardAccess?: string };
|
known?: { context?: string; commandTable?: string; boardAccess?: string };
|
||||||
@@ -44,7 +59,62 @@ const operationInput = (route: Route, index: number): DashboardBundleInput => {
|
|||||||
return entry.json ?? (entry as 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
|
general: large
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -53,8 +123,8 @@ const commandTableFixture = (large: boolean) => ({
|
|||||||
key: `command-${index}`,
|
key: `command-${index}`,
|
||||||
name: `명령 ${index}`,
|
name: `명령 ${index}`,
|
||||||
reqArg: index % 2 === 0,
|
reqArg: index % 2 === 0,
|
||||||
possible: true,
|
possible: index >= blockedCount,
|
||||||
status: 'available',
|
status: index >= blockedCount ? 'available' : 'blocked',
|
||||||
inputFields: [
|
inputFields: [
|
||||||
{
|
{
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
@@ -87,6 +157,7 @@ const COMMAND_TABLE_REVISION = 'CCCCCCCCCCCCCCCCCCCCCC';
|
|||||||
const BOARD_ACCESS_REVISION = 'DDDDDDDDDDDDDDDDDDDDDD';
|
const BOARD_ACCESS_REVISION = 'DDDDDDDDDDDDDDDDDDDDDD';
|
||||||
|
|
||||||
const contextRevision = (state: NavigationFixture) => {
|
const contextRevision = (state: NavigationFixture) => {
|
||||||
|
if (state.contextRevision) return state.contextRevision;
|
||||||
const name = state.generalName ?? '메뉴검증장수';
|
const name = state.generalName ?? '메뉴검증장수';
|
||||||
if (name === '메뉴검증장수') return CONTEXT_INITIAL_REVISION;
|
if (name === '메뉴검증장수') return CONTEXT_INITIAL_REVISION;
|
||||||
if (name === '부드럽게갱신된장수') return 'EEEEEEEEEEEEEEEEEEEEEE';
|
if (name === '부드럽게갱신된장수') return 'EEEEEEEEEEEEEEEEEEEEEE';
|
||||||
@@ -95,14 +166,20 @@ const contextRevision = (state: NavigationFixture) => {
|
|||||||
return 'HHHHHHHHHHHHHHHHHHHHHH';
|
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 (forceSnapshot || !known) return { kind: 'snapshot' as const, revision, data: value };
|
||||||
if (known === revision) return { kind: 'unchanged' as const, revision };
|
if (known === revision) return { kind: 'unchanged' as const, revision };
|
||||||
return {
|
return {
|
||||||
kind: 'patch' as const,
|
kind: 'patch' as const,
|
||||||
baseRevision: known,
|
baseRevision: known,
|
||||||
revision,
|
revision,
|
||||||
operations: [
|
operations: operations ?? [
|
||||||
{
|
{
|
||||||
op: 'replace' as const,
|
op: 'replace' as const,
|
||||||
path: '/general/name',
|
path: '/general/name',
|
||||||
@@ -169,12 +246,13 @@ const generalContext = (state: NavigationFixture) => ({
|
|||||||
securityMax: 2_000,
|
securityMax: 2_000,
|
||||||
trust: 80,
|
trust: 80,
|
||||||
trade: 100,
|
trade: 100,
|
||||||
defence: 1_000,
|
defence: state.cityDefence ?? 1_000,
|
||||||
defenceMax: 2_000,
|
defenceMax: 2_000,
|
||||||
wall: 1_000,
|
wall: 1_000,
|
||||||
wallMax: 2_000,
|
wallMax: 2_000,
|
||||||
supplyState: 1,
|
supplyState: 1,
|
||||||
frontState: 0,
|
frontState: 0,
|
||||||
|
state: state.cityState ?? 0,
|
||||||
},
|
},
|
||||||
nation: {
|
nation: {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -184,7 +262,7 @@ const generalContext = (state: NavigationFixture) => ({
|
|||||||
gold: 10_000,
|
gold: 10_000,
|
||||||
rice: 20_000,
|
rice: 20_000,
|
||||||
tech: 100,
|
tech: 100,
|
||||||
rate: 20,
|
rate: state.nationRate ?? 20,
|
||||||
bill: 100,
|
bill: 100,
|
||||||
capitalCityId: 1,
|
capitalCityId: 1,
|
||||||
typeCode: 'che_유가',
|
typeCode: 'che_유가',
|
||||||
@@ -241,18 +319,33 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
const input = operationInput(route, index);
|
const input = operationInput(route, index);
|
||||||
const include = input.include ?? {};
|
const include = input.include ?? {};
|
||||||
const forceSnapshot = input.forceSnapshot === true;
|
const forceSnapshot = input.forceSnapshot === true;
|
||||||
|
if (forceSnapshot) state.forceSnapshotCalls = (state.forceSnapshotCalls ?? 0) + 1;
|
||||||
const revision = contextRevision(state);
|
const revision = contextRevision(state);
|
||||||
const context = include.context
|
const context = include.context
|
||||||
? deltaSlice(generalContext(state), revision, input.known?.context, forceSnapshot)
|
? deltaSlice(
|
||||||
|
generalContext(state),
|
||||||
|
revision,
|
||||||
|
input.known?.context,
|
||||||
|
forceSnapshot,
|
||||||
|
state.contextOperations
|
||||||
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const currentCommandTableRevision = state.commandTableRevision ?? COMMAND_TABLE_REVISION;
|
||||||
const commandTable = include.commandTable
|
const commandTable = include.commandTable
|
||||||
? forceSnapshot || !input.known?.commandTable
|
? forceSnapshot || !input.known?.commandTable
|
||||||
? {
|
? {
|
||||||
kind: 'snapshot' as const,
|
kind: 'snapshot' as const,
|
||||||
revision: COMMAND_TABLE_REVISION,
|
revision: currentCommandTableRevision,
|
||||||
data: commandTableFixture(state.largeCommandTable === true),
|
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;
|
: undefined;
|
||||||
const boardAccess = include.boardAccess
|
const boardAccess = include.boardAccess
|
||||||
? forceSnapshot || !input.known?.boardAccess
|
? forceSnapshot || !input.known?.boardAccess
|
||||||
@@ -297,7 +390,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
startYear: 180,
|
startYear: 180,
|
||||||
year: 185,
|
year: 185,
|
||||||
month: 1,
|
month: 1,
|
||||||
cityList: [[1, 8, 0, 1, 1, 1]],
|
cityList: [[1, 8, state.cityState ?? 0, 1, 1, 1]],
|
||||||
nationList: [[1, '위', '#008000', 1]],
|
nationList: [[1, '위', '#008000', 1]],
|
||||||
spyList: {},
|
spyList: {},
|
||||||
shownByGeneralList: [],
|
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.locator(`a[href="${basePath}/board"]`).first().click();
|
||||||
await page.waitForURL(`**${basePath}/board`);
|
await page.waitForURL(`**${basePath}/board`);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { defineStore } from 'pinia';
|
|||||||
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
|
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
applyReadModelDelta,
|
applyReadModelDelta,
|
||||||
ReadModelDeltaMismatchError,
|
cloneReadModelJson,
|
||||||
type RealtimeEvent,
|
type RealtimeEvent,
|
||||||
type RealtimeReadModelChanges,
|
type RealtimeReadModelChanges,
|
||||||
} from '@sammo-ts/common';
|
} from '@sammo-ts/common';
|
||||||
@@ -15,6 +15,7 @@ import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'
|
|||||||
import { structurallyShare } from '../utils/structuralShare';
|
import { structurallyShare } from '../utils/structuralShare';
|
||||||
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel';
|
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel';
|
||||||
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
|
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
|
||||||
|
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
|
||||||
|
|
||||||
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
|
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
@@ -103,6 +104,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
let recordGeneralId: number | null = null;
|
let recordGeneralId: number | null = null;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let contextSnapshot: GeneralContext | undefined;
|
let contextSnapshot: GeneralContext | undefined;
|
||||||
|
let commandTableSnapshot: CommandTable | undefined;
|
||||||
|
let boardAccessSnapshot: BoardAccess | undefined;
|
||||||
let contextRevision: string | null = null;
|
let contextRevision: string | null = null;
|
||||||
let commandTableRevision: string | null = null;
|
let commandTableRevision: string | null = null;
|
||||||
let boardAccessRevision: string | null = null;
|
let boardAccessRevision: string | null = null;
|
||||||
@@ -319,6 +322,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const applyDashboardPatch = (patch: DashboardReadModelPatch) => {
|
const applyDashboardPatch = (patch: DashboardReadModelPatch) => {
|
||||||
if (patch.contextSnapshot === null) {
|
if (patch.contextSnapshot === null) {
|
||||||
contextSnapshot = null;
|
contextSnapshot = null;
|
||||||
|
commandTableSnapshot = undefined;
|
||||||
|
boardAccessSnapshot = undefined;
|
||||||
general.value = null;
|
general.value = null;
|
||||||
city.value = null;
|
city.value = null;
|
||||||
nation.value = null;
|
nation.value = null;
|
||||||
@@ -337,6 +342,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
if (patch.general === null) {
|
if (patch.general === null) {
|
||||||
contextSnapshot = null;
|
contextSnapshot = null;
|
||||||
|
commandTableSnapshot = undefined;
|
||||||
|
boardAccessSnapshot = undefined;
|
||||||
general.value = null;
|
general.value = null;
|
||||||
city.value = null;
|
city.value = null;
|
||||||
nation.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.worldMap !== undefined) worldMap.value = structurallyShare(worldMap.value, patch.worldMap);
|
||||||
if (patch.mapLayout !== undefined) mapLayout.value = structurallyShare(mapLayout.value, patch.mapLayout);
|
if (patch.mapLayout !== undefined) mapLayout.value = structurallyShare(mapLayout.value, patch.mapLayout);
|
||||||
if (patch.commandTable !== undefined) {
|
if (patch.commandTable !== undefined) {
|
||||||
|
commandTableSnapshot = patch.commandTable ?? undefined;
|
||||||
commandTable.value = structurallyShare(commandTable.value, patch.commandTable);
|
commandTable.value = structurallyShare(commandTable.value, patch.commandTable);
|
||||||
}
|
}
|
||||||
if (patch.messages !== undefined) messages.value = structurallyShare(messages.value, patch.messages);
|
if (patch.messages !== undefined) messages.value = structurallyShare(messages.value, patch.messages);
|
||||||
if (patch.messageContacts !== undefined) {
|
if (patch.messageContacts !== undefined) {
|
||||||
messageContacts.value = structurallyShare(messageContacts.value, patch.messageContacts);
|
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);
|
boardAccess.value = structurallyShare(boardAccess.value, patch.boardAccess);
|
||||||
|
}
|
||||||
if (patch.reservedGeneralTurns !== undefined) {
|
if (patch.reservedGeneralTurns !== undefined) {
|
||||||
reservedGeneralTurns.value = structurallyShare<unknown>(
|
reservedGeneralTurns.value = structurallyShare<unknown>(
|
||||||
reservedGeneralTurns.value,
|
reservedGeneralTurns.value,
|
||||||
@@ -409,10 +419,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
patch.lobbyInfo = toRaw(lobbyInfo.value);
|
patch.lobbyInfo = toRaw(lobbyInfo.value);
|
||||||
patch.worldMap = toRaw(worldMap.value);
|
patch.worldMap = toRaw(worldMap.value);
|
||||||
patch.mapLayout = toRaw(mapLayout.value);
|
patch.mapLayout = toRaw(mapLayout.value);
|
||||||
patch.commandTable = toRaw(commandTable.value);
|
patch.commandTable = commandTableSnapshot ?? null;
|
||||||
patch.messages = toRaw(messages.value);
|
patch.messages = toRaw(messages.value);
|
||||||
patch.messageContacts = toRaw(messageContacts.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.reservedGeneralTurns = toRaw(reservedGeneralTurns.value as unknown) as ReservedTurnView[] | null;
|
||||||
patch.reservedGeneralRevision = reservedGeneralRevision.value;
|
patch.reservedGeneralRevision = reservedGeneralRevision.value;
|
||||||
patch.globalRecords = toRaw(globalRecords.value);
|
patch.globalRecords = toRaw(globalRecords.value);
|
||||||
@@ -433,16 +443,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bundle.commandTable) {
|
if (bundle.commandTable) {
|
||||||
const current = commandTable.value === null ? undefined : toRaw(commandTable.value);
|
const applied = applyReadModelDelta(commandTableSnapshot, commandTableRevision, bundle.commandTable);
|
||||||
const applied = applyReadModelDelta(current, commandTableRevision, bundle.commandTable);
|
|
||||||
patch.commandTableRevision = applied.revision;
|
patch.commandTableRevision = applied.revision;
|
||||||
if (bundle.commandTable.kind !== 'unchanged') {
|
if (bundle.commandTable.kind !== 'unchanged') {
|
||||||
patch.commandTable = applied.data;
|
patch.commandTable = applied.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bundle.boardAccess) {
|
if (bundle.boardAccess) {
|
||||||
const current = boardAccess.value === null ? undefined : toRaw(boardAccess.value);
|
const applied = applyReadModelDelta(boardAccessSnapshot, boardAccessRevision, bundle.boardAccess);
|
||||||
const applied = applyReadModelDelta(current, boardAccessRevision, bundle.boardAccess);
|
|
||||||
patch.boardAccessRevision = applied.revision;
|
patch.boardAccessRevision = applied.revision;
|
||||||
if (bundle.boardAccess.kind !== 'unchanged') {
|
if (bundle.boardAccess.kind !== 'unchanged') {
|
||||||
patch.boardAccess = applied.data;
|
patch.boardAccess = applied.data;
|
||||||
@@ -469,15 +477,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
forceSnapshot: force || undefined,
|
forceSnapshot: force || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bundle = await request(forceSnapshot);
|
return resolveWithReadModelSnapshotFallback({
|
||||||
try {
|
request,
|
||||||
return resolveContextBundlePatch(bundle);
|
resolve: resolveContextBundlePatch,
|
||||||
} catch (error) {
|
forceSnapshot,
|
||||||
if (forceSnapshot || !(error instanceof ReadModelDeltaMismatchError)) {
|
});
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return resolveContextBundlePatch(await request(true));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshMainData = async () => {
|
const refreshMainData = async () => {
|
||||||
@@ -576,7 +580,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
let realtimeCoordinatorScope: string | null = null;
|
let realtimeCoordinatorScope: string | null = null;
|
||||||
|
|
||||||
const publishDashboardPatch = (patch: DashboardReadModelPatch) => {
|
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(
|
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 test from 'node:test';
|
||||||
|
|
||||||
import { createEmptyRealtimeReadModelChanges } from '@sammo-ts/common';
|
import { createEmptyRealtimeReadModelChanges } from '@sammo-ts/common';
|
||||||
import {
|
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../src/utils/dashboardReadModel.ts';
|
||||||
createMergedReadModelRefreshQueue,
|
|
||||||
resolveDashboardRefreshPlan,
|
|
||||||
} from '../src/utils/dashboardReadModel.ts';
|
|
||||||
|
|
||||||
void test('last-turn-time-only events do not schedule any dashboard query', () => {
|
void test('last-turn-time-only events do not schedule any dashboard query', () => {
|
||||||
const plan = resolveDashboardRefreshPlan(createEmptyRealtimeReadModelChanges(), {
|
const plan = resolveDashboardRefreshPlan(createEmptyRealtimeReadModelChanges(), {
|
||||||
@@ -55,10 +52,63 @@ void test('refreshes the map only for map-projection changes', () => {
|
|||||||
mapGeneralIds: [7],
|
mapGeneralIds: [7],
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, true);
|
||||||
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', () => {
|
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,
|
contactsChanged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, true);
|
||||||
resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('does not refresh front status for contact-only permission changes', () => {
|
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],
|
frontStatusActorIds: [7],
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).frontStatus, true);
|
||||||
resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).frontStatus,
|
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false);
|
||||||
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 () => {
|
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> {
|
export interface AppliedReadModelDelta<T> {
|
||||||
data: T;
|
data: T;
|
||||||
revision: string;
|
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);
|
export const createJsonPatch = (current: unknown, next: unknown): JsonPatchOperation[] => createPatch(current, next);
|
||||||
|
|
||||||
@@ -74,11 +99,18 @@ export const applyReadModelDelta = <T>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = cloneJsonValue(current);
|
const next = cloneReadModelJson(current);
|
||||||
const errors = applyPatch(next, delta.operations as Operation[]);
|
try {
|
||||||
const failure = errors.find((error) => error !== null);
|
const errors = applyPatch(next, delta.operations as Operation[]);
|
||||||
if (failure) {
|
const failure = errors.find((error) => error !== null);
|
||||||
throw new ReadModelDeltaMismatchError(`JSON Patch application failed: ${failure.message}`);
|
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 {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('applyReadModelDelta', () => {
|
||||||
it('applies a JSON Patch without mutating the previous snapshot', () => {
|
it('applies a JSON Patch without mutating the previous snapshot', () => {
|
||||||
@@ -46,4 +46,44 @@ describe('applyReadModelDelta', () => {
|
|||||||
})
|
})
|
||||||
).toThrow(ReadModelDeltaMismatchError);
|
).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