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
+2
View File
@@ -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;
+10 -8
View File
@@ -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,
};
}),
});
+166 -164
View File
@@ -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) {
+113 -149
View File
@@ -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,
};
};