feat: dashboard source coverage를 안전하게 활성화
엔진의 부대·예약턴·집계 변화와 mailbox writer를 저널에 연결하고 dashboard.global 및 인증 아이콘 source를 dependency vector에 포함한다. post-deploy coverage v1 활성화는 shared head seed와 CAS를 한 transaction으로 수행한다.
This commit is contained in:
@@ -82,8 +82,9 @@ const createDashboardSliceDelta = async <T>(options: {
|
|||||||
|
|
||||||
export const dashboardRouter = router({
|
export const dashboardRouter = router({
|
||||||
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
|
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
|
||||||
const viewerId = ctx.auth?.user.id;
|
const authUser = ctx.auth?.user;
|
||||||
if (!viewerId) {
|
const viewerId = authUser?.id;
|
||||||
|
if (!authUser || !viewerId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ export const dashboardRouter = router({
|
|||||||
null;
|
null;
|
||||||
}
|
}
|
||||||
const sourceState = generalId
|
const sourceState = generalId
|
||||||
? await readDashboardSourceRevisionState(ctx.db, generalId)
|
? await readDashboardSourceRevisionState(ctx.db, generalId, authUser)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [contextDelta, commandTableDelta, boardAccessDelta] = await Promise.all([
|
const [contextDelta, commandTableDelta, boardAccessDelta] = await Promise.all([
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export const turnsRouter = router({
|
|||||||
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
|
setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision)
|
||||||
);
|
);
|
||||||
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||||
|
ctx.changeJournal?.mark('dashboard.global');
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
shiftGeneral: authedProcedure
|
shiftGeneral: authedProcedure
|
||||||
@@ -364,6 +365,7 @@ export const turnsRouter = router({
|
|||||||
shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
||||||
);
|
);
|
||||||
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||||
|
ctx.changeJournal?.mark('dashboard.global');
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
repeatGeneral: authedProcedure
|
repeatGeneral: authedProcedure
|
||||||
@@ -380,6 +382,7 @@ export const turnsRouter = router({
|
|||||||
repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision)
|
||||||
);
|
);
|
||||||
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||||
|
ctx.changeJournal?.mark('dashboard.global');
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
setGeneralBulk: authedProcedure
|
setGeneralBulk: authedProcedure
|
||||||
@@ -407,6 +410,7 @@ export const turnsRouter = router({
|
|||||||
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
|
||||||
);
|
);
|
||||||
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
ctx.changeJournal?.mark('reserved.general', input.generalId);
|
||||||
|
ctx.changeJournal?.mark('dashboard.global');
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
setNation: authedProcedure
|
setNation: authedProcedure
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { DatabaseClient } from '../context.js';
|
|||||||
export const DASHBOARD_SOURCE_REVISION_COVERAGE_VERSION = 1;
|
export const DASHBOARD_SOURCE_REVISION_COVERAGE_VERSION = 1;
|
||||||
|
|
||||||
const SOURCE_REVISION_LENGTH = 22;
|
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';
|
export type DashboardSourceSlice = 'context' | 'commandTable' | 'boardAccess';
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ interface DashboardSourceRevisionRow {
|
|||||||
cityId: number;
|
cityId: number;
|
||||||
nationId: number;
|
nationId: number;
|
||||||
coverageVersion: number;
|
coverageVersion: number;
|
||||||
|
globalRevision: bigint;
|
||||||
generalRevision: bigint;
|
generalRevision: bigint;
|
||||||
cityRevision: bigint;
|
cityRevision: bigint;
|
||||||
nationRevision: bigint;
|
nationRevision: bigint;
|
||||||
@@ -40,12 +41,25 @@ interface DashboardSourceRevisionRow {
|
|||||||
|
|
||||||
type RevisionTuple = readonly [domain: string, entityId: number, revision: string];
|
type RevisionTuple = readonly [domain: string, entityId: number, revision: string];
|
||||||
type DashboardRevisionVector = {
|
type DashboardRevisionVector = {
|
||||||
|
global: string;
|
||||||
general: string;
|
general: string;
|
||||||
city: string;
|
city: string;
|
||||||
nation: string;
|
nation: string;
|
||||||
world: string;
|
world: string;
|
||||||
access: string;
|
access: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface DashboardAuthSource {
|
||||||
|
iconUpdatedAt?: string;
|
||||||
|
profileIconResetAt?: string;
|
||||||
|
canUseGeneralPicture?: boolean;
|
||||||
|
icons?: readonly {
|
||||||
|
id: string;
|
||||||
|
picture: string;
|
||||||
|
imageServer: number;
|
||||||
|
createdAt: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
type ParsedDashboardRevisionVector = {
|
type ParsedDashboardRevisionVector = {
|
||||||
[Key in keyof DashboardRevisionVector]: DashboardRevisionVector[Key] | null;
|
[Key in keyof DashboardRevisionVector]: DashboardRevisionVector[Key] | null;
|
||||||
};
|
};
|
||||||
@@ -76,23 +90,46 @@ const digestSourceRevision = (slice: DashboardSourceSlice, dependencies: readonl
|
|||||||
.digest('base64url')
|
.digest('base64url')
|
||||||
.slice(0, SOURCE_REVISION_LENGTH);
|
.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 = (
|
const isCompleteRevisionVector = (
|
||||||
revisions: ParsedDashboardRevisionVector
|
revisions: ParsedDashboardRevisionVector
|
||||||
): revisions is DashboardRevisionVector => Object.values(revisions).every((revision) => revision !== null);
|
): revisions is DashboardRevisionVector => Object.values(revisions).every((revision) => revision !== null);
|
||||||
|
|
||||||
const buildSourceRevisions = (
|
const buildSourceRevisions = (
|
||||||
identity: DashboardSourceRevisionState['identity'],
|
identity: DashboardSourceRevisionState['identity'],
|
||||||
revisions: DashboardRevisionVector
|
revisions: DashboardRevisionVector,
|
||||||
|
authSourceRevision: string
|
||||||
): Record<DashboardSourceSlice, string> => {
|
): Record<DashboardSourceSlice, string> => {
|
||||||
|
const global = ['dashboard.global', 0, revisions.global] as const;
|
||||||
const general = ['general.content', identity.generalId, revisions.general] 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 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 nation = ['nation.content', identity.nationId, identity.nationId > 0 ? revisions.nation : '0'] as const;
|
||||||
const world = ['world.content', 0, revisions.world] as const;
|
const world = ['world.content', 0, revisions.world] as const;
|
||||||
const access = ['access.general', identity.generalId, revisions.access] as const;
|
const access = ['access.general', identity.generalId, revisions.access] as const;
|
||||||
|
const auth = ['auth.context', 0, authSourceRevision] as const;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context: digestSourceRevision('context', [general, city, nation, world, access]),
|
context: digestSourceRevision('context', [global, general, city, nation, world, access, auth]),
|
||||||
commandTable: digestSourceRevision('commandTable', [general, city, nation, world]),
|
commandTable: digestSourceRevision('commandTable', [global, general, city, nation, world]),
|
||||||
boardAccess: digestSourceRevision('boardAccess', [general, nation]),
|
boardAccess: digestSourceRevision('boardAccess', [general, nation]),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -104,7 +141,8 @@ const buildSourceRevisions = (
|
|||||||
*/
|
*/
|
||||||
export const readDashboardSourceRevisionState = async (
|
export const readDashboardSourceRevisionState = async (
|
||||||
db: Pick<DatabaseClient, '$queryRaw'>,
|
db: Pick<DatabaseClient, '$queryRaw'>,
|
||||||
generalId: number
|
generalId: number,
|
||||||
|
authSource?: DashboardAuthSource
|
||||||
): Promise<DashboardSourceRevisionState | null> => {
|
): Promise<DashboardSourceRevisionState | null> => {
|
||||||
if (!Number.isSafeInteger(generalId) || generalId <= 0) {
|
if (!Number.isSafeInteger(generalId) || generalId <= 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -118,6 +156,7 @@ export const readDashboardSourceRevisionState = async (
|
|||||||
actor."city_id" AS "cityId",
|
actor."city_id" AS "cityId",
|
||||||
actor."nation_id" AS "nationId",
|
actor."nation_id" AS "nationId",
|
||||||
meta."coverage_version" AS "coverageVersion",
|
meta."coverage_version" AS "coverageVersion",
|
||||||
|
COALESCE(global_revision."revision", 0) AS "globalRevision",
|
||||||
COALESCE(general_revision."revision", 0) AS "generalRevision",
|
COALESCE(general_revision."revision", 0) AS "generalRevision",
|
||||||
COALESCE(city_revision."revision", 0) AS "cityRevision",
|
COALESCE(city_revision."revision", 0) AS "cityRevision",
|
||||||
COALESCE(nation_revision."revision", 0) AS "nationRevision",
|
COALESCE(nation_revision."revision", 0) AS "nationRevision",
|
||||||
@@ -125,6 +164,9 @@ export const readDashboardSourceRevisionState = async (
|
|||||||
COALESCE(access_revision."revision", 0) AS "accessRevision"
|
COALESCE(access_revision."revision", 0) AS "accessRevision"
|
||||||
FROM "general" AS actor
|
FROM "general" AS actor
|
||||||
CROSS JOIN "read_model_revision_meta" AS meta
|
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
|
LEFT JOIN "read_model_revision" AS general_revision
|
||||||
ON general_revision."domain" = 'general.content'
|
ON general_revision."domain" = 'general.content'
|
||||||
AND general_revision."entity_id" = actor."id"
|
AND general_revision."entity_id" = actor."id"
|
||||||
@@ -159,6 +201,7 @@ export const readDashboardSourceRevisionState = async (
|
|||||||
};
|
};
|
||||||
const coverageVersion = parseNonNegativeInteger(row.coverageVersion);
|
const coverageVersion = parseNonNegativeInteger(row.coverageVersion);
|
||||||
const revisions = {
|
const revisions = {
|
||||||
|
global: parseNonNegativeRevision(row.globalRevision),
|
||||||
general: parseNonNegativeRevision(row.generalRevision),
|
general: parseNonNegativeRevision(row.generalRevision),
|
||||||
city: parseNonNegativeRevision(row.cityRevision),
|
city: parseNonNegativeRevision(row.cityRevision),
|
||||||
nation: parseNonNegativeRevision(row.nationRevision),
|
nation: parseNonNegativeRevision(row.nationRevision),
|
||||||
@@ -183,7 +226,11 @@ export const readDashboardSourceRevisionState = async (
|
|||||||
return {
|
return {
|
||||||
coverageVersion,
|
coverageVersion,
|
||||||
identity: validIdentity,
|
identity: validIdentity,
|
||||||
sourceRevisions: buildSourceRevisions(validIdentity, revisions),
|
sourceRevisions: buildSourceRevisions(
|
||||||
|
validIdentity,
|
||||||
|
revisions,
|
||||||
|
createDashboardAuthSourceRevision(authSource)
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ const installSourceRevisionState = (
|
|||||||
cityId: 0,
|
cityId: 0,
|
||||||
nationId: 0,
|
nationId: 0,
|
||||||
coverageVersion: 1,
|
coverageVersion: 1,
|
||||||
|
globalRevision: 1n,
|
||||||
generalRevision: 1n,
|
generalRevision: 1n,
|
||||||
cityRevision: 0n,
|
cityRevision: 0n,
|
||||||
nationRevision: 0n,
|
nationRevision: 0n,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const row = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
cityId: 3,
|
cityId: 3,
|
||||||
nationId: 2,
|
nationId: 2,
|
||||||
coverageVersion: 1,
|
coverageVersion: 1,
|
||||||
|
globalRevision: 10n,
|
||||||
generalRevision: 11n,
|
generalRevision: 11n,
|
||||||
cityRevision: 12n,
|
cityRevision: 12n,
|
||||||
nationRevision: 13n,
|
nationRevision: 13n,
|
||||||
@@ -16,9 +17,13 @@ const row = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const read = async (value: unknown) => {
|
const read = async (value: unknown, authSource?: Parameters<typeof readDashboardSourceRevisionState>[2]) => {
|
||||||
const queryRaw = vi.fn(async (_query: unknown) => value);
|
const queryRaw = vi.fn(async (_query: unknown) => value);
|
||||||
const state = await readDashboardSourceRevisionState({ $queryRaw: queryRaw } as Pick<DatabaseClient, '$queryRaw'>, 7);
|
const state = await readDashboardSourceRevisionState(
|
||||||
|
{ $queryRaw: queryRaw } as Pick<DatabaseClient, '$queryRaw'>,
|
||||||
|
7,
|
||||||
|
authSource
|
||||||
|
);
|
||||||
return { queryRaw, state };
|
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 () => {
|
it('uses zero for missing revision rows and returns opaque 22-character hashes', async () => {
|
||||||
const { queryRaw, state } = await read([
|
const { queryRaw, state } = await read([
|
||||||
row({
|
row({
|
||||||
|
globalRevision: 0n,
|
||||||
generalRevision: 0n,
|
generalRevision: 0n,
|
||||||
cityRevision: 0n,
|
cityRevision: 0n,
|
||||||
nationRevision: 0n,
|
nationRevision: 0n,
|
||||||
@@ -41,19 +47,23 @@ describe('dashboard source revision', () => {
|
|||||||
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 };
|
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 () => {
|
it('hashes exactly the documented context, command, and board dependency vectors', async () => {
|
||||||
const initial = (await read([row()])).state;
|
const initial = (await read([row()])).state;
|
||||||
|
const globalChanged = (await read([row({ globalRevision: 99n })])).state;
|
||||||
const cityChanged = (await read([row({ cityRevision: 99n })])).state;
|
const cityChanged = (await read([row({ cityRevision: 99n })])).state;
|
||||||
const accessChanged = (await read([row({ accessRevision: 99n })])).state;
|
const accessChanged = (await read([row({ accessRevision: 99n })])).state;
|
||||||
const worldChanged = (await read([row({ worldRevision: 99n })])).state;
|
const worldChanged = (await read([row({ worldRevision: 99n })])).state;
|
||||||
const nationChanged = (await read([row({ nationRevision: 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');
|
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.context).not.toBe(initial.sourceRevisions.context);
|
||||||
expect(cityChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
|
expect(cityChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
|
||||||
expect(cityChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
|
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);
|
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 () => {
|
it('rejects missing meta/actor rows, malformed values, and query failures', async () => {
|
||||||
await expect(read([])).resolves.toMatchObject({ state: null });
|
await expect(read([])).resolves.toMatchObject({ state: null });
|
||||||
await expect(read([row({ coverageVersion: -1 })])).resolves.toMatchObject({ state: null });
|
await expect(read([row({ coverageVersion: -1 })])).resolves.toMatchObject({ state: null });
|
||||||
|
|||||||
@@ -894,7 +894,10 @@ describe('appRouter', () => {
|
|||||||
actionCode: 'che_화계',
|
actionCode: 'che_화계',
|
||||||
arg: { destCityId: 7 },
|
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(
|
await expect(
|
||||||
caller.turns.reserved.setGeneral({
|
caller.turns.reserved.setGeneral({
|
||||||
|
|||||||
@@ -463,6 +463,31 @@ export const createReadModelChangeJournal = (changes: RealtimeReadModelChanges):
|
|||||||
return journal;
|
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 = (
|
export const excludeDeletedReservedTurnQueues = (
|
||||||
changes: ReservedTurnChanges,
|
changes: ReservedTurnChanges,
|
||||||
deletedGeneralIds: readonly number[],
|
deletedGeneralIds: readonly number[],
|
||||||
@@ -1463,13 +1488,15 @@ export const createDatabaseTurnHooks = async (
|
|||||||
for (const snapshot of pendingYearbookSnapshots) {
|
for (const snapshot of pendingYearbookSnapshots) {
|
||||||
await persistYearbookSnapshot(prisma, snapshot);
|
await persistYearbookSnapshot(prisma, snapshot);
|
||||||
}
|
}
|
||||||
|
const persistedMessageMailboxes: number[] = [];
|
||||||
for (const finalization of pendingUnificationFinalizations) {
|
for (const finalization of pendingUnificationFinalizations) {
|
||||||
if (options?.profileName && finalization.profileName !== options.profileName) {
|
if (options?.profileName && finalization.profileName !== options.profileName) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unification profile mismatch: pending=${finalization.profileName}, daemon=${options.profileName}.`
|
`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) {
|
for (const message of messages) {
|
||||||
await sendMessage(
|
await sendMessage(
|
||||||
@@ -1506,6 +1533,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error('Failed to persist turn message.');
|
throw new Error('Failed to persist turn message.');
|
||||||
}
|
}
|
||||||
|
persistedMessageMailboxes.push(draft.mailbox);
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1555,6 +1583,10 @@ export const createDatabaseTurnHooks = async (
|
|||||||
readModelChanges.worldChanged = true;
|
readModelChanges.worldChanged = true;
|
||||||
}
|
}
|
||||||
const journal = createReadModelChangeJournal(readModelChanges);
|
const journal = createReadModelChangeJournal(readModelChanges);
|
||||||
|
if (hasDashboardSourceMutation(changes, readModelChanges)) {
|
||||||
|
journal.mark('dashboard.global');
|
||||||
|
}
|
||||||
|
markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes));
|
||||||
markIds(journal, 'access.general', accessScoreResetGeneralIds);
|
markIds(journal, 'access.general', accessScoreResetGeneralIds);
|
||||||
if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) {
|
if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) {
|
||||||
journal.mark('betting');
|
journal.mark('betting');
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface UnificationFinalizationInput {
|
|||||||
export interface UnificationFinalizationResult {
|
export interface UnificationFinalizationResult {
|
||||||
status: 'APPLIED' | 'ALREADY_APPLIED';
|
status: 'APPLIED' | 'ALREADY_APPLIED';
|
||||||
generationKey: string;
|
generationKey: string;
|
||||||
|
messageMailboxes: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const readNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
const readNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
||||||
@@ -140,7 +141,8 @@ const cancelPendingUniqueAuctions = async (
|
|||||||
transaction: GamePrisma.TransactionClient,
|
transaction: GamePrisma.TransactionClient,
|
||||||
input: UnificationFinalizationInput,
|
input: UnificationFinalizationInput,
|
||||||
world: InMemoryTurnWorld
|
world: InMemoryTurnWorld
|
||||||
): Promise<void> => {
|
): Promise<number[]> => {
|
||||||
|
const messageMailboxes: number[] = [];
|
||||||
const lockedRows = await transaction.$queryRaw<LockedUnificationAuctionRow[]>`
|
const lockedRows = await transaction.$queryRaw<LockedUnificationAuctionRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
auction.id AS "auctionId",
|
auction.id AS "auctionId",
|
||||||
@@ -245,9 +247,17 @@ const cancelPendingUniqueAuctions = async (
|
|||||||
time: input.completedAt,
|
time: input.completedAt,
|
||||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||||
};
|
};
|
||||||
await sendMessage({ insertMessage: (draft) => insertMessage(transaction, draft) }, message, {
|
await sendMessage(
|
||||||
sendDestOnly: true,
|
{
|
||||||
});
|
insertMessage: async (draft) => {
|
||||||
|
const messageId = await insertMessage(transaction, draft);
|
||||||
|
messageMailboxes.push(draft.mailbox);
|
||||||
|
return messageId;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message,
|
||||||
|
{ sendDestOnly: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.auction.update({
|
await transaction.auction.update({
|
||||||
@@ -255,6 +265,7 @@ const cancelPendingUniqueAuctions = async (
|
|||||||
data: { status: 'CANCELED', finishedAt: input.completedAt },
|
data: { status: 'CANCELED', finishedAt: input.completedAt },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return messageMailboxes;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const persistUnificationFinalization = async (
|
export const persistUnificationFinalization = async (
|
||||||
@@ -267,7 +278,7 @@ export const persistUnificationFinalization = async (
|
|||||||
}
|
}
|
||||||
const claim = await claimGeneration(transaction, input);
|
const claim = await claimGeneration(transaction, input);
|
||||||
if (claim === 'ALREADY_APPLIED') {
|
if (claim === 'ALREADY_APPLIED') {
|
||||||
return { status: 'ALREADY_APPLIED', generationKey: input.generationKey };
|
return { status: 'ALREADY_APPLIED', generationKey: input.generationKey, messageMailboxes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
@@ -298,7 +309,7 @@ export const persistUnificationFinalization = async (
|
|||||||
|
|
||||||
// Ref cancels and refunds every unfinished unique auction before it merges
|
// Ref cancels and refunds every unfinished unique auction before it merges
|
||||||
// inheritance. Keep that order inside the generation transaction.
|
// inheritance. Keep that order inside the generation transaction.
|
||||||
await cancelPendingUniqueAuctions(transaction, input, world);
|
const messageMailboxes = await cancelPendingUniqueAuctions(transaction, input, world);
|
||||||
|
|
||||||
const pointRows = eligibleGenerals.length
|
const pointRows = eligibleGenerals.length
|
||||||
? await transaction.inheritancePoint.findMany({
|
? 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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
|
|||||||
globalRecordsChanged: true,
|
globalRecordsChanged: true,
|
||||||
});
|
});
|
||||||
expect(stateAndLogReceipt?.invalidation.revisions).toEqual([
|
expect(stateAndLogReceipt?.invalidation.revisions).toEqual([
|
||||||
|
{ domain: 'dashboard.global', entityId: 0, revision: 1n },
|
||||||
{ domain: 'map.world', entityId: 0, revision: 1n },
|
{ domain: 'map.world', entityId: 0, revision: 1n },
|
||||||
{ domain: 'records.global', entityId: 0, revision: 1n },
|
{ domain: 'records.global', entityId: 0, revision: 1n },
|
||||||
{ domain: 'world.content', 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();
|
const monthReceipt = hooks.takeCommittedReadModelChangeReceipt();
|
||||||
expect(monthReceipt?.changes.worldChanged).toBe(true);
|
expect(monthReceipt?.changes.worldChanged).toBe(true);
|
||||||
expect(monthReceipt?.invalidation.revisions).toEqual([
|
expect(monthReceipt?.invalidation.revisions).toEqual([
|
||||||
|
{ domain: 'dashboard.global', entityId: 0, revision: 2n },
|
||||||
{ domain: 'map.world', entityId: 0, revision: 2n },
|
{ domain: 'map.world', entityId: 0, revision: 2n },
|
||||||
{ domain: 'world.content', entityId: 0, revision: 2n },
|
{ domain: 'world.content', entityId: 0, revision: 2n },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createReadModelChangeJournal,
|
createReadModelChangeJournal,
|
||||||
createRealtimeReadModelBaseline,
|
createRealtimeReadModelBaseline,
|
||||||
createWorldReadModelSignature,
|
createWorldReadModelSignature,
|
||||||
|
hasDashboardSourceMutation,
|
||||||
mergePersistedVisibleLogChanges,
|
mergePersistedVisibleLogChanges,
|
||||||
summarizeRealtimeReadModelChanges,
|
summarizeRealtimeReadModelChanges,
|
||||||
} from '../src/turn/databaseHooks.js';
|
} 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', () => {
|
it('ignores moving clock and lease metadata but detects month, turn-term, config, and gameplay meta', () => {
|
||||||
const state = {
|
const state = {
|
||||||
currentYear: 190,
|
currentYear: 190,
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ describe('persistUnificationFinalization', () => {
|
|||||||
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
|
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
|
||||||
status: 'ALREADY_APPLIED',
|
status: 'ALREADY_APPLIED',
|
||||||
generationKey: input.generationKey,
|
generationKey: input.generationKey,
|
||||||
|
messageMailboxes: [],
|
||||||
});
|
});
|
||||||
expect(transaction.unificationFinalization.create).not.toHaveBeenCalled();
|
expect(transaction.unificationFinalization.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -221,6 +222,7 @@ describe('persistUnificationFinalization', () => {
|
|||||||
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
|
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
|
||||||
status: 'APPLIED',
|
status: 'APPLIED',
|
||||||
generationKey: input.generationKey,
|
generationKey: input.generationKey,
|
||||||
|
messageMailboxes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(inheritanceUpsert).toHaveBeenCalledWith(
|
expect(inheritanceUpsert).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
|
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
|
||||||
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-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",
|
"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:game": "prisma db push --schema prisma/game.prisma",
|
||||||
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -8,3 +8,4 @@ export * from './redis.js';
|
|||||||
export * from './turnEngineDb.js';
|
export * from './turnEngineDb.js';
|
||||||
export * from './readModelChangeJournal.js';
|
export * from './readModelChangeJournal.js';
|
||||||
export * from './readModelOutboxDispatcher.js';
|
export * from './readModelOutboxDispatcher.js';
|
||||||
|
export * from './readModelCoverageActivation.js';
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
|
||||||
|
|
||||||
|
export const READ_MODEL_REVISION_COVERAGE_VERSION = 1;
|
||||||
|
|
||||||
|
type CoverageDatabase = Pick<GamePrismaClient, '$executeRaw' | '$queryRaw'>;
|
||||||
|
|
||||||
|
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<ReadModelCoverageActivationResult> => {
|
||||||
|
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<CoverageRow[]>(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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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<void>) | 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user