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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user