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({
|
||||
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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DashboardSourceSlice, string> => {
|
||||
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<DatabaseClient, '$queryRaw'>,
|
||||
generalId: number
|
||||
generalId: number,
|
||||
authSource?: DashboardAuthSource
|
||||
): Promise<DashboardSourceRevisionState | null> => {
|
||||
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)
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ const installSourceRevisionState = (
|
||||
cityId: 0,
|
||||
nationId: 0,
|
||||
coverageVersion: 1,
|
||||
globalRevision: 1n,
|
||||
generalRevision: 1n,
|
||||
cityRevision: 0n,
|
||||
nationRevision: 0n,
|
||||
|
||||
@@ -8,6 +8,7 @@ const row = (overrides: Record<string, unknown> = {}) => ({
|
||||
cityId: 3,
|
||||
nationId: 2,
|
||||
coverageVersion: 1,
|
||||
globalRevision: 10n,
|
||||
generalRevision: 11n,
|
||||
cityRevision: 12n,
|
||||
nationRevision: 13n,
|
||||
@@ -16,9 +17,13 @@ const row = (overrides: Record<string, unknown> = {}) => ({
|
||||
...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 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 };
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user