feat: send dashboard read-model deltas
This commit is contained in:
@@ -25,6 +25,7 @@ import { dynastyRouter } from './router/dynasty/index.js';
|
||||
import { voteRouter } from './router/vote/index.js';
|
||||
import { bettingRouter } from './router/betting/index.js';
|
||||
import { archiveRouter } from './router/archive/index.js';
|
||||
import { dashboardRouter } from './router/dashboard/index.js';
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
@@ -52,6 +53,7 @@ export const appRouter = router({
|
||||
vote: voteRouter,
|
||||
betting: bettingRouter,
|
||||
archive: archiveRouter,
|
||||
dashboard: dashboardRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -34,6 +34,15 @@ const getBoardActor = async (ctx: Parameters<typeof getMyGeneral>[0]) => {
|
||||
return { general, permission };
|
||||
};
|
||||
|
||||
export const getBoardAccess = async (ctx: Parameters<typeof getMyGeneral>[0]) => {
|
||||
const { permission } = await getBoardActor(ctx);
|
||||
return {
|
||||
permission,
|
||||
canMeeting: permission >= 0,
|
||||
canSecret: permission >= 2,
|
||||
};
|
||||
};
|
||||
|
||||
const parseDataUrl = (dataUrl: string): Buffer => {
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (match) {
|
||||
@@ -82,14 +91,7 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
|
||||
};
|
||||
|
||||
export const boardRouter = router({
|
||||
getAccess: authedProcedure.query(async ({ ctx }) => {
|
||||
const { permission } = await getBoardActor(ctx);
|
||||
return {
|
||||
permission,
|
||||
canMeeting: permission >= 0,
|
||||
canSecret: permission >= 2,
|
||||
};
|
||||
}),
|
||||
getAccess: authedProcedure.query(({ ctx }) => getBoardAccess(ctx)),
|
||||
getArticles: accessAuthedInputProcedure(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => {
|
||||
const { general, permission } = await getBoardActor(ctx);
|
||||
assertBoardAccess(permission, input.isSecret);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
|
||||
import { getBoardAccess } from '../board/index.js';
|
||||
import { getGeneralContext } from '../general/index.js';
|
||||
import { getTurnCommandTable } from '../turns/index.js';
|
||||
|
||||
const zRevision = z.string().regex(/^[A-Za-z0-9_-]{22}$/u);
|
||||
|
||||
const zContextBundleInput = z
|
||||
.object({
|
||||
include: z.object({
|
||||
context: z.boolean(),
|
||||
commandTable: z.boolean(),
|
||||
boardAccess: z.boolean(),
|
||||
}),
|
||||
known: z
|
||||
.object({
|
||||
context: zRevision.optional(),
|
||||
commandTable: zRevision.optional(),
|
||||
boardAccess: zRevision.optional(),
|
||||
})
|
||||
.optional(),
|
||||
forceSnapshot: z.boolean().optional(),
|
||||
})
|
||||
.refine((input) => Object.values(input.include).some(Boolean), {
|
||||
message: 'At least one dashboard context slice must be requested.',
|
||||
path: ['include'],
|
||||
});
|
||||
|
||||
export const dashboardRouter = router({
|
||||
getContextBundleDelta: authedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
|
||||
const viewerId = ctx.auth?.user.id;
|
||||
if (!viewerId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const currentContext = await getGeneralContext(ctx);
|
||||
const generalId = currentContext?.general.id ?? null;
|
||||
const [commandTable, boardAccess] = await Promise.all([
|
||||
input.include.commandTable && generalId ? getTurnCommandTable(ctx, generalId) : Promise.resolve(undefined),
|
||||
input.include.boardAccess && generalId ? getBoardAccess(ctx) : Promise.resolve(undefined),
|
||||
]);
|
||||
const context = input.include.context ? currentContext : undefined;
|
||||
|
||||
const [contextDelta, commandTableDelta, boardAccessDelta] = await Promise.all([
|
||||
context === undefined
|
||||
? Promise.resolve(undefined)
|
||||
: createReadModelDelta({
|
||||
store: ctx.redis,
|
||||
profile: ctx.profile.name,
|
||||
viewerId,
|
||||
slice: `main-context:${generalId ?? 'none'}`,
|
||||
value: context,
|
||||
knownRevision: input.known?.context,
|
||||
forceSnapshot: input.forceSnapshot,
|
||||
}),
|
||||
commandTable === undefined
|
||||
? Promise.resolve(undefined)
|
||||
: createReadModelDelta({
|
||||
store: ctx.redis,
|
||||
profile: ctx.profile.name,
|
||||
viewerId,
|
||||
slice: `main-command-table:${generalId}`,
|
||||
value: commandTable,
|
||||
knownRevision: input.known?.commandTable,
|
||||
forceSnapshot: input.forceSnapshot,
|
||||
}),
|
||||
boardAccess === undefined
|
||||
? Promise.resolve(undefined)
|
||||
: createReadModelDelta({
|
||||
store: ctx.redis,
|
||||
profile: ctx.profile.name,
|
||||
viewerId,
|
||||
slice: `main-board-access:${generalId}`,
|
||||
value: boardAccess,
|
||||
knownRevision: input.known?.boardAccess,
|
||||
forceSnapshot: input.forceSnapshot,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
context: contextDelta,
|
||||
commandTable: commandTableDelta,
|
||||
boardAccess: boardAccessDelta,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -173,6 +173,171 @@ const resolvePenalty = (penalty: unknown): Record<string, number> => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
troopId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
officerLevel: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
crew: true,
|
||||
train: true,
|
||||
atmos: true,
|
||||
injury: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
age: true,
|
||||
turnTime: true,
|
||||
crewTypeId: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
weaponCode: true,
|
||||
horseCode: true,
|
||||
bookCode: true,
|
||||
itemCode: true,
|
||||
meta: true,
|
||||
penalty: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!general) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [city, nation, worldState] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
level: true,
|
||||
nationId: true,
|
||||
population: true,
|
||||
populationMax: true,
|
||||
agriculture: true,
|
||||
agricultureMax: true,
|
||||
commerce: true,
|
||||
commerceMax: true,
|
||||
security: true,
|
||||
securityMax: true,
|
||||
trust: true,
|
||||
trade: true,
|
||||
defence: true,
|
||||
defenceMax: true,
|
||||
wall: true,
|
||||
wallMax: true,
|
||||
region: true,
|
||||
supplyState: true,
|
||||
frontState: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
level: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
tech: true,
|
||||
typeCode: true,
|
||||
capitalCityId: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||
]);
|
||||
|
||||
const metaRecord = asRecord(general.meta);
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
const settings = resolveUserSettings(metaRecord);
|
||||
const penalties = resolvePenalty(general.penalty);
|
||||
|
||||
return {
|
||||
general: {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
nationId: general.nationId,
|
||||
cityId: general.cityId,
|
||||
troopId: general.troopId,
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
officerLevel: general.officerLevel,
|
||||
stats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
},
|
||||
gold: general.gold,
|
||||
rice: general.rice,
|
||||
crew: general.crew,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
injury: general.injury,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
age: general.age,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
crewTypeId: general.crewTypeId,
|
||||
traits: {
|
||||
personal: general.personalCode,
|
||||
specialWar: general.specialCode,
|
||||
specialDomestic: general.special2Code,
|
||||
},
|
||||
progression: {
|
||||
experienceLevel: readNumber(metaRecord.explevel, 0),
|
||||
dedicationLevel: readNumber(metaRecord.dedlevel, 0),
|
||||
statExperience: {
|
||||
leadership: readNumber(metaRecord.leadership_exp, 0),
|
||||
strength: readNumber(metaRecord.strength_exp, 0),
|
||||
intelligence: readNumber(metaRecord.intel_exp, 0),
|
||||
},
|
||||
statUpgradeLimit: readNumber(constValues.upgradeLimit, 30),
|
||||
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
|
||||
},
|
||||
items: {
|
||||
horse: normalizeItemCode(general.horseCode),
|
||||
weapon: normalizeItemCode(general.weaponCode),
|
||||
book: normalizeItemCode(general.bookCode),
|
||||
item: normalizeItemCode(general.itemCode),
|
||||
},
|
||||
},
|
||||
iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []),
|
||||
canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false,
|
||||
iconChangeAvailableAt:
|
||||
typeof metaRecord.generalIconChangedAt === 'string'
|
||||
? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString()
|
||||
: null,
|
||||
city,
|
||||
nation,
|
||||
settings,
|
||||
penalties,
|
||||
};
|
||||
};
|
||||
|
||||
export const generalRouter = router({
|
||||
adjustIcon: engineAuthedProcedure
|
||||
.input(
|
||||
@@ -201,170 +366,7 @@ export const generalRouter = router({
|
||||
input?.clientRequestId ?? ctx.requestId
|
||||
);
|
||||
}),
|
||||
me: authedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
troopId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
officerLevel: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
crew: true,
|
||||
train: true,
|
||||
atmos: true,
|
||||
injury: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
age: true,
|
||||
turnTime: true,
|
||||
crewTypeId: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
weaponCode: true,
|
||||
horseCode: true,
|
||||
bookCode: true,
|
||||
itemCode: true,
|
||||
meta: true,
|
||||
penalty: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!general) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [city, nation, worldState] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
level: true,
|
||||
nationId: true,
|
||||
population: true,
|
||||
populationMax: true,
|
||||
agriculture: true,
|
||||
agricultureMax: true,
|
||||
commerce: true,
|
||||
commerceMax: true,
|
||||
security: true,
|
||||
securityMax: true,
|
||||
trust: true,
|
||||
trade: true,
|
||||
defence: true,
|
||||
defenceMax: true,
|
||||
wall: true,
|
||||
wallMax: true,
|
||||
region: true,
|
||||
supplyState: true,
|
||||
frontState: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
level: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
tech: true,
|
||||
typeCode: true,
|
||||
capitalCityId: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||
]);
|
||||
|
||||
const metaRecord = asRecord(general.meta);
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
const settings = resolveUserSettings(metaRecord);
|
||||
const penalties = resolvePenalty(general.penalty);
|
||||
|
||||
return {
|
||||
general: {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
nationId: general.nationId,
|
||||
cityId: general.cityId,
|
||||
troopId: general.troopId,
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
officerLevel: general.officerLevel,
|
||||
stats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
},
|
||||
gold: general.gold,
|
||||
rice: general.rice,
|
||||
crew: general.crew,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
injury: general.injury,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
age: general.age,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
crewTypeId: general.crewTypeId,
|
||||
traits: {
|
||||
personal: general.personalCode,
|
||||
specialWar: general.specialCode,
|
||||
specialDomestic: general.special2Code,
|
||||
},
|
||||
progression: {
|
||||
experienceLevel: readNumber(metaRecord.explevel, 0),
|
||||
dedicationLevel: readNumber(metaRecord.dedlevel, 0),
|
||||
statExperience: {
|
||||
leadership: readNumber(metaRecord.leadership_exp, 0),
|
||||
strength: readNumber(metaRecord.strength_exp, 0),
|
||||
intelligence: readNumber(metaRecord.intel_exp, 0),
|
||||
},
|
||||
statUpgradeLimit: readNumber(constValues.upgradeLimit, 30),
|
||||
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
|
||||
},
|
||||
items: {
|
||||
horse: normalizeItemCode(general.horseCode),
|
||||
weapon: normalizeItemCode(general.weaponCode),
|
||||
book: normalizeItemCode(general.bookCode),
|
||||
item: normalizeItemCode(general.itemCode),
|
||||
},
|
||||
},
|
||||
iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []),
|
||||
canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false,
|
||||
iconChangeAvailableAt:
|
||||
typeof metaRecord.generalIconChangedAt === 'string'
|
||||
? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString()
|
||||
: null,
|
||||
city,
|
||||
nation,
|
||||
settings,
|
||||
penalties,
|
||||
};
|
||||
}),
|
||||
me: authedProcedure.query(({ ctx }) => getGeneralContext(ctx)),
|
||||
ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
|
||||
@@ -42,17 +42,14 @@ const zPushAmount = z
|
||||
const zRepeatAmount = z.number().int().min(1).max(12);
|
||||
|
||||
const buildTurnListSchema = (minimum: number, maximum: number) =>
|
||||
z
|
||||
.array(z.number().int().min(minimum).max(maximum))
|
||||
.min(1);
|
||||
z.array(z.number().int().min(minimum).max(maximum)).min(1);
|
||||
|
||||
const buildBulkEntrySchema = (turnList: z.ZodType<number[]>) =>
|
||||
z
|
||||
.object({
|
||||
turnList,
|
||||
action: z.string().min(1),
|
||||
args: z.unknown().optional(),
|
||||
});
|
||||
z.object({
|
||||
turnList,
|
||||
action: z.string().min(1),
|
||||
args: z.unknown().optional(),
|
||||
});
|
||||
|
||||
const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => {
|
||||
try {
|
||||
@@ -112,9 +109,107 @@ const assertReservedTurnPermission = async (
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message:
|
||||
result.kind === 'deny'
|
||||
? `예약 불가능한 커맨드 :${result.reason}`
|
||||
: '예약 권한을 확인할 정보가 부족합니다.',
|
||||
result.kind === 'deny' ? `예약 불가능한 커맨드 :${result.reason}` : '예약 권한을 확인할 정보가 부족합니다.',
|
||||
});
|
||||
};
|
||||
|
||||
export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number) => {
|
||||
const [worldState, general] = await Promise.all([ctx.db.worldState.findFirst(), getOwnedGeneral(ctx, generalId)]);
|
||||
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
|
||||
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, itemModules] =
|
||||
await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.general.findMany({
|
||||
where: { nationId: general.nationId },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ctx.db.city.findMany({
|
||||
select: { id: true, name: true, nationId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.nation.findMany({
|
||||
select: { id: true, name: true, color: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.general.findMany({
|
||||
where: { npcState: { lt: 2 } },
|
||||
select: { id: true, name: true, nationId: true, cityId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
buildBattleSimEnvironment(worldState, ctx.profile.id),
|
||||
loadBattleSimTraitOptions(),
|
||||
loadItemModules([...ITEM_KEYS]),
|
||||
]);
|
||||
|
||||
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
||||
const cityById = new Map(cities.map((entry) => [entry.id, entry]));
|
||||
const items: TurnCommandInputOptions['items'] = {
|
||||
horse: [{ value: 'None', label: '판매/해제' }],
|
||||
weapon: [{ value: 'None', label: '판매/해제' }],
|
||||
book: [{ value: 'None', label: '판매/해제' }],
|
||||
item: [{ value: 'None', label: '판매/해제' }],
|
||||
};
|
||||
for (const item of itemModules) {
|
||||
if (item.buyable) {
|
||||
items[item.slot].push({ value: item.key, label: item.name });
|
||||
}
|
||||
}
|
||||
const inputOptions: TurnCommandInputOptions = {
|
||||
cities: cities.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`,
|
||||
})),
|
||||
nations: nations.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: entry.name,
|
||||
color: entry.color,
|
||||
})),
|
||||
generals: generals.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${
|
||||
cityById.get(entry.cityId)?.name ?? '재야'
|
||||
})`,
|
||||
})),
|
||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
||||
.map((entry) => ({ value: entry.id, label: entry.name })),
|
||||
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({
|
||||
value: Number(value),
|
||||
label,
|
||||
})),
|
||||
nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })),
|
||||
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
||||
value: index,
|
||||
label: `색상 ${index + 1}`,
|
||||
color,
|
||||
})),
|
||||
items,
|
||||
};
|
||||
|
||||
return buildTurnCommandTable({
|
||||
worldState,
|
||||
general,
|
||||
city,
|
||||
nation,
|
||||
nationGenerals,
|
||||
inputOptions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -125,108 +220,7 @@ export const turnsRouter = router({
|
||||
generalId: z.number().int().positive(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [worldState, general] = await Promise.all([
|
||||
ctx.db.worldState.findFirst(),
|
||||
getOwnedGeneral(ctx, input.generalId),
|
||||
]);
|
||||
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
|
||||
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, itemModules] =
|
||||
await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.general.findMany({
|
||||
where: { nationId: general.nationId },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ctx.db.city.findMany({
|
||||
select: { id: true, name: true, nationId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.nation.findMany({
|
||||
select: { id: true, name: true, color: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.general.findMany({
|
||||
where: { npcState: { lt: 2 } },
|
||||
select: { id: true, name: true, nationId: true, cityId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
buildBattleSimEnvironment(worldState, ctx.profile.id),
|
||||
loadBattleSimTraitOptions(),
|
||||
loadItemModules([...ITEM_KEYS]),
|
||||
]);
|
||||
|
||||
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
||||
const cityById = new Map(cities.map((entry) => [entry.id, entry]));
|
||||
const items: TurnCommandInputOptions['items'] = {
|
||||
horse: [{ value: 'None', label: '판매/해제' }],
|
||||
weapon: [{ value: 'None', label: '판매/해제' }],
|
||||
book: [{ value: 'None', label: '판매/해제' }],
|
||||
item: [{ value: 'None', label: '판매/해제' }],
|
||||
};
|
||||
for (const item of itemModules) {
|
||||
if (item.buyable) {
|
||||
items[item.slot].push({ value: item.key, label: item.name });
|
||||
}
|
||||
}
|
||||
const inputOptions: TurnCommandInputOptions = {
|
||||
cities: cities.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`,
|
||||
})),
|
||||
nations: nations.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: entry.name,
|
||||
color: entry.color,
|
||||
})),
|
||||
generals: generals.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${
|
||||
cityById.get(entry.cityId)?.name ?? '재야'
|
||||
})`,
|
||||
})),
|
||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
||||
.map((entry) => ({ value: entry.id, label: entry.name })),
|
||||
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({
|
||||
value: Number(value),
|
||||
label,
|
||||
})),
|
||||
nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })),
|
||||
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
||||
value: index,
|
||||
label: `색상 ${index + 1}`,
|
||||
color,
|
||||
})),
|
||||
items,
|
||||
};
|
||||
|
||||
return buildTurnCommandTable({
|
||||
worldState,
|
||||
general,
|
||||
city,
|
||||
nation,
|
||||
nationGenerals,
|
||||
inputOptions,
|
||||
});
|
||||
}),
|
||||
.query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
|
||||
reserved: router({
|
||||
getGeneral: authedProcedure
|
||||
.input(
|
||||
@@ -322,13 +316,7 @@ export const turnsRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
entries: z
|
||||
.array(
|
||||
buildBulkEntrySchema(
|
||||
buildTurnListSchema(-3, MAX_GENERAL_TURNS - 1)
|
||||
)
|
||||
)
|
||||
.min(1),
|
||||
entries: z.array(buildBulkEntrySchema(buildTurnListSchema(-3, MAX_GENERAL_TURNS - 1))).min(1),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
@@ -343,13 +331,7 @@ export const turnsRouter = router({
|
||||
);
|
||||
const worldState = await getReservationWorldState(ctx);
|
||||
for (const update of updates) {
|
||||
await assertReservedTurnPermission(
|
||||
worldState,
|
||||
general,
|
||||
'general',
|
||||
update.action,
|
||||
update.args
|
||||
);
|
||||
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
|
||||
}
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
||||
@@ -472,13 +454,7 @@ export const turnsRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
entries: z
|
||||
.array(
|
||||
buildBulkEntrySchema(
|
||||
buildTurnListSchema(0, MAX_NATION_TURNS - 1)
|
||||
)
|
||||
)
|
||||
.min(1),
|
||||
entries: z.array(buildBulkEntrySchema(buildTurnListSchema(0, MAX_NATION_TURNS - 1))).min(1),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
@@ -505,22 +481,10 @@ export const turnsRouter = router({
|
||||
);
|
||||
const worldState = await getReservationWorldState(ctx);
|
||||
for (const update of updates) {
|
||||
await assertReservedTurnPermission(
|
||||
worldState,
|
||||
general,
|
||||
'nation',
|
||||
update.action,
|
||||
update.args
|
||||
);
|
||||
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
||||
}
|
||||
const snapshot = await mutateReservedTurns(() =>
|
||||
setNationTurns(
|
||||
ctx.db,
|
||||
general.nationId,
|
||||
general.officerLevel,
|
||||
updates,
|
||||
input.expectedRevision
|
||||
)
|
||||
setNationTurns(ctx.db, general.nationId, general.officerLevel, updates, input.expectedRevision)
|
||||
);
|
||||
return { ok: true, ...snapshot };
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createJsonPatch, type ReadModelDelta } from '@sammo-ts/common';
|
||||
|
||||
const CACHE_TTL_SECONDS = 15 * 60;
|
||||
const REVISION_LENGTH = 22;
|
||||
|
||||
export interface ReadModelDeltaCacheStore {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, options: { EX: number }): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ReadModelDeltaRequest<T> {
|
||||
store: ReadModelDeltaCacheStore;
|
||||
profile: string;
|
||||
viewerId: string;
|
||||
slice: string;
|
||||
value: T;
|
||||
knownRevision?: string;
|
||||
forceSnapshot?: boolean;
|
||||
}
|
||||
|
||||
const canonicalize = (value: unknown): unknown => {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalize(item)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const digest = (value: string): string =>
|
||||
createHash('sha256').update(value).digest('base64url').slice(0, REVISION_LENGTH);
|
||||
|
||||
const buildScope = (profile: string, viewerId: string, slice: string): string =>
|
||||
digest(`${profile}\0${viewerId}\0${slice}`);
|
||||
|
||||
export const buildReadModelDeltaCacheKey = (
|
||||
profile: string,
|
||||
viewerId: string,
|
||||
slice: string,
|
||||
revision: string
|
||||
): string => `sammo:${profile}:private-read-model:${buildScope(profile, viewerId, slice)}:${revision}`;
|
||||
|
||||
const canPatch = (value: unknown): value is Record<string, unknown> | unknown[] =>
|
||||
value !== null && typeof value === 'object';
|
||||
|
||||
const storeSnapshot = async (store: ReadModelDeltaCacheStore, key: string, serialized: string): Promise<void> => {
|
||||
try {
|
||||
await store.set(key, serialized, { EX: CACHE_TTL_SECONDS });
|
||||
} catch {
|
||||
// Redis is a best-effort optimization. The caller still receives a full snapshot.
|
||||
}
|
||||
};
|
||||
|
||||
export const createReadModelDelta = async <T>(request: ReadModelDeltaRequest<T>): Promise<ReadModelDelta<T>> => {
|
||||
const canonicalValue = canonicalize(request.value) as T;
|
||||
const serialized = JSON.stringify(canonicalValue);
|
||||
const revision = digest(serialized);
|
||||
const currentKey = buildReadModelDeltaCacheKey(request.profile, request.viewerId, request.slice, revision);
|
||||
|
||||
if (!request.forceSnapshot && request.knownRevision === revision) {
|
||||
return { kind: 'unchanged', revision };
|
||||
}
|
||||
|
||||
if (!request.forceSnapshot && request.knownRevision) {
|
||||
const baselineKey = buildReadModelDeltaCacheKey(
|
||||
request.profile,
|
||||
request.viewerId,
|
||||
request.slice,
|
||||
request.knownRevision
|
||||
);
|
||||
try {
|
||||
const baselineSerialized = await request.store.get(baselineKey);
|
||||
if (baselineSerialized) {
|
||||
const baseline = JSON.parse(baselineSerialized) as unknown;
|
||||
if (canPatch(baseline) && canPatch(canonicalValue)) {
|
||||
const operations = createJsonPatch(baseline, canonicalValue);
|
||||
const patch = {
|
||||
kind: 'patch' as const,
|
||||
baseRevision: request.knownRevision,
|
||||
revision,
|
||||
operations,
|
||||
};
|
||||
const snapshot = { kind: 'snapshot' as const, revision, data: canonicalValue };
|
||||
await storeSnapshot(request.store, currentKey, serialized);
|
||||
return Buffer.byteLength(JSON.stringify(patch)) < Buffer.byteLength(JSON.stringify(snapshot))
|
||||
? patch
|
||||
: snapshot;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Corrupt/missing cache data and Redis failures recover with a full snapshot.
|
||||
}
|
||||
}
|
||||
|
||||
await storeSnapshot(request.store, currentKey, serialized);
|
||||
return {
|
||||
kind: 'snapshot',
|
||||
revision,
|
||||
data: canonicalValue,
|
||||
};
|
||||
};
|
||||
@@ -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 } } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user