feat: send dashboard read-model deltas

This commit is contained in:
2026-08-11 12:36:29 +00:00
parent c737ee2cd1
commit a2e94c3092
17 changed files with 1285 additions and 395 deletions
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import { applyReadModelDelta } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GameApiContext } from '../src/context.js';
import { dashboardRouter } from '../src/router/dashboard/index.js';
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'hwe:default',
issuedAt: '2026-08-11T00:00:00.000Z',
expiresAt: '2026-08-12T00:00:00.000Z',
sessionId: 'dashboard-delta-session',
user: {
id: 'viewer-1',
username: 'dashboard-viewer',
displayName: '대시보드 사용자',
roles: [],
},
sanctions: {},
};
const buildContext = (authenticated: boolean) => {
let generalName = '초기 장수';
const redisValues = new Map<string, string>();
const context = {
auth: authenticated ? auth : null,
profile: { id: 'hwe', scenario: 'default', name: 'hwe:default' },
redis: {
get: async (key: string) => redisValues.get(key) ?? null,
set: async (key: string, value: string) => {
redisValues.set(key, value);
return 'OK';
},
},
db: {
general: {
findFirst: async () => ({
id: 7,
name: generalName,
npcState: 0,
nationId: 0,
cityId: 0,
troopId: 0,
picture: null,
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
officerLevel: 0,
gold: 1_000,
rice: 2_000,
crew: 300,
train: 80,
atmos: 90,
injury: 0,
experience: 100,
dedication: 200,
age: 20,
turnTime: new Date('2026-08-11T00:00:00.000Z'),
crewTypeId: 0,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
weaponCode: 'None',
horseCode: 'None',
bookCode: 'None',
itemCode: 'None',
meta: {},
penalty: {},
}),
},
city: { findUnique: async () => null },
nation: { findUnique: async () => null },
worldState: { findFirst: async () => ({ config: { const: {} } }) },
},
} as unknown as GameApiContext;
return {
context,
rename: (name: string) => {
generalName = name;
},
};
};
const contextOnly = {
include: { context: true, commandTable: false, boardAccess: false },
};
describe('dashboardRouter.getContextBundleDelta', () => {
it('returns a snapshot, unchanged revision, and applicable patch for the authenticated viewer', async () => {
const fixture = buildContext(true);
const caller = dashboardRouter.createCaller(fixture.context);
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
expect(initial.context?.kind).toBe('snapshot');
if (!initial.context || initial.context.kind !== 'snapshot') throw new Error('initial snapshot missing');
if (!initial.context.data) throw new Error('initial general context missing');
const initialData = initial.context.data;
const initialRevision = initial.context.revision;
const unchanged = await caller.getContextBundleDelta({
...contextOnly,
known: { context: initialRevision },
});
expect(unchanged.context).toEqual({ kind: 'unchanged', revision: initialRevision });
fixture.rename('갱신된 장수');
const changed = await caller.getContextBundleDelta({
...contextOnly,
known: { context: initialRevision },
});
expect(changed.context?.kind).toBe('patch');
if (!changed.context) throw new Error('context delta missing');
const applied = applyReadModelDelta(initialData, initialRevision, changed.context).data;
if (!applied) throw new Error('patched general context missing');
expect(applied.general.name).toBe('갱신된 장수');
expect(Buffer.byteLength(JSON.stringify(changed))).toBeLessThan(1_000);
});
it('rejects anonymous requests before reading dashboard data', async () => {
const fixture = buildContext(false);
await expect(
dashboardRouter.createCaller(fixture.context).getContextBundleDelta(contextOnly)
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('rejects an empty bundle request', async () => {
const fixture = buildContext(true);
await expect(
dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
include: { context: false, commandTable: false, boardAccess: false },
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
});
@@ -0,0 +1,68 @@
import { randomUUID } from 'node:crypto';
import { applyReadModelDelta } from '@sammo-ts/common';
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { describe, expect, it } from 'vitest';
import { buildReadModelDeltaCacheKey, createReadModelDelta } from '../src/services/readModelDeltaCache.js';
const liveDescribe = process.env.REDIS_URL ? describe : describe.skip;
liveDescribe('read-model delta cache with live Redis', () => {
it('stores a private expiring baseline and serves an applicable patch', async () => {
const connector = createRedisConnector(resolveRedisConfigFromEnv());
await connector.connect();
const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? randomUUID();
const profile = `hwe:dashboard-delta-${runId}`;
const viewerId = `viewer-${randomUUID()}`;
const slice = 'main-command-table:7';
const initialValue = {
general: Array.from({ length: 48 }, (_, index) => ({
key: `command-${index}`,
name: `명령 ${index}`,
possible: true,
inputFields: [{ key: 'amount', kind: 'number', required: true }],
})),
};
const keys: string[] = [];
try {
const initial = await createReadModelDelta({
store: connector.client,
profile,
viewerId,
slice,
value: initialValue,
forceSnapshot: true,
});
const initialKey = buildReadModelDeltaCacheKey(profile, viewerId, slice, initial.revision);
keys.push(initialKey);
expect(await connector.client.get(initialKey)).not.toBeNull();
expect(await connector.client.ttl(initialKey)).toBeGreaterThan(0);
const nextValue = structuredClone(initialValue);
const first = nextValue.general[0];
if (!first) throw new Error('command fixture is empty');
first.possible = false;
const changed = await createReadModelDelta({
store: connector.client,
profile,
viewerId,
slice,
value: nextValue,
knownRevision: initial.revision,
});
expect(changed.kind).toBe('patch');
expect(applyReadModelDelta(initialValue, initial.revision, changed).data).toEqual(nextValue);
expect(Buffer.byteLength(JSON.stringify(changed))).toBeLessThan(1_000);
const changedKey = buildReadModelDeltaCacheKey(profile, viewerId, slice, changed.revision);
keys.push(changedKey);
expect(await connector.client.get(changedKey)).not.toBeNull();
} finally {
if (keys.length > 0) await connector.client.del(keys);
await connector.disconnect();
}
});
});
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { applyReadModelDelta } from '@sammo-ts/common';
import {
buildReadModelDeltaCacheKey,
createReadModelDelta,
type ReadModelDeltaCacheStore,
} from '../src/services/readModelDeltaCache.js';
class MemoryStore implements ReadModelDeltaCacheStore {
readonly values = new Map<string, string>();
setCalls = 0;
async get(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async set(key: string, value: string): Promise<string> {
this.setCalls += 1;
this.values.set(key, value);
return 'OK';
}
}
const largeCommandTable = () => ({
general: Array.from({ length: 48 }, (_, index) => ({
key: `command-${index}`,
name: `명령 ${index}`,
reqArg: index % 2 === 0,
possible: true,
status: 'available',
inputFields: [{ key: 'amount', label: '수량', type: 'number' }],
})),
nation: [],
inputOptions: {
cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })),
},
});
describe('createReadModelDelta', () => {
it('reduces unchanged and one-field updates below one kilobyte', async () => {
const store = new MemoryStore();
const initialValue = largeCommandTable();
const initial = await createReadModelDelta({
store,
profile: 'hwe:default',
viewerId: 'user-1',
slice: 'command-table',
value: initialValue,
forceSnapshot: true,
});
expect(initial.kind).toBe('snapshot');
expect(Buffer.byteLength(JSON.stringify(initial))).toBeGreaterThan(5_000);
const unchanged = await createReadModelDelta({
store,
profile: 'hwe:default',
viewerId: 'user-1',
slice: 'command-table',
value: initialValue,
knownRevision: initial.revision,
});
expect(unchanged.kind).toBe('unchanged');
expect(Buffer.byteLength(JSON.stringify(unchanged))).toBeLessThan(1_000);
expect(store.setCalls).toBe(1);
const nextValue = structuredClone(initialValue);
const firstCommand = nextValue.general[0];
if (!firstCommand) throw new Error('command fixture is empty');
firstCommand.possible = false;
firstCommand.status = 'blocked';
const changed = await createReadModelDelta({
store,
profile: 'hwe:default',
viewerId: 'user-1',
slice: 'command-table',
value: nextValue,
knownRevision: initial.revision,
});
expect(changed.kind).toBe('patch');
expect(Buffer.byteLength(JSON.stringify(changed))).toBeLessThan(1_000);
expect(applyReadModelDelta(initialValue, initial.revision, changed).data).toEqual(nextValue);
});
it('keeps private baselines in viewer-scoped keys', () => {
expect(buildReadModelDeltaCacheKey('hwe:default', 'user-1', 'context', 'revision')).not.toBe(
buildReadModelDeltaCacheKey('hwe:default', 'user-2', 'context', 'revision')
);
});
it('falls back to a snapshot when Redis is unavailable', async () => {
const store: ReadModelDeltaCacheStore = {
get: async () => {
throw new Error('redis unavailable');
},
set: async () => {
throw new Error('redis unavailable');
},
};
const delta = await createReadModelDelta({
store,
profile: 'hwe:default',
viewerId: 'user-1',
slice: 'context',
value: { general: { id: 1 } },
knownRevision: 'old-revision',
});
expect(delta).toMatchObject({ kind: 'snapshot', data: { general: { id: 1 } } });
});
});