perf: dashboard revision-first 조회 계약을 추가
content revision과 별도인 source revision을 선택적으로 교환하고 coverage가 충족될 때만 payload loader를 생략한다. 구형 client와 미완성 producer coverage는 기존 full computation으로 안전하게 복구한다.
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import type { ReadModelDelta } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { accessLimitAuthedProcedure, router } from '../../trpc.js';
|
||||
import {
|
||||
canUseDashboardSourceRevision,
|
||||
readDashboardSourceRevisionState,
|
||||
type DashboardSourceRevisionState,
|
||||
type DashboardSourceSlice,
|
||||
} from '../../services/dashboardSourceRevision.js';
|
||||
import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
|
||||
import { getBoardAccess } from '../board/index.js';
|
||||
import { getGeneralContext } from '../general/index.js';
|
||||
@@ -22,9 +29,57 @@ const zContextBundleInput = z.object({
|
||||
boardAccess: zRevision.optional(),
|
||||
})
|
||||
.optional(),
|
||||
knownSource: z
|
||||
.object({
|
||||
context: zRevision.optional(),
|
||||
commandTable: zRevision.optional(),
|
||||
boardAccess: zRevision.optional(),
|
||||
})
|
||||
.optional(),
|
||||
forceSnapshot: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const createDashboardSliceDelta = async <T>(options: {
|
||||
included: boolean;
|
||||
sourceState: DashboardSourceRevisionState | null;
|
||||
slice: DashboardSourceSlice;
|
||||
knownContent?: string;
|
||||
knownSource?: string;
|
||||
forceSnapshot?: boolean;
|
||||
load: () => Promise<T | undefined>;
|
||||
create: (value: T) => Promise<ReadModelDelta<T>>;
|
||||
}): Promise<ReadModelDelta<T> | undefined> => {
|
||||
if (!options.included) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sourceRevision = options.sourceState?.sourceRevisions[options.slice];
|
||||
if (
|
||||
sourceRevision !== undefined &&
|
||||
options.knownContent !== undefined &&
|
||||
canUseDashboardSourceRevision({
|
||||
state: options.sourceState,
|
||||
slice: options.slice,
|
||||
knownContent: options.knownContent,
|
||||
knownSource: options.knownSource,
|
||||
forceSnapshot: options.forceSnapshot,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
kind: 'unchanged',
|
||||
revision: options.knownContent,
|
||||
sourceRevision,
|
||||
};
|
||||
}
|
||||
|
||||
const value = await options.load();
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const delta = await options.create(value);
|
||||
return sourceRevision ? { ...delta, sourceRevision } : delta;
|
||||
};
|
||||
|
||||
export const dashboardRouter = router({
|
||||
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
|
||||
const viewerId = ctx.auth?.user.id;
|
||||
@@ -33,59 +88,81 @@ export const dashboardRouter = router({
|
||||
}
|
||||
|
||||
const includesProjection = Object.values(input.include).some(Boolean);
|
||||
const currentContext = input.include.context ? await getGeneralContext(ctx) : undefined;
|
||||
const generalId =
|
||||
currentContext?.general.id ??
|
||||
ctx.realtimeAccessGeneralId ??
|
||||
(includesProjection
|
||||
? (
|
||||
await ctx.db.general.findFirst({
|
||||
where: { userId: viewerId },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true },
|
||||
})
|
||||
)?.id ?? null
|
||||
: 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;
|
||||
let generalId: number | null = null;
|
||||
if (includesProjection) {
|
||||
generalId =
|
||||
ctx.realtimeAccessGeneralId ??
|
||||
(
|
||||
await ctx.db.general.findFirst({
|
||||
where: { userId: viewerId },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true },
|
||||
})
|
||||
)?.id ??
|
||||
null;
|
||||
}
|
||||
const sourceState = generalId
|
||||
? await readDashboardSourceRevisionState(ctx.db, generalId)
|
||||
: null;
|
||||
|
||||
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,
|
||||
}),
|
||||
createDashboardSliceDelta({
|
||||
included: input.include.context,
|
||||
sourceState,
|
||||
slice: 'context',
|
||||
knownContent: input.known?.context,
|
||||
knownSource: input.knownSource?.context,
|
||||
forceSnapshot: input.forceSnapshot,
|
||||
load: () => getGeneralContext(ctx),
|
||||
create: (value) =>
|
||||
createReadModelDelta({
|
||||
store: ctx.redis,
|
||||
profile: ctx.profile.name,
|
||||
viewerId,
|
||||
slice: `main-context:${generalId ?? 'none'}`,
|
||||
value,
|
||||
knownRevision: input.known?.context,
|
||||
forceSnapshot: input.forceSnapshot,
|
||||
}),
|
||||
}),
|
||||
createDashboardSliceDelta({
|
||||
included: input.include.commandTable && generalId !== null,
|
||||
sourceState,
|
||||
slice: 'commandTable',
|
||||
knownContent: input.known?.commandTable,
|
||||
knownSource: input.knownSource?.commandTable,
|
||||
forceSnapshot: input.forceSnapshot,
|
||||
load: () => (generalId ? getTurnCommandTable(ctx, generalId) : Promise.resolve(undefined)),
|
||||
create: (value) =>
|
||||
createReadModelDelta({
|
||||
store: ctx.redis,
|
||||
profile: ctx.profile.name,
|
||||
viewerId,
|
||||
slice: `main-command-table:${generalId}`,
|
||||
value,
|
||||
knownRevision: input.known?.commandTable,
|
||||
forceSnapshot: input.forceSnapshot,
|
||||
}),
|
||||
}),
|
||||
createDashboardSliceDelta({
|
||||
included: input.include.boardAccess && generalId !== null,
|
||||
sourceState,
|
||||
slice: 'boardAccess',
|
||||
knownContent: input.known?.boardAccess,
|
||||
knownSource: input.knownSource?.boardAccess,
|
||||
forceSnapshot: input.forceSnapshot,
|
||||
load: () => (generalId ? getBoardAccess(ctx) : Promise.resolve(undefined)),
|
||||
create: (value) =>
|
||||
createReadModelDelta({
|
||||
store: ctx.redis,
|
||||
profile: ctx.profile.name,
|
||||
viewerId,
|
||||
slice: `main-board-access:${generalId}`,
|
||||
value,
|
||||
knownRevision: input.known?.boardAccess,
|
||||
forceSnapshot: input.forceSnapshot,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
/**
|
||||
* Reserved target version for the revision-first protocol tests. Do not set the
|
||||
* database coverage meta to this value until every transitive context/command
|
||||
* dependency and producer has been reconciled; migrations/runtime remain at 0.
|
||||
*/
|
||||
export const DASHBOARD_SOURCE_REVISION_COVERAGE_VERSION = 1;
|
||||
|
||||
const SOURCE_REVISION_LENGTH = 22;
|
||||
const SOURCE_REVISION_CODE_VERSION = 'dashboard-private-slices-v1';
|
||||
|
||||
export type DashboardSourceSlice = 'context' | 'commandTable' | 'boardAccess';
|
||||
|
||||
export interface DashboardSourceRevisionState {
|
||||
coverageVersion: number;
|
||||
identity: {
|
||||
generalId: number;
|
||||
cityId: number;
|
||||
nationId: number;
|
||||
};
|
||||
sourceRevisions: Record<DashboardSourceSlice, string>;
|
||||
}
|
||||
|
||||
interface DashboardSourceRevisionRow {
|
||||
generalId: number;
|
||||
cityId: number;
|
||||
nationId: number;
|
||||
coverageVersion: number;
|
||||
generalRevision: bigint;
|
||||
cityRevision: bigint;
|
||||
nationRevision: bigint;
|
||||
worldRevision: bigint;
|
||||
accessRevision: bigint;
|
||||
}
|
||||
|
||||
type RevisionTuple = readonly [domain: string, entityId: number, revision: string];
|
||||
type DashboardRevisionVector = {
|
||||
general: string;
|
||||
city: string;
|
||||
nation: string;
|
||||
world: string;
|
||||
access: string;
|
||||
};
|
||||
type ParsedDashboardRevisionVector = {
|
||||
[Key in keyof DashboardRevisionVector]: DashboardRevisionVector[Key] | null;
|
||||
};
|
||||
|
||||
const parseNonNegativeInteger = (value: unknown): number | null => {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseNonNegativeRevision = (value: unknown): string | null => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value >= 0n ? value.toString() : null;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
|
||||
}
|
||||
if (typeof value === 'string' && /^(?:0|[1-9]\d*)$/u.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const digestSourceRevision = (slice: DashboardSourceSlice, dependencies: readonly RevisionTuple[]): string =>
|
||||
createHash('sha256')
|
||||
.update(JSON.stringify([SOURCE_REVISION_CODE_VERSION, slice, dependencies]))
|
||||
.digest('base64url')
|
||||
.slice(0, SOURCE_REVISION_LENGTH);
|
||||
|
||||
const isCompleteRevisionVector = (
|
||||
revisions: ParsedDashboardRevisionVector
|
||||
): revisions is DashboardRevisionVector => Object.values(revisions).every((revision) => revision !== null);
|
||||
|
||||
const buildSourceRevisions = (
|
||||
identity: DashboardSourceRevisionState['identity'],
|
||||
revisions: DashboardRevisionVector
|
||||
): Record<DashboardSourceSlice, string> => {
|
||||
const general = ['general.content', identity.generalId, revisions.general] as const;
|
||||
const city = ['city.content', identity.cityId, identity.cityId > 0 ? revisions.city : '0'] as const;
|
||||
const nation = ['nation.content', identity.nationId, identity.nationId > 0 ? revisions.nation : '0'] as const;
|
||||
const world = ['world.content', 0, revisions.world] as const;
|
||||
const access = ['access.general', identity.generalId, revisions.access] as const;
|
||||
|
||||
return {
|
||||
context: digestSourceRevision('context', [general, city, nation, world, access]),
|
||||
commandTable: digestSourceRevision('commandTable', [general, city, nation, world]),
|
||||
boardAccess: digestSourceRevision('boardAccess', [general, nation]),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the access-gate actor identity, coverage gate, and all dashboard-private
|
||||
* dependency heads in one indexed statement. Missing revision rows are revision 0.
|
||||
* A missing actor/meta row, query failure, or malformed result disables the optimization.
|
||||
*/
|
||||
export const readDashboardSourceRevisionState = async (
|
||||
db: Pick<DatabaseClient, '$queryRaw'>,
|
||||
generalId: number
|
||||
): Promise<DashboardSourceRevisionState | null> => {
|
||||
if (!Number.isSafeInteger(generalId) || generalId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rows: DashboardSourceRevisionRow[];
|
||||
try {
|
||||
rows = await db.$queryRaw<DashboardSourceRevisionRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
actor."id" AS "generalId",
|
||||
actor."city_id" AS "cityId",
|
||||
actor."nation_id" AS "nationId",
|
||||
meta."coverage_version" AS "coverageVersion",
|
||||
COALESCE(general_revision."revision", 0) AS "generalRevision",
|
||||
COALESCE(city_revision."revision", 0) AS "cityRevision",
|
||||
COALESCE(nation_revision."revision", 0) AS "nationRevision",
|
||||
COALESCE(world_revision."revision", 0) AS "worldRevision",
|
||||
COALESCE(access_revision."revision", 0) AS "accessRevision"
|
||||
FROM "general" AS actor
|
||||
CROSS JOIN "read_model_revision_meta" AS meta
|
||||
LEFT JOIN "read_model_revision" AS general_revision
|
||||
ON general_revision."domain" = 'general.content'
|
||||
AND general_revision."entity_id" = actor."id"
|
||||
LEFT JOIN "read_model_revision" AS city_revision
|
||||
ON city_revision."domain" = 'city.content'
|
||||
AND city_revision."entity_id" = actor."city_id"
|
||||
LEFT JOIN "read_model_revision" AS nation_revision
|
||||
ON nation_revision."domain" = 'nation.content'
|
||||
AND nation_revision."entity_id" = actor."nation_id"
|
||||
LEFT JOIN "read_model_revision" AS world_revision
|
||||
ON world_revision."domain" = 'world.content'
|
||||
AND world_revision."entity_id" = 0
|
||||
LEFT JOIN "read_model_revision" AS access_revision
|
||||
ON access_revision."domain" = 'access.general'
|
||||
AND access_revision."entity_id" = actor."id"
|
||||
WHERE actor."id" = ${generalId}
|
||||
AND meta."id" = 1
|
||||
`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = rows.length === 1 ? rows[0] : undefined;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const identity = {
|
||||
generalId: parseNonNegativeInteger(row.generalId),
|
||||
cityId: parseNonNegativeInteger(row.cityId),
|
||||
nationId: parseNonNegativeInteger(row.nationId),
|
||||
};
|
||||
const coverageVersion = parseNonNegativeInteger(row.coverageVersion);
|
||||
const revisions = {
|
||||
general: parseNonNegativeRevision(row.generalRevision),
|
||||
city: parseNonNegativeRevision(row.cityRevision),
|
||||
nation: parseNonNegativeRevision(row.nationRevision),
|
||||
world: parseNonNegativeRevision(row.worldRevision),
|
||||
access: parseNonNegativeRevision(row.accessRevision),
|
||||
};
|
||||
if (
|
||||
identity.generalId !== generalId ||
|
||||
identity.cityId === null ||
|
||||
identity.nationId === null ||
|
||||
coverageVersion === null ||
|
||||
!isCompleteRevisionVector(revisions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validIdentity = {
|
||||
generalId,
|
||||
cityId: identity.cityId,
|
||||
nationId: identity.nationId,
|
||||
};
|
||||
return {
|
||||
coverageVersion,
|
||||
identity: validIdentity,
|
||||
sourceRevisions: buildSourceRevisions(validIdentity, revisions),
|
||||
};
|
||||
};
|
||||
|
||||
export const canUseDashboardSourceRevision = (options: {
|
||||
state: DashboardSourceRevisionState | null;
|
||||
slice: DashboardSourceSlice;
|
||||
knownContent?: string;
|
||||
knownSource?: string;
|
||||
forceSnapshot?: boolean;
|
||||
}): boolean =>
|
||||
options.forceSnapshot !== true &&
|
||||
options.state !== null &&
|
||||
options.state.coverageVersion >= DASHBOARD_SOURCE_REVISION_COVERAGE_VERSION &&
|
||||
options.knownContent !== undefined &&
|
||||
options.knownSource !== undefined &&
|
||||
options.knownSource === options.state.sourceRevisions[options.slice];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,7 @@ type NavigationFixture = {
|
||||
commandTableKind: string | null;
|
||||
boardAccessKind: string | null;
|
||||
}>;
|
||||
dashboardRequests?: DashboardBundleInput[];
|
||||
};
|
||||
|
||||
type JsonPatchOperation = {
|
||||
@@ -73,6 +74,7 @@ type JsonPatchOperation = {
|
||||
type DashboardBundleInput = {
|
||||
include?: { context?: boolean; commandTable?: boolean; boardAccess?: boolean };
|
||||
known?: { context?: string; commandTable?: string; boardAccess?: string };
|
||||
knownSource?: { context?: string; commandTable?: string; boardAccess?: string };
|
||||
forceSnapshot?: boolean;
|
||||
};
|
||||
|
||||
@@ -389,18 +391,22 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
);
|
||||
}
|
||||
const input = operationInput(route, index);
|
||||
(state.dashboardRequests ??= []).push(structuredClone(input));
|
||||
const include = input.include ?? {};
|
||||
const forceSnapshot = input.forceSnapshot === true;
|
||||
if (forceSnapshot) state.forceSnapshotCalls = (state.forceSnapshotCalls ?? 0) + 1;
|
||||
const revision = contextRevision(state);
|
||||
const context = include.context
|
||||
? deltaSlice(
|
||||
generalContext(state),
|
||||
revision,
|
||||
input.known?.context,
|
||||
forceSnapshot,
|
||||
state.contextOperations
|
||||
)
|
||||
? {
|
||||
...deltaSlice(
|
||||
generalContext(state),
|
||||
revision,
|
||||
input.known?.context,
|
||||
forceSnapshot,
|
||||
state.contextOperations
|
||||
),
|
||||
sourceRevision: revision,
|
||||
}
|
||||
: undefined;
|
||||
const currentCommandTableRevision = state.commandTableRevision ?? COMMAND_TABLE_REVISION;
|
||||
const commandTable = include.commandTable
|
||||
@@ -408,6 +414,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
? {
|
||||
kind: 'snapshot' as const,
|
||||
revision: currentCommandTableRevision,
|
||||
sourceRevision: currentCommandTableRevision,
|
||||
data: commandTableFixture(
|
||||
state.largeCommandTable === true,
|
||||
state.commandBlockedCount,
|
||||
@@ -415,11 +422,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
),
|
||||
}
|
||||
: input.known.commandTable === currentCommandTableRevision
|
||||
? { kind: 'unchanged' as const, revision: currentCommandTableRevision }
|
||||
? {
|
||||
kind: 'unchanged' as const,
|
||||
revision: currentCommandTableRevision,
|
||||
sourceRevision: currentCommandTableRevision,
|
||||
}
|
||||
: {
|
||||
kind: 'patch' as const,
|
||||
baseRevision: input.known.commandTable,
|
||||
revision: currentCommandTableRevision,
|
||||
sourceRevision: currentCommandTableRevision,
|
||||
operations: state.commandTableOperations ?? [],
|
||||
}
|
||||
: undefined;
|
||||
@@ -428,13 +440,18 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
? {
|
||||
kind: 'snapshot' as const,
|
||||
revision: BOARD_ACCESS_REVISION,
|
||||
sourceRevision: BOARD_ACCESS_REVISION,
|
||||
data: {
|
||||
permission: state.permission,
|
||||
canMeeting: state.officerLevel >= 1,
|
||||
canSecret: state.permission >= 2,
|
||||
},
|
||||
}
|
||||
: { kind: 'unchanged' as const, revision: BOARD_ACCESS_REVISION }
|
||||
: {
|
||||
kind: 'unchanged' as const,
|
||||
revision: BOARD_ACCESS_REVISION,
|
||||
sourceRevision: BOARD_ACCESS_REVISION,
|
||||
}
|
||||
: undefined;
|
||||
return response({ context, commandTable, boardAccess });
|
||||
}
|
||||
@@ -2225,6 +2242,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
boardAccessKind: 'unchanged',
|
||||
});
|
||||
expect(realtimeBundle?.bytes).toBeLessThan(1_000);
|
||||
expect(
|
||||
state.dashboardRequests?.find(
|
||||
(request) =>
|
||||
request.forceSnapshot !== true &&
|
||||
request.include?.context === true &&
|
||||
request.known?.context === CONTEXT_INITIAL_REVISION
|
||||
)?.knownSource
|
||||
).toEqual({
|
||||
context: CONTEXT_INITIAL_REVISION,
|
||||
commandTable: COMMAND_TABLE_REVISION,
|
||||
boardAccess: BOARD_ACCESS_REVISION,
|
||||
});
|
||||
|
||||
const operationsBeforeSurvey = state.operations.length;
|
||||
await page.evaluate(() => {
|
||||
@@ -2248,6 +2277,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
await expect
|
||||
.poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 })
|
||||
.toEqual(['dashboard.getContextBundleDelta', 'general.getFrontStatus']);
|
||||
expect(state.dashboardRequests?.at(-1)?.knownSource?.context).toBe('EEEEEEEEEEEEEEEEEEEEEE');
|
||||
|
||||
const profile = await page.evaluate(() => {
|
||||
const probe = (
|
||||
|
||||
@@ -52,6 +52,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
contextRevision?: string | null;
|
||||
commandTableRevision?: string | null;
|
||||
boardAccessRevision?: string | null;
|
||||
contextSourceRevision?: string | null;
|
||||
commandTableSourceRevision?: string | null;
|
||||
boardAccessSourceRevision?: string | null;
|
||||
general?: PresentGeneralContext['general'] | null;
|
||||
city?: PresentGeneralContext['city'] | null;
|
||||
nation?: PresentGeneralContext['nation'] | null;
|
||||
@@ -120,6 +123,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
let contextRevision: string | null = null;
|
||||
let commandTableRevision: string | null = null;
|
||||
let boardAccessRevision: string | null = null;
|
||||
let contextSourceRevision: string | null = null;
|
||||
let commandTableSourceRevision: string | null = null;
|
||||
let boardAccessSourceRevision: string | null = null;
|
||||
|
||||
const messageDraftText = ref('');
|
||||
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
||||
@@ -346,6 +352,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
resetRecentRecords(null);
|
||||
commandTableRevision = null;
|
||||
boardAccessRevision = null;
|
||||
contextSourceRevision = null;
|
||||
commandTableSourceRevision = null;
|
||||
boardAccessSourceRevision = null;
|
||||
} else if (patch.contextSnapshot !== undefined) {
|
||||
contextSnapshot = patch.contextSnapshot;
|
||||
general.value = structurallyShare(general.value, patch.contextSnapshot.general);
|
||||
@@ -368,6 +377,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
contextRevision = null;
|
||||
commandTableRevision = null;
|
||||
boardAccessRevision = null;
|
||||
contextSourceRevision = null;
|
||||
commandTableSourceRevision = null;
|
||||
boardAccessSourceRevision = null;
|
||||
} else if (patch.general !== undefined) {
|
||||
general.value = structurallyShare(general.value, patch.general);
|
||||
}
|
||||
@@ -418,9 +430,24 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
} else if (patch.frontStatus !== undefined) {
|
||||
updateFrontStatus(patch.frontStatus);
|
||||
}
|
||||
if (patch.contextRevision !== undefined) contextRevision = patch.contextRevision;
|
||||
if (patch.commandTableRevision !== undefined) commandTableRevision = patch.commandTableRevision;
|
||||
if (patch.boardAccessRevision !== undefined) boardAccessRevision = patch.boardAccessRevision;
|
||||
if (patch.contextRevision !== undefined) {
|
||||
contextRevision = patch.contextRevision;
|
||||
contextSourceRevision = patch.contextSourceRevision ?? null;
|
||||
} else if (patch.contextSourceRevision !== undefined) {
|
||||
contextSourceRevision = patch.contextSourceRevision;
|
||||
}
|
||||
if (patch.commandTableRevision !== undefined) {
|
||||
commandTableRevision = patch.commandTableRevision;
|
||||
commandTableSourceRevision = patch.commandTableSourceRevision ?? null;
|
||||
} else if (patch.commandTableSourceRevision !== undefined) {
|
||||
commandTableSourceRevision = patch.commandTableSourceRevision;
|
||||
}
|
||||
if (patch.boardAccessRevision !== undefined) {
|
||||
boardAccessRevision = patch.boardAccessRevision;
|
||||
boardAccessSourceRevision = patch.boardAccessSourceRevision ?? null;
|
||||
} else if (patch.boardAccessSourceRevision !== undefined) {
|
||||
boardAccessSourceRevision = patch.boardAccessSourceRevision;
|
||||
}
|
||||
};
|
||||
|
||||
const currentDashboardPatch = (): DashboardReadModelPatch => {
|
||||
@@ -429,6 +456,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
patch.contextRevision = contextRevision;
|
||||
patch.commandTableRevision = commandTableRevision;
|
||||
patch.boardAccessRevision = boardAccessRevision;
|
||||
patch.contextSourceRevision = contextSourceRevision;
|
||||
patch.commandTableSourceRevision = commandTableSourceRevision;
|
||||
patch.boardAccessSourceRevision = boardAccessSourceRevision;
|
||||
patch.general = toRaw(general.value);
|
||||
patch.city = toRaw(city.value);
|
||||
patch.nation = toRaw(nation.value);
|
||||
@@ -455,6 +485,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (bundle.context) {
|
||||
const applied = applyReadModelDelta(contextSnapshot, contextRevision, bundle.context);
|
||||
patch.contextRevision = applied.revision;
|
||||
patch.contextSourceRevision = applied.sourceRevision ?? null;
|
||||
if (bundle.context.kind !== 'unchanged') {
|
||||
patch.contextSnapshot = applied.data;
|
||||
}
|
||||
@@ -462,6 +493,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (bundle.commandTable) {
|
||||
const applied = applyReadModelDelta(commandTableSnapshot, commandTableRevision, bundle.commandTable);
|
||||
patch.commandTableRevision = applied.revision;
|
||||
patch.commandTableSourceRevision = applied.sourceRevision ?? null;
|
||||
if (bundle.commandTable.kind !== 'unchanged') {
|
||||
patch.commandTable = applied.data;
|
||||
}
|
||||
@@ -469,6 +501,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (bundle.boardAccess) {
|
||||
const applied = applyReadModelDelta(boardAccessSnapshot, boardAccessRevision, bundle.boardAccess);
|
||||
patch.boardAccessRevision = applied.revision;
|
||||
patch.boardAccessSourceRevision = applied.sourceRevision ?? null;
|
||||
if (bundle.boardAccess.kind !== 'unchanged') {
|
||||
patch.boardAccess = applied.data;
|
||||
}
|
||||
@@ -491,6 +524,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
...(commandTableRevision ? { commandTable: commandTableRevision } : {}),
|
||||
...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}),
|
||||
},
|
||||
knownSource: force
|
||||
? undefined
|
||||
: {
|
||||
...(contextSourceRevision ? { context: contextSourceRevision } : {}),
|
||||
...(commandTableSourceRevision ? { commandTable: commandTableSourceRevision } : {}),
|
||||
...(boardAccessSourceRevision ? { boardAccess: boardAccessSourceRevision } : {}),
|
||||
},
|
||||
forceSnapshot: force || undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -293,9 +293,11 @@ manual mutation/명시적 제한 endpoint는 기존 server-side gate를 계속
|
||||
값이고 내부 ID vector 자체를 browser에 내보내지 않는다.
|
||||
|
||||
```ts
|
||||
type KnownDashboardRevision = {
|
||||
content?: string;
|
||||
source?: string;
|
||||
type DashboardDeltaInput = {
|
||||
// 기존 client가 보내는 content revision 계약을 유지한다.
|
||||
known?: Partial<Record<'context' | 'commandTable' | 'boardAccess', string>>;
|
||||
// revision-first를 이해하는 client만 별도로 보낸다.
|
||||
knownSource?: Partial<Record<'context' | 'commandTable' | 'boardAccess', string>>;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -313,6 +315,23 @@ API는 access gate에서 얻은 현재 general/city/nation identity로 slice dep
|
||||
- general의 소속·위치가 바뀌면 `general.content`가 먼저 mismatch되므로 새 identity로
|
||||
projection과 source revision을 다시 만든다.
|
||||
|
||||
현재 dashboard private-slice 구현은 context에
|
||||
`general.content/city.content/nation.content/world.content/access.general`, command table에
|
||||
`general.content/city.content/nation.content/world.content`, board access에
|
||||
`general.content/nation.content`를 사용한다. source hash에는 dependency-vector code version을
|
||||
포함하고 browser에는 22자 base64url hash만 반환한다. access gate가 확보한 general ID를
|
||||
기준으로 actor city/nation, coverage와 revision head를 payload loader보다 먼저 한 SQL로 읽는다.
|
||||
coverage가 낮거나 meta/actor row가 없거나 query/result가 잘못되면 source hash를 authority로
|
||||
사용하지 않고 기존 content 계산으로 복구한다.
|
||||
|
||||
단, 위 vector는 현재 Phase C protocol 구현 범위이며 아직 activation-safe한 전체 producer
|
||||
coverage가 아니다. `getGeneralContext()`는 troop/leader turn, 국가 도시·장수 aggregate,
|
||||
top chiefs와 인증 계정 icon 등 own general/city/nation row 밖의 값을 읽고,
|
||||
`getTurnCommandTable()`은 전체 city/nation/general option 목록을 읽는다. 이 transitive
|
||||
dependency의 revision key와 모든 writer mark/reconciliation이 끝나기 전에는
|
||||
`read_model_revision_meta.coverage_version`을 반드시 0으로 유지한다. 현재 migration/runtime도
|
||||
0이며, unit test의 coverage 1 fixture는 fast-path 기계적 계약만 검증할 뿐 활성화 근거가 아니다.
|
||||
|
||||
### shared projection
|
||||
|
||||
- world map base cache key는 Redis에서 best-effort로 증가한 값이 아니라 DB의
|
||||
|
||||
@@ -11,16 +11,19 @@ export type ReadModelDelta<T> =
|
||||
| {
|
||||
kind: 'snapshot';
|
||||
revision: string;
|
||||
sourceRevision?: string;
|
||||
data: T;
|
||||
}
|
||||
| {
|
||||
kind: 'unchanged';
|
||||
revision: string;
|
||||
sourceRevision?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'patch';
|
||||
baseRevision: string;
|
||||
revision: string;
|
||||
sourceRevision?: string;
|
||||
operations: JsonPatchOperation[];
|
||||
};
|
||||
|
||||
@@ -41,6 +44,7 @@ export class ReadModelDeltaApplyError extends Error {
|
||||
export interface AppliedReadModelDelta<T> {
|
||||
data: T;
|
||||
revision: string;
|
||||
sourceRevision?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,6 +147,7 @@ export const applyReadModelDelta = <T>(
|
||||
return {
|
||||
data: delta.data,
|
||||
revision: delta.revision,
|
||||
...(delta.sourceRevision ? { sourceRevision: delta.sourceRevision } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,6 +164,7 @@ export const applyReadModelDelta = <T>(
|
||||
return {
|
||||
data: current,
|
||||
revision: currentRevision,
|
||||
...(delta.sourceRevision ? { sourceRevision: delta.sourceRevision } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,5 +191,6 @@ export const applyReadModelDelta = <T>(
|
||||
return {
|
||||
data: next,
|
||||
revision: delta.revision,
|
||||
...(delta.sourceRevision ? { sourceRevision: delta.sourceRevision } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -118,6 +118,21 @@ describe('applyReadModelDelta', () => {
|
||||
expect(applied.data).toBe(current);
|
||||
});
|
||||
|
||||
it('carries an optional source revision without changing content revision semantics', () => {
|
||||
const current = { value: 1 };
|
||||
const applied = applyReadModelDelta(current, 'revision-1', {
|
||||
kind: 'unchanged',
|
||||
revision: 'revision-1',
|
||||
sourceRevision: 'source-revision-2',
|
||||
});
|
||||
|
||||
expect(applied).toEqual({
|
||||
data: current,
|
||||
revision: 'revision-1',
|
||||
sourceRevision: 'source-revision-2',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a patch based on a different snapshot', () => {
|
||||
expect(() =>
|
||||
applyReadModelDelta({ value: 1 }, 'revision-2', {
|
||||
|
||||
Reference in New Issue
Block a user