diff --git a/app/game-api/src/router/dashboard/index.ts b/app/game-api/src/router/dashboard/index.ts index 1f463c33..17f4e0e1 100644 --- a/app/game-api/src/router/dashboard/index.ts +++ b/app/game-api/src/router/dashboard/index.ts @@ -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 (options: { + included: boolean; + sourceState: DashboardSourceRevisionState | null; + slice: DashboardSourceSlice; + knownContent?: string; + knownSource?: string; + forceSnapshot?: boolean; + load: () => Promise; + create: (value: T) => Promise>; +}): Promise | 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 { diff --git a/app/game-api/src/services/dashboardSourceRevision.ts b/app/game-api/src/services/dashboardSourceRevision.ts new file mode 100644 index 00000000..28ba384f --- /dev/null +++ b/app/game-api/src/services/dashboardSourceRevision.ts @@ -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; +} + +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 => { + 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, + generalId: number +): Promise => { + if (!Number.isSafeInteger(generalId) || generalId <= 0) { + return null; + } + + let rows: DashboardSourceRevisionRow[]; + try { + rows = await db.$queryRaw(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]; diff --git a/app/game-api/test/dashboardRouter.test.ts b/app/game-api/test/dashboardRouter.test.ts index f1f23351..b2f5a1a8 100644 --- a/app/game-api/test/dashboardRouter.test.ts +++ b/app/game-api/test/dashboardRouter.test.ts @@ -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) => { + 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); }); }); diff --git a/app/game-api/test/dashboardSourceRevision.test.ts b/app/game-api/test/dashboardSourceRevision.test.ts new file mode 100644 index 00000000..56674036 --- /dev/null +++ b/app/game-api/test/dashboardSourceRevision.test.ts @@ -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 = {}) => ({ + 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, 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; + await expect(readDashboardSourceRevisionState(db, 7)).resolves.toBeNull(); + }); +}); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 9f88cdb9..07bdd257 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -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 = ( diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 6b5fc236..35979a96 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -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(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, }); diff --git a/docs/architecture/realtime-change-journal.md b/docs/architecture/realtime-change-journal.md index 000b3be8..2c44ba9c 100644 --- a/docs/architecture/realtime-change-journal.md +++ b/docs/architecture/realtime-change-journal.md @@ -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>; + // revision-first를 이해하는 client만 별도로 보낸다. + knownSource?: Partial>; }; ``` @@ -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의 diff --git a/packages/common/src/realtime/delta.ts b/packages/common/src/realtime/delta.ts index 22fd9a08..b683ac9a 100644 --- a/packages/common/src/realtime/delta.ts +++ b/packages/common/src/realtime/delta.ts @@ -11,16 +11,19 @@ export type ReadModelDelta = | { 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 { data: T; revision: string; + sourceRevision?: string; } /** @@ -143,6 +147,7 @@ export const applyReadModelDelta = ( return { data: delta.data, revision: delta.revision, + ...(delta.sourceRevision ? { sourceRevision: delta.sourceRevision } : {}), }; } @@ -159,6 +164,7 @@ export const applyReadModelDelta = ( return { data: current, revision: currentRevision, + ...(delta.sourceRevision ? { sourceRevision: delta.sourceRevision } : {}), }; } @@ -185,5 +191,6 @@ export const applyReadModelDelta = ( return { data: next, revision: delta.revision, + ...(delta.sourceRevision ? { sourceRevision: delta.sourceRevision } : {}), }; }; diff --git a/packages/common/test/realtimeDelta.test.ts b/packages/common/test/realtimeDelta.test.ts index c68932ee..833e53eb 100644 --- a/packages/common/test/realtimeDelta.test.ts +++ b/packages/common/test/realtimeDelta.test.ts @@ -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', {