From 1a77bb3df10d37903523dab085ba021c03372547 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 16 Aug 2026 18:51:17 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20dashboard=20source=20coverage=EB=A5=BC?= =?UTF-8?q?=20=EC=95=88=EC=A0=84=ED=95=98=EA=B2=8C=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔진의 부대·예약턴·집계 변화와 mailbox writer를 저널에 연결하고 dashboard.global 및 인증 아이콘 source를 dependency vector에 포함한다. post-deploy coverage v1 활성화는 shared head seed와 CAS를 한 transaction으로 수행한다. --- app/game-api/src/router/dashboard/index.ts | 7 +- app/game-api/src/router/turns/index.ts | 4 + .../src/services/dashboardSourceRevision.ts | 59 +++++++++++++-- app/game-api/test/dashboardRouter.test.ts | 1 + .../test/dashboardSourceRevision.test.ts | 52 ++++++++++++- app/game-api/test/router.test.ts | 5 +- app/game-engine/src/turn/databaseHooks.ts | 34 ++++++++- .../src/turn/unificationPersistence.ts | 25 +++++-- ...angeJournalPersistence.integration.test.ts | 2 + .../test/realtimeReadModelChanges.test.ts | 46 ++++++++++++ .../test/unificationPersistence.test.ts | 2 + packages/infra/package.json | 1 + .../scripts/activate-read-model-coverage.mjs | 41 +++++++++++ packages/infra/src/index.ts | 1 + .../infra/src/readModelCoverageActivation.ts | 73 +++++++++++++++++++ ...odelCoverageActivation.integration.test.ts | 73 +++++++++++++++++++ 16 files changed, 404 insertions(+), 22 deletions(-) create mode 100644 packages/infra/scripts/activate-read-model-coverage.mjs create mode 100644 packages/infra/src/readModelCoverageActivation.ts create mode 100644 packages/infra/test/readModelCoverageActivation.integration.test.ts diff --git a/app/game-api/src/router/dashboard/index.ts b/app/game-api/src/router/dashboard/index.ts index 17f4e0e1..2df96263 100644 --- a/app/game-api/src/router/dashboard/index.ts +++ b/app/game-api/src/router/dashboard/index.ts @@ -82,8 +82,9 @@ const createDashboardSliceDelta = async (options: { export const dashboardRouter = router({ getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => { - const viewerId = ctx.auth?.user.id; - if (!viewerId) { + const authUser = ctx.auth?.user; + const viewerId = authUser?.id; + if (!authUser || !viewerId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } @@ -102,7 +103,7 @@ export const dashboardRouter = router({ null; } const sourceState = generalId - ? await readDashboardSourceRevisionState(ctx.db, generalId) + ? await readDashboardSourceRevisionState(ctx.db, generalId, authUser) : null; const [contextDelta, commandTableDelta, boardAccessDelta] = await Promise.all([ diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index aad0d1ea..b43a18b8 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -347,6 +347,7 @@ export const turnsRouter = router({ setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision) ); ctx.changeJournal?.mark('reserved.general', input.generalId); + ctx.changeJournal?.mark('dashboard.global'); return { ok: true, ...snapshot }; }), shiftGeneral: authedProcedure @@ -364,6 +365,7 @@ export const turnsRouter = router({ shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision) ); ctx.changeJournal?.mark('reserved.general', input.generalId); + ctx.changeJournal?.mark('dashboard.global'); return { ok: true, ...snapshot }; }), repeatGeneral: authedProcedure @@ -380,6 +382,7 @@ export const turnsRouter = router({ repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision) ); ctx.changeJournal?.mark('reserved.general', input.generalId); + ctx.changeJournal?.mark('dashboard.global'); return { ok: true, ...snapshot }; }), setGeneralBulk: authedProcedure @@ -407,6 +410,7 @@ export const turnsRouter = router({ setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision) ); ctx.changeJournal?.mark('reserved.general', input.generalId); + ctx.changeJournal?.mark('dashboard.global'); return { ok: true, ...snapshot }; }), setNation: authedProcedure diff --git a/app/game-api/src/services/dashboardSourceRevision.ts b/app/game-api/src/services/dashboardSourceRevision.ts index 28ba384f..0ca57571 100644 --- a/app/game-api/src/services/dashboardSourceRevision.ts +++ b/app/game-api/src/services/dashboardSourceRevision.ts @@ -12,7 +12,7 @@ import type { DatabaseClient } from '../context.js'; export const DASHBOARD_SOURCE_REVISION_COVERAGE_VERSION = 1; const SOURCE_REVISION_LENGTH = 22; -const SOURCE_REVISION_CODE_VERSION = 'dashboard-private-slices-v1'; +const SOURCE_REVISION_CODE_VERSION = 'dashboard-private-slices-v2'; export type DashboardSourceSlice = 'context' | 'commandTable' | 'boardAccess'; @@ -31,6 +31,7 @@ interface DashboardSourceRevisionRow { cityId: number; nationId: number; coverageVersion: number; + globalRevision: bigint; generalRevision: bigint; cityRevision: bigint; nationRevision: bigint; @@ -40,12 +41,25 @@ interface DashboardSourceRevisionRow { type RevisionTuple = readonly [domain: string, entityId: number, revision: string]; type DashboardRevisionVector = { + global: string; general: string; city: string; nation: string; world: string; access: string; }; + +export interface DashboardAuthSource { + iconUpdatedAt?: string; + profileIconResetAt?: string; + canUseGeneralPicture?: boolean; + icons?: readonly { + id: string; + picture: string; + imageServer: number; + createdAt: string; + }[]; +} type ParsedDashboardRevisionVector = { [Key in keyof DashboardRevisionVector]: DashboardRevisionVector[Key] | null; }; @@ -76,23 +90,46 @@ const digestSourceRevision = (slice: DashboardSourceSlice, dependencies: readonl .digest('base64url') .slice(0, SOURCE_REVISION_LENGTH); +export const createDashboardAuthSourceRevision = (source: DashboardAuthSource | undefined): string => + createHash('sha256') + .update( + JSON.stringify([ + SOURCE_REVISION_CODE_VERSION, + 'auth.context', + source?.canUseGeneralPicture !== false, + source?.iconUpdatedAt ?? null, + source?.profileIconResetAt ?? null, + (source?.icons ?? []).map(({ id, picture, imageServer, createdAt }) => [ + id, + picture, + imageServer, + createdAt, + ]), + ]) + ) + .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 + revisions: DashboardRevisionVector, + authSourceRevision: string ): Record => { + const global = ['dashboard.global', 0, revisions.global] as const; 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; + const auth = ['auth.context', 0, authSourceRevision] as const; return { - context: digestSourceRevision('context', [general, city, nation, world, access]), - commandTable: digestSourceRevision('commandTable', [general, city, nation, world]), + context: digestSourceRevision('context', [global, general, city, nation, world, access, auth]), + commandTable: digestSourceRevision('commandTable', [global, general, city, nation, world]), boardAccess: digestSourceRevision('boardAccess', [general, nation]), }; }; @@ -104,7 +141,8 @@ const buildSourceRevisions = ( */ export const readDashboardSourceRevisionState = async ( db: Pick, - generalId: number + generalId: number, + authSource?: DashboardAuthSource ): Promise => { if (!Number.isSafeInteger(generalId) || generalId <= 0) { return null; @@ -118,6 +156,7 @@ export const readDashboardSourceRevisionState = async ( actor."city_id" AS "cityId", actor."nation_id" AS "nationId", meta."coverage_version" AS "coverageVersion", + COALESCE(global_revision."revision", 0) AS "globalRevision", COALESCE(general_revision."revision", 0) AS "generalRevision", COALESCE(city_revision."revision", 0) AS "cityRevision", COALESCE(nation_revision."revision", 0) AS "nationRevision", @@ -125,6 +164,9 @@ export const readDashboardSourceRevisionState = async ( 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 global_revision + ON global_revision."domain" = 'dashboard.global' + AND global_revision."entity_id" = 0 LEFT JOIN "read_model_revision" AS general_revision ON general_revision."domain" = 'general.content' AND general_revision."entity_id" = actor."id" @@ -159,6 +201,7 @@ export const readDashboardSourceRevisionState = async ( }; const coverageVersion = parseNonNegativeInteger(row.coverageVersion); const revisions = { + global: parseNonNegativeRevision(row.globalRevision), general: parseNonNegativeRevision(row.generalRevision), city: parseNonNegativeRevision(row.cityRevision), nation: parseNonNegativeRevision(row.nationRevision), @@ -183,7 +226,11 @@ export const readDashboardSourceRevisionState = async ( return { coverageVersion, identity: validIdentity, - sourceRevisions: buildSourceRevisions(validIdentity, revisions), + sourceRevisions: buildSourceRevisions( + validIdentity, + revisions, + createDashboardAuthSourceRevision(authSource) + ), }; }; diff --git a/app/game-api/test/dashboardRouter.test.ts b/app/game-api/test/dashboardRouter.test.ts index b2f5a1a8..a44468b5 100644 --- a/app/game-api/test/dashboardRouter.test.ts +++ b/app/game-api/test/dashboardRouter.test.ts @@ -117,6 +117,7 @@ const installSourceRevisionState = ( cityId: 0, nationId: 0, coverageVersion: 1, + globalRevision: 1n, generalRevision: 1n, cityRevision: 0n, nationRevision: 0n, diff --git a/app/game-api/test/dashboardSourceRevision.test.ts b/app/game-api/test/dashboardSourceRevision.test.ts index 56674036..5787bba6 100644 --- a/app/game-api/test/dashboardSourceRevision.test.ts +++ b/app/game-api/test/dashboardSourceRevision.test.ts @@ -8,6 +8,7 @@ const row = (overrides: Record = {}) => ({ cityId: 3, nationId: 2, coverageVersion: 1, + globalRevision: 10n, generalRevision: 11n, cityRevision: 12n, nationRevision: 13n, @@ -16,9 +17,13 @@ const row = (overrides: Record = {}) => ({ ...overrides, }); -const read = async (value: unknown) => { +const read = async (value: unknown, authSource?: Parameters[2]) => { const queryRaw = vi.fn(async (_query: unknown) => value); - const state = await readDashboardSourceRevisionState({ $queryRaw: queryRaw } as Pick, 7); + const state = await readDashboardSourceRevisionState( + { $queryRaw: queryRaw } as Pick, + 7, + authSource + ); return { queryRaw, state }; }; @@ -26,6 +31,7 @@ describe('dashboard source revision', () => { it('uses zero for missing revision rows and returns opaque 22-character hashes', async () => { const { queryRaw, state } = await read([ row({ + globalRevision: 0n, generalRevision: 0n, cityRevision: 0n, nationRevision: 0n, @@ -41,19 +47,23 @@ describe('dashboard source revision', () => { 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); + expect(statement.sql.match(/COALESCE\([^)]*\."revision", 0\)/gu)).toHaveLength(6); }); it('hashes exactly the documented context, command, and board dependency vectors', async () => { const initial = (await read([row()])).state; + const globalChanged = (await read([row({ globalRevision: 99n })])).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) { + if (!initial || !globalChanged || !cityChanged || !accessChanged || !worldChanged || !nationChanged) { throw new Error('source revision state missing'); } + expect(globalChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context); + expect(globalChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable); + expect(globalChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess); 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); @@ -68,6 +78,40 @@ describe('dashboard source revision', () => { expect(nationChanged.sourceRevisions.boardAccess).not.toBe(initial.sourceRevisions.boardAccess); }); + it('includes only the authenticated icon projection in the context source', async () => { + const initial = ( + await read([row()], { + canUseGeneralPicture: true, + icons: [ + { + id: 'icon-1', + picture: 'icon-a.png', + imageServer: 1, + createdAt: '2026-08-16T00:00:00.000Z', + }, + ], + }) + ).state; + const changed = ( + await read([row()], { + canUseGeneralPicture: false, + icons: [ + { + id: 'icon-1', + picture: 'icon-a.png', + imageServer: 1, + createdAt: '2026-08-16T00:00:00.000Z', + }, + ], + }) + ).state; + if (!initial || !changed) throw new Error('source revision state missing'); + + expect(changed.sourceRevisions.context).not.toBe(initial.sourceRevisions.context); + expect(changed.sourceRevisions.commandTable).toBe(initial.sourceRevisions.commandTable); + expect(changed.sourceRevisions.boardAccess).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 }); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 7653d32f..f99f2ab4 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -894,7 +894,10 @@ describe('appRouter', () => { actionCode: 'che_화계', arg: { destCityId: 7 }, }); - expect(changeJournal.snapshot()).toEqual([{ domain: 'reserved.general', entityId: 13 }]); + expect(changeJournal.snapshot()).toEqual([ + { domain: 'dashboard.global', entityId: 0 }, + { domain: 'reserved.general', entityId: 13 }, + ]); await expect( caller.turns.reserved.setGeneral({ diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index bbc99cc6..65dc7eea 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -463,6 +463,31 @@ export const createReadModelChangeJournal = (changes: RealtimeReadModelChanges): return journal; }; +/** + * Conservative transitive dependency for the private dashboard projections. + * Context reads troop/leader-turn state and command options scan the complete + * general/city/nation sets, so entity-local heads alone are insufficient. + */ +export const hasDashboardSourceMutation = ( + changes: TurnWorldChanges, + readModelChanges: RealtimeReadModelChanges +): boolean => + readModelChanges.worldChanged || + readModelChanges.reservedGeneralIds.length > 0 || + changes.generals.length > 0 || + changes.createdGenerals.length > 0 || + changes.deletedGenerals.length > 0 || + changes.lifecycleEvents.length > 0 || + changes.cities.length > 0 || + changes.nations.length > 0 || + changes.createdNations.length > 0 || + changes.deletedNations.length > 0 || + changes.troops.length > 0 || + changes.createdTroops.length > 0 || + changes.deletedTroops.length > 0 || + changes.diplomacy.length > 0 || + changes.createdDiplomacy.length > 0; + export const excludeDeletedReservedTurnQueues = ( changes: ReservedTurnChanges, deletedGeneralIds: readonly number[], @@ -1463,13 +1488,15 @@ export const createDatabaseTurnHooks = async ( for (const snapshot of pendingYearbookSnapshots) { await persistYearbookSnapshot(prisma, snapshot); } + const persistedMessageMailboxes: number[] = []; for (const finalization of pendingUnificationFinalizations) { if (options?.profileName && finalization.profileName !== options.profileName) { throw new Error( `Unification profile mismatch: pending=${finalization.profileName}, daemon=${options.profileName}.` ); } - await persistUnificationFinalization(prisma, finalization, world); + const result = await persistUnificationFinalization(prisma, finalization, world); + persistedMessageMailboxes.push(...result.messageMailboxes); } for (const message of messages) { await sendMessage( @@ -1506,6 +1533,7 @@ export const createDatabaseTurnHooks = async ( if (!id) { throw new Error('Failed to persist turn message.'); } + persistedMessageMailboxes.push(draft.mailbox); return id; }, }, @@ -1555,6 +1583,10 @@ export const createDatabaseTurnHooks = async ( readModelChanges.worldChanged = true; } const journal = createReadModelChangeJournal(readModelChanges); + if (hasDashboardSourceMutation(changes, readModelChanges)) { + journal.mark('dashboard.global'); + } + markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes)); markIds(journal, 'access.general', accessScoreResetGeneralIds); if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) { journal.mark('betting'); diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index fa0b8776..ef057a4d 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -24,6 +24,7 @@ export interface UnificationFinalizationInput { export interface UnificationFinalizationResult { status: 'APPLIED' | 'ALREADY_APPLIED'; generationKey: string; + messageMailboxes: number[]; } const readNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0); @@ -140,7 +141,8 @@ const cancelPendingUniqueAuctions = async ( transaction: GamePrisma.TransactionClient, input: UnificationFinalizationInput, world: InMemoryTurnWorld -): Promise => { +): Promise => { + const messageMailboxes: number[] = []; const lockedRows = await transaction.$queryRaw` SELECT auction.id AS "auctionId", @@ -245,9 +247,17 @@ const cancelPendingUniqueAuctions = async ( time: input.completedAt, validUntil: new Date('9999-12-31T00:00:00.000Z'), }; - await sendMessage({ insertMessage: (draft) => insertMessage(transaction, draft) }, message, { - sendDestOnly: true, - }); + await sendMessage( + { + insertMessage: async (draft) => { + const messageId = await insertMessage(transaction, draft); + messageMailboxes.push(draft.mailbox); + return messageId; + }, + }, + message, + { sendDestOnly: true } + ); } await transaction.auction.update({ @@ -255,6 +265,7 @@ const cancelPendingUniqueAuctions = async ( data: { status: 'CANCELED', finishedAt: input.completedAt }, }); } + return messageMailboxes; }; export const persistUnificationFinalization = async ( @@ -267,7 +278,7 @@ export const persistUnificationFinalization = async ( } const claim = await claimGeneration(transaction, input); if (claim === 'ALREADY_APPLIED') { - return { status: 'ALREADY_APPLIED', generationKey: input.generationKey }; + return { status: 'ALREADY_APPLIED', generationKey: input.generationKey, messageMailboxes: [] }; } const state = world.getState(); @@ -298,7 +309,7 @@ export const persistUnificationFinalization = async ( // Ref cancels and refunds every unfinished unique auction before it merges // inheritance. Keep that order inside the generation transaction. - await cancelPendingUniqueAuctions(transaction, input, world); + const messageMailboxes = await cancelPendingUniqueAuctions(transaction, input, world); const pointRows = eligibleGenerals.length ? await transaction.inheritancePoint.findMany({ @@ -655,5 +666,5 @@ export const persistUnificationFinalization = async ( }, }); - return { status: 'APPLIED', generationKey: input.generationKey }; + return { status: 'APPLIED', generationKey: input.generationKey, messageMailboxes }; }; diff --git a/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts b/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts index eeb686aa..6640a4f3 100644 --- a/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts +++ b/app/game-engine/test/readModelChangeJournalPersistence.integration.test.ts @@ -165,6 +165,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => { globalRecordsChanged: true, }); expect(stateAndLogReceipt?.invalidation.revisions).toEqual([ + { domain: 'dashboard.global', entityId: 0, revision: 1n }, { domain: 'map.world', entityId: 0, revision: 1n }, { domain: 'records.global', entityId: 0, revision: 1n }, { domain: 'world.content', entityId: 0, revision: 1n }, @@ -177,6 +178,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => { const monthReceipt = hooks.takeCommittedReadModelChangeReceipt(); expect(monthReceipt?.changes.worldChanged).toBe(true); expect(monthReceipt?.invalidation.revisions).toEqual([ + { domain: 'dashboard.global', entityId: 0, revision: 2n }, { domain: 'map.world', entityId: 0, revision: 2n }, { domain: 'world.content', entityId: 0, revision: 2n }, ]); diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index 83f07bbc..eb8f98e5 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -7,6 +7,7 @@ import { createReadModelChangeJournal, createRealtimeReadModelBaseline, createWorldReadModelSignature, + hasDashboardSourceMutation, mergePersistedVisibleLogChanges, summarizeRealtimeReadModelChanges, } from '../src/turn/databaseHooks.js'; @@ -59,6 +60,51 @@ describe('durable read-model change journal mapping', () => { ]); }); + it('detects troop, leader-turn, and aggregate dashboard dependencies conservatively', () => { + const emptyWorldChanges = { + accessScoreResetGeneralIds: [], + generals: [], + cities: [], + nations: [], + troops: [], + deletedTroops: [], + deletedGenerals: [], + deletedNations: [], + deletedNationSnapshots: [], + diplomacy: [], + logs: [], + messages: [], + createdGenerals: [], + createdNations: [], + createdTroops: [], + createdDiplomacy: [], + createdEvents: [], + deletedEvents: [], + lifecycleEvents: [], + pendingNeutralAuctions: [], + inheritancePointAdjustments: [], + pendingNationBettingOpens: [], + pendingNationBettingFinishes: [], + pendingYearbookSnapshots: [], + pendingUnificationFinalizations: [], + } satisfies TurnWorldChanges; + const readModelChanges = createEmptyRealtimeReadModelChanges(); + + expect(hasDashboardSourceMutation(emptyWorldChanges, readModelChanges)).toBe(false); + expect( + hasDashboardSourceMutation( + { ...emptyWorldChanges, troops: [{ id: 3 } as TurnWorldChanges['troops'][number]] }, + readModelChanges + ) + ).toBe(true); + expect( + hasDashboardSourceMutation(emptyWorldChanges, { + ...readModelChanges, + reservedGeneralIds: [7], + }) + ).toBe(true); + }); + it('ignores moving clock and lease metadata but detects month, turn-term, config, and gameplay meta', () => { const state = { currentYear: 190, diff --git a/app/game-engine/test/unificationPersistence.test.ts b/app/game-engine/test/unificationPersistence.test.ts index 7fd79b6e..7bd58307 100644 --- a/app/game-engine/test/unificationPersistence.test.ts +++ b/app/game-engine/test/unificationPersistence.test.ts @@ -175,6 +175,7 @@ describe('persistUnificationFinalization', () => { await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({ status: 'ALREADY_APPLIED', generationKey: input.generationKey, + messageMailboxes: [], }); expect(transaction.unificationFinalization.create).not.toHaveBeenCalled(); }); @@ -221,6 +222,7 @@ describe('persistUnificationFinalization', () => { await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({ status: 'APPLIED', generationKey: input.generationKey, + messageMailboxes: [], }); expect(inheritanceUpsert).toHaveBeenCalledWith( diff --git a/packages/infra/package.json b/packages/infra/package.json index b52a7cd0..e673dc31 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -21,6 +21,7 @@ "verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh", "verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh", "verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh", + "coverage:activate:game": "node scripts/activate-read-model-coverage.mjs", "prisma:db:push:game": "prisma db push --schema prisma/game.prisma", "prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma" }, diff --git a/packages/infra/scripts/activate-read-model-coverage.mjs b/packages/infra/scripts/activate-read-model-coverage.mjs new file mode 100644 index 00000000..7f0efd9d --- /dev/null +++ b/packages/infra/scripts/activate-read-model-coverage.mjs @@ -0,0 +1,41 @@ +import { + activateReadModelRevisionCoverage, + createGamePostgresConnector, + resolvePostgresConfigFromEnv, +} from '../dist/index.js'; + +const main = async () => { + const profile = process.env.READ_MODEL_COVERAGE_PROFILE?.trim(); + const expectedConfirm = profile ? `activate:${profile}:coverage-v1` : ''; + if (!profile || process.env.READ_MODEL_COVERAGE_CONFIRM !== expectedConfirm) { + throw new Error('confirmation'); + } + if (!/^[a-zA-Z0-9_-]+$/u.test(profile)) { + throw new Error('profile'); + } + + const connector = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: profile })); + try { + await connector.connect(); + const result = await connector.prisma.$transaction((transaction) => + activateReadModelRevisionCoverage(transaction) + ); + process.stdout.write( + `${JSON.stringify({ + profile, + previousVersion: result.previousVersion, + coverageVersion: result.coverageVersion, + seededHeads: result.seededHeads, + })}\n` + ); + } finally { + await connector.disconnect(); + } +}; + +await main().catch(() => { + process.stderr.write( + 'Read-model coverage activation failed. Check the profile confirmation, built infra package, database connectivity, and current coverage version.\n' + ); + process.exitCode = 1; +}); diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index 7286b6ca..ecee1325 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -8,3 +8,4 @@ export * from './redis.js'; export * from './turnEngineDb.js'; export * from './readModelChangeJournal.js'; export * from './readModelOutboxDispatcher.js'; +export * from './readModelCoverageActivation.js'; diff --git a/packages/infra/src/readModelCoverageActivation.ts b/packages/infra/src/readModelCoverageActivation.ts new file mode 100644 index 00000000..e03b7f05 --- /dev/null +++ b/packages/infra/src/readModelCoverageActivation.ts @@ -0,0 +1,73 @@ +import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; + +export const READ_MODEL_REVISION_COVERAGE_VERSION = 1; + +type CoverageDatabase = Pick; + +interface CoverageRow { + coverageVersion: number; +} + +export interface ReadModelCoverageActivationResult { + previousVersion: number; + coverageVersion: number; + seededHeads: number; +} + +/** + * Transaction-bound post-deploy activation. The caller must run this only after + * every writer for this binary version is deployed. It seeds shared heads + * without overwriting concurrent increments, then raises the authority gate. + */ +export const activateReadModelRevisionCoverage = async ( + transaction: CoverageDatabase, + expectedVersion = 0 +): Promise => { + if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0) { + throw new RangeError('Expected read-model coverage version must be a non-negative safe integer.'); + } + + await transaction.$executeRaw(GamePrisma.sql` + SELECT pg_advisory_xact_lock( + hashtext('read-model-revision-coverage'), + ${READ_MODEL_REVISION_COVERAGE_VERSION} + ) + `); + const rows = await transaction.$queryRaw(GamePrisma.sql` + SELECT "coverage_version" AS "coverageVersion" + FROM "read_model_revision_meta" + WHERE "id" = 1 + FOR UPDATE + `); + const current = rows.length === 1 ? rows[0]?.coverageVersion : undefined; + if (!Number.isSafeInteger(current) || (current !== expectedVersion && current !== READ_MODEL_REVISION_COVERAGE_VERSION)) { + throw new Error( + `Read-model coverage activation expected ${expectedVersion} or ${READ_MODEL_REVISION_COVERAGE_VERSION}, received ${String(current)}.` + ); + } + + const seededHeads = await transaction.$executeRaw(GamePrisma.sql` + INSERT INTO "read_model_revision" ("domain", "entity_id", "revision", "updated_at") + VALUES + ('dashboard.global', 0, 1, CURRENT_TIMESTAMP), + ('map.world', 0, 1, CURRENT_TIMESTAMP) + ON CONFLICT ("domain", "entity_id") DO NOTHING + `); + if (current !== READ_MODEL_REVISION_COVERAGE_VERSION) { + const updated = await transaction.$executeRaw(GamePrisma.sql` + UPDATE "read_model_revision_meta" + SET "coverage_version" = ${READ_MODEL_REVISION_COVERAGE_VERSION} + WHERE "id" = 1 + AND "coverage_version" = ${expectedVersion} + `); + if (updated !== 1) { + throw new Error('Read-model coverage activation lost its version compare-and-set.'); + } + } + + return { + previousVersion: current, + coverageVersion: READ_MODEL_REVISION_COVERAGE_VERSION, + seededHeads, + }; +}; diff --git a/packages/infra/test/readModelCoverageActivation.integration.test.ts b/packages/infra/test/readModelCoverageActivation.integration.test.ts new file mode 100644 index 00000000..4fa50cb7 --- /dev/null +++ b/packages/infra/test/readModelCoverageActivation.integration.test.ts @@ -0,0 +1,73 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { createGamePostgresConnector, type GamePrismaClient } from '../src/gamePrisma.js'; +import { activateReadModelRevisionCoverage } from '../src/readModelCoverageActivation.js'; + +const databaseUrl = process.env.READ_MODEL_JOURNAL_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); + +integration('read-model coverage activation PostgreSQL boundary', () => { + let disconnect: (() => Promise) | undefined; + let prisma: GamePrismaClient; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + prisma = connector.prisma; + disconnect = connector.disconnect; + await connector.connect(); + }); + + afterAll(async () => disconnect?.()); + + beforeEach(async () => { + await prisma.$transaction(async (transaction) => { + await transaction.readModelRevision.deleteMany(); + await transaction.readModelRevisionMeta.upsert({ + where: { id: 1 }, + create: { id: 1, coverageVersion: 0 }, + update: { coverageVersion: 0 }, + }); + }); + }); + + it('seeds shared heads and raises coverage in one idempotent transaction', async () => { + await prisma.readModelRevision.create({ + data: { domain: 'dashboard.global', entityId: 0, revision: 9n }, + }); + + await expect( + prisma.$transaction((transaction) => activateReadModelRevisionCoverage(transaction)) + ).resolves.toEqual({ previousVersion: 0, coverageVersion: 1, seededHeads: 1 }); + await expect( + prisma.$transaction((transaction) => activateReadModelRevisionCoverage(transaction)) + ).resolves.toEqual({ previousVersion: 1, coverageVersion: 1, seededHeads: 0 }); + + await expect(prisma.readModelRevisionMeta.findUniqueOrThrow({ where: { id: 1 } })).resolves.toMatchObject({ + coverageVersion: 1, + }); + await expect( + prisma.readModelRevision.findUniqueOrThrow({ + where: { domain_entityId: { domain: 'dashboard.global', entityId: 0 } }, + }) + ).resolves.toMatchObject({ revision: 9n }); + await expect( + prisma.readModelRevision.findUniqueOrThrow({ + where: { domain_entityId: { domain: 'map.world', entityId: 0 } }, + }) + ).resolves.toMatchObject({ revision: 1n }); + }); + + it('rolls seeded heads and coverage back with the owner transaction', async () => { + await expect( + prisma.$transaction(async (transaction) => { + await activateReadModelRevisionCoverage(transaction); + throw new Error('rollback activation fixture'); + }) + ).rejects.toThrow('rollback activation fixture'); + + await expect(prisma.readModelRevision.count()).resolves.toBe(0); + await expect(prisma.readModelRevisionMeta.findUniqueOrThrow({ where: { id: 1 } })).resolves.toMatchObject({ + coverageVersion: 0, + }); + }); +});