perf: dashboard revision-first 조회 계약을 추가
content revision과 별도인 source revision을 선택적으로 교환하고 coverage가 충족될 때만 payload loader를 생략한다. 구형 client와 미완성 producer coverage는 기존 full computation으로 안전하게 복구한다.
This commit is contained in:
@@ -58,6 +58,15 @@ const buildContext = (authenticated: boolean, generalAccessTracking = false) =>
|
||||
meta: {},
|
||||
penalty: {},
|
||||
}));
|
||||
const findCity = vi.fn(async () => null);
|
||||
const findNation = vi.fn(async () => null);
|
||||
const findWorld = vi.fn(async () => ({
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: { const: {} },
|
||||
meta: { lastTurnTime: '2026-08-11T00:00:00.000Z' },
|
||||
}));
|
||||
const context = {
|
||||
auth: authenticated ? auth : null,
|
||||
profile: { id: 'hwe', scenario: 'default', name: 'hwe:default' },
|
||||
@@ -73,18 +82,10 @@ const buildContext = (authenticated: boolean, generalAccessTracking = false) =>
|
||||
general: {
|
||||
findFirst: findGeneral,
|
||||
},
|
||||
city: { findUnique: async () => null },
|
||||
nation: { findUnique: async () => null },
|
||||
city: { findUnique: findCity },
|
||||
nation: { findUnique: findNation },
|
||||
generalAccessLog: { findUnique: async () => null },
|
||||
worldState: {
|
||||
findFirst: async () => ({
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: { const: {} },
|
||||
meta: { lastTurnTime: '2026-08-11T00:00:00.000Z' },
|
||||
}),
|
||||
},
|
||||
worldState: { findFirst: findWorld },
|
||||
},
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
@@ -94,6 +95,43 @@ const buildContext = (authenticated: boolean, generalAccessTracking = false) =>
|
||||
generalName = name;
|
||||
},
|
||||
findGeneral,
|
||||
findCity,
|
||||
findNation,
|
||||
findWorld,
|
||||
};
|
||||
};
|
||||
|
||||
const installSourceRevisionState = (
|
||||
context: GameApiContext,
|
||||
initial: Partial<{
|
||||
coverageVersion: number;
|
||||
generalRevision: bigint;
|
||||
cityRevision: bigint;
|
||||
nationRevision: bigint;
|
||||
worldRevision: bigint;
|
||||
accessRevision: bigint;
|
||||
}> = {}
|
||||
) => {
|
||||
let row = {
|
||||
generalId: 7,
|
||||
cityId: 0,
|
||||
nationId: 0,
|
||||
coverageVersion: 1,
|
||||
generalRevision: 1n,
|
||||
cityRevision: 0n,
|
||||
nationRevision: 0n,
|
||||
worldRevision: 1n,
|
||||
accessRevision: 1n,
|
||||
...initial,
|
||||
};
|
||||
const queryRaw = vi.fn(async () => [row]);
|
||||
Object.assign(context.db, { $queryRaw: queryRaw });
|
||||
context.realtimeAccessGeneralId = 7;
|
||||
return {
|
||||
queryRaw,
|
||||
update: (next: Partial<typeof row>) => {
|
||||
row = { ...row, ...next };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -141,6 +179,8 @@ describe('dashboardRouter.getContextBundleDelta', () => {
|
||||
|
||||
it('uses an all-false bundle as an access-only gate without projecting dashboard context', async () => {
|
||||
const fixture = buildContext(true, true);
|
||||
const queryRaw = vi.fn(async (_query: unknown) => []);
|
||||
Object.assign(fixture.context.db, { $queryRaw: queryRaw });
|
||||
await expect(
|
||||
dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
|
||||
include: { context: false, commandTable: false, boardAccess: false },
|
||||
@@ -152,5 +192,113 @@ describe('dashboardRouter.getContextBundleDelta', () => {
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, turnTime: true },
|
||||
});
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns revision-first unchanged without running the projection loader', async () => {
|
||||
const fixture = buildContext(true);
|
||||
const source = installSourceRevisionState(fixture.context);
|
||||
const caller = dashboardRouter.createCaller(fixture.context);
|
||||
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
|
||||
if (!initial.context || initial.context.kind !== 'snapshot' || !initial.context.sourceRevision) {
|
||||
throw new Error('initial source revision missing');
|
||||
}
|
||||
|
||||
fixture.findGeneral.mockClear();
|
||||
fixture.findCity.mockClear();
|
||||
fixture.findNation.mockClear();
|
||||
fixture.findWorld.mockClear();
|
||||
const unchanged = await caller.getContextBundleDelta({
|
||||
...contextOnly,
|
||||
known: { context: initial.context.revision },
|
||||
knownSource: { context: initial.context.sourceRevision },
|
||||
});
|
||||
|
||||
expect(unchanged.context).toEqual({
|
||||
kind: 'unchanged',
|
||||
revision: initial.context.revision,
|
||||
sourceRevision: initial.context.sourceRevision,
|
||||
});
|
||||
expect(source.queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.findGeneral).not.toHaveBeenCalled();
|
||||
expect(fixture.findCity).not.toHaveBeenCalled();
|
||||
expect(fixture.findNation).not.toHaveBeenCalled();
|
||||
expect(fixture.findWorld).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to full computation while coverage is zero', async () => {
|
||||
const fixture = buildContext(true);
|
||||
installSourceRevisionState(fixture.context, { coverageVersion: 0 });
|
||||
const caller = dashboardRouter.createCaller(fixture.context);
|
||||
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
|
||||
if (!initial.context || initial.context.kind !== 'snapshot' || !initial.context.sourceRevision) {
|
||||
throw new Error('initial source revision missing');
|
||||
}
|
||||
|
||||
fixture.findGeneral.mockClear();
|
||||
const unchanged = await caller.getContextBundleDelta({
|
||||
...contextOnly,
|
||||
known: { context: initial.context.revision },
|
||||
knownSource: { context: initial.context.sourceRevision },
|
||||
});
|
||||
|
||||
expect(unchanged.context).toMatchObject({ kind: 'unchanged', revision: initial.context.revision });
|
||||
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('advances source revision when source changes but canonical content does not', async () => {
|
||||
const fixture = buildContext(true);
|
||||
const source = installSourceRevisionState(fixture.context);
|
||||
const caller = dashboardRouter.createCaller(fixture.context);
|
||||
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
|
||||
if (!initial.context || initial.context.kind !== 'snapshot' || !initial.context.sourceRevision) {
|
||||
throw new Error('initial source revision missing');
|
||||
}
|
||||
|
||||
source.update({ generalRevision: 2n });
|
||||
fixture.findGeneral.mockClear();
|
||||
const unchanged = await caller.getContextBundleDelta({
|
||||
...contextOnly,
|
||||
known: { context: initial.context.revision },
|
||||
knownSource: { context: initial.context.sourceRevision },
|
||||
});
|
||||
|
||||
expect(unchanged.context).toMatchObject({ kind: 'unchanged', revision: initial.context.revision });
|
||||
expect(unchanged.context?.sourceRevision).not.toBe(initial.context.sourceRevision);
|
||||
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps old content-only clients on the existing full-computation path', async () => {
|
||||
const fixture = buildContext(true);
|
||||
installSourceRevisionState(fixture.context);
|
||||
const caller = dashboardRouter.createCaller(fixture.context);
|
||||
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
|
||||
if (!initial.context || initial.context.kind !== 'snapshot') throw new Error('initial snapshot missing');
|
||||
|
||||
fixture.findGeneral.mockClear();
|
||||
const unchanged = await caller.getContextBundleDelta({
|
||||
...contextOnly,
|
||||
known: { context: initial.context.revision },
|
||||
});
|
||||
|
||||
expect(unchanged.context).toMatchObject({ kind: 'unchanged', revision: initial.context.revision });
|
||||
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to full computation when the revision-head query fails', async () => {
|
||||
const fixture = buildContext(true);
|
||||
Object.assign(fixture.context.db, {
|
||||
$queryRaw: vi.fn(async () => Promise.reject(new Error('revision table unavailable'))),
|
||||
});
|
||||
fixture.context.realtimeAccessGeneralId = 7;
|
||||
|
||||
const result = await dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
|
||||
...contextOnly,
|
||||
known: { context: 'A'.repeat(22) },
|
||||
knownSource: { context: 'B'.repeat(22) },
|
||||
});
|
||||
|
||||
expect(result.context?.kind).toBe('snapshot');
|
||||
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
import { readDashboardSourceRevisionState } from '../src/services/dashboardSourceRevision.js';
|
||||
|
||||
const row = (overrides: Record<string, unknown> = {}) => ({
|
||||
generalId: 7,
|
||||
cityId: 3,
|
||||
nationId: 2,
|
||||
coverageVersion: 1,
|
||||
generalRevision: 11n,
|
||||
cityRevision: 12n,
|
||||
nationRevision: 13n,
|
||||
worldRevision: 14n,
|
||||
accessRevision: 15n,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const read = async (value: unknown) => {
|
||||
const queryRaw = vi.fn(async (_query: unknown) => value);
|
||||
const state = await readDashboardSourceRevisionState({ $queryRaw: queryRaw } as Pick<DatabaseClient, '$queryRaw'>, 7);
|
||||
return { queryRaw, state };
|
||||
};
|
||||
|
||||
describe('dashboard source revision', () => {
|
||||
it('uses zero for missing revision rows and returns opaque 22-character hashes', async () => {
|
||||
const { queryRaw, state } = await read([
|
||||
row({
|
||||
generalRevision: 0n,
|
||||
cityRevision: 0n,
|
||||
nationRevision: 0n,
|
||||
worldRevision: 0n,
|
||||
accessRevision: 0n,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(state?.coverageVersion).toBe(1);
|
||||
expect(Object.values(state?.sourceRevisions ?? {})).toEqual([
|
||||
expect.stringMatching(/^[A-Za-z0-9_-]{22}$/u),
|
||||
expect.stringMatching(/^[A-Za-z0-9_-]{22}$/u),
|
||||
expect.stringMatching(/^[A-Za-z0-9_-]{22}$/u),
|
||||
]);
|
||||
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
|
||||
expect(statement.sql.match(/COALESCE\([^)]*\."revision", 0\)/gu)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('hashes exactly the documented context, command, and board dependency vectors', async () => {
|
||||
const initial = (await read([row()])).state;
|
||||
const cityChanged = (await read([row({ cityRevision: 99n })])).state;
|
||||
const accessChanged = (await read([row({ accessRevision: 99n })])).state;
|
||||
const worldChanged = (await read([row({ worldRevision: 99n })])).state;
|
||||
const nationChanged = (await read([row({ nationRevision: 99n })])).state;
|
||||
if (!initial || !cityChanged || !accessChanged || !worldChanged || !nationChanged) {
|
||||
throw new Error('source revision state missing');
|
||||
}
|
||||
|
||||
expect(cityChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
|
||||
expect(cityChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
|
||||
expect(cityChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
|
||||
expect(accessChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
|
||||
expect(accessChanged.sourceRevisions.commandTable).toBe(initial.sourceRevisions.commandTable);
|
||||
expect(accessChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
|
||||
expect(worldChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
|
||||
expect(worldChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
|
||||
expect(worldChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
|
||||
expect(nationChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
|
||||
expect(nationChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
|
||||
expect(nationChanged.sourceRevisions.boardAccess).not.toBe(initial.sourceRevisions.boardAccess);
|
||||
});
|
||||
|
||||
it('rejects missing meta/actor rows, malformed values, and query failures', async () => {
|
||||
await expect(read([])).resolves.toMatchObject({ state: null });
|
||||
await expect(read([row({ coverageVersion: -1 })])).resolves.toMatchObject({ state: null });
|
||||
await expect(read([row({ generalRevision: 'not-a-revision' })])).resolves.toMatchObject({ state: null });
|
||||
await expect(read([row({ generalRevision: true })])).resolves.toMatchObject({ state: null });
|
||||
|
||||
const db = {
|
||||
$queryRaw: vi.fn(async () => Promise.reject(new Error('query failed'))),
|
||||
} as unknown as Pick<DatabaseClient, '$queryRaw'>;
|
||||
await expect(readDashboardSourceRevisionState(db, 7)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user