merge: 최신 main의 갱신 비용 변경을 tRPC 전송에 통합

# Conflicts:
#	app/game-api/src/server.ts
#	app/game-frontend/e2e/mainNavigation.spec.ts
#	app/game-frontend/src/utils/trpc.ts
This commit is contained in:
2026-08-17 11:22:19 +00:00
18 changed files with 643 additions and 96 deletions
+48 -32
View File
@@ -20,6 +20,7 @@ import {
} from '../utils/dashboardReadModel';
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
@@ -519,27 +520,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const fetchContextBundlePatch = async (
include: DashboardContextBundleInclude,
forceSnapshot = false
forceSnapshot = false,
refreshGrant?: string
): Promise<DashboardReadModelPatch> => {
const queryOptions = createRealtimeRequestOptions(refreshGrant);
const request = (force: boolean) =>
trpc.dashboard.getContextBundleDelta.query({
include,
known: force
? undefined
: {
...(contextRevision ? { context: contextRevision } : {}),
...(commandTableRevision ? { commandTable: commandTableRevision } : {}),
...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}),
},
knownSource: force
? undefined
: {
...(contextSourceRevision ? { context: contextSourceRevision } : {}),
...(commandTableSourceRevision ? { commandTable: commandTableSourceRevision } : {}),
...(boardAccessSourceRevision ? { boardAccess: boardAccessSourceRevision } : {}),
},
forceSnapshot: force || undefined,
});
trpc.dashboard.getContextBundleDelta.query(
{
include,
known: force
? undefined
: {
...(contextRevision ? { context: contextRevision } : {}),
...(commandTableRevision ? { commandTable: commandTableRevision } : {}),
...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}),
},
knownSource: force
? undefined
: {
...(contextSourceRevision ? { context: contextSourceRevision } : {}),
...(commandTableSourceRevision ? { commandTable: commandTableSourceRevision } : {}),
...(boardAccessSourceRevision ? { boardAccess: boardAccessSourceRevision } : {}),
},
forceSnapshot: force || undefined,
},
queryOptions
);
return resolveWithReadModelSnapshotFallback({
request,
@@ -674,7 +680,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
);
const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation) => {
const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation, refreshGrant: string) => {
const id = generalId.value;
if (!id) {
return;
@@ -688,37 +694,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (plan.records) recordsError.value = null;
if (plan.frontStatus) frontStatusError.value = null;
try {
const queryOptions = createRealtimeRequestOptions(refreshGrant);
// Every automatic refresh crosses this access-limit gate before
// any selected follow-up query starts. An all-false bundle is an
// access-only check and does not project general context.
const contextPatch = await fetchContextBundlePatch(resolveDashboardContextBundleInclude(plan));
const contextPatch = await fetchContextBundlePatch(
resolveDashboardContextBundleInclude(plan),
false,
refreshGrant
);
accessLimited.value = false;
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
const lobbyPromise = plan.lobby
? trpc.lobby.info.query(undefined, queryOptions)
: Promise.resolve(undefined);
const mapPromise = plan.map
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true })
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }, queryOptions)
: Promise.resolve(undefined);
const contactsPromise = plan.contacts
? trpc.messages.getContacts.query({ generalId: id })
? trpc.messages.getContacts.query({ generalId: id }, queryOptions)
: Promise.resolve(undefined);
const reservedPromise = plan.reservedTurns
? trpc.turns.reserved.getGeneral.query({ generalId: id })
? trpc.turns.reserved.getGeneral.query({ generalId: id }, queryOptions)
: Promise.resolve(undefined);
const recordsPromise = plan.records
? trpc.general.getRecentRecords
.query({ lastGeneralRecordId, lastWorldHistoryId })
.query({ lastGeneralRecordId, lastWorldHistoryId }, queryOptions)
.catch((err: unknown) => {
recordsError.value = resolveErrorMessage(err);
return null;
})
: Promise.resolve(undefined);
const frontPromise = plan.frontStatus
? trpc.general.getFrontStatus.query().catch((err: unknown) => {
? trpc.general.getFrontStatus.query(undefined, queryOptions).catch((err: unknown) => {
frontStatusError.value = resolveErrorMessage(err);
return null;
})
: Promise.resolve(undefined);
const tournamentPromise: Promise<TournamentState | undefined> = plan.tournament
? trpc.tournament.getState.query().catch(() => undefined)
? trpc.tournament.getState.query(undefined, queryOptions).catch(() => undefined)
: Promise.resolve(undefined);
const [lobby, map, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([
@@ -761,13 +774,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const readModelRefreshQueue = createMergedReadModelRefreshQueue(refreshChangedReadModels);
const refreshMessages = async () => {
const refreshMessages = async (refreshGrant?: string) => {
const id = generalId.value;
if (!id) {
return;
}
try {
const nextMessages = await trpc.messages.getRecent.query({ generalId: id });
const nextMessages = await trpc.messages.getRecent.query(
{ generalId: id },
createRealtimeRequestOptions(refreshGrant)
);
const patch = { messages: nextMessages } satisfies DashboardReadModelPatch;
applyDashboardPatch(patch);
publishDashboardPatch(patch);
@@ -1145,7 +1161,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!payload || payload.type !== 'readModelInvalidated') {
return;
}
readModelRefreshQueue.request(payload.invalidation);
readModelRefreshQueue.request(payload.invalidation, payload.refreshGrant);
});
source.addEventListener('messagesInvalidated', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
@@ -1153,7 +1169,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!payload || payload.type !== 'messagesInvalidated') {
return;
}
void refreshMessages();
void refreshMessages(payload.refreshGrant);
});
// Rolling deployment fallback: an older API may still expose internal
@@ -20,9 +20,7 @@ export const resolveDashboardRefreshPlan = (
identity: DashboardReadModelIdentity
): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity);
export const resolveDashboardContextBundleInclude = (
plan: DashboardRefreshPlan
): DashboardContextBundleInclude => ({
export const resolveDashboardContextBundleInclude = (plan: DashboardRefreshPlan): DashboardContextBundleInclude => ({
context: plan.context,
commandTable: plan.commands,
boardAccess: plan.boardAccess,
@@ -31,12 +29,12 @@ export const resolveDashboardContextBundleInclude = (
type TimerHandle = ReturnType<typeof setTimeout>;
export interface MergedReadModelRefreshQueue {
request(invalidation: RealtimeReadModelInvalidation): void;
request(invalidation: RealtimeReadModelInvalidation, refreshGrant: string): void;
cancelPending(): void;
}
export const createMergedReadModelRefreshQueue = (
refresh: (invalidation: RealtimeReadModelInvalidation) => Promise<void>,
refresh: (invalidation: RealtimeReadModelInvalidation, refreshGrant: string) => Promise<void>,
options: {
minIntervalMs?: number;
now?: () => number;
@@ -49,6 +47,7 @@ export const createMergedReadModelRefreshQueue = (
const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
let pending = createEmptyRealtimeReadModelInvalidation();
let pendingRefreshGrant = '';
let hasPending = false;
let running = false;
let timer: TimerHandle | null = null;
@@ -65,11 +64,13 @@ export const createMergedReadModelRefreshQueue = (
return;
}
const next = pending;
const nextRefreshGrant = pendingRefreshGrant;
pending = createEmptyRealtimeReadModelInvalidation();
pendingRefreshGrant = '';
hasPending = false;
running = true;
lastStartedAt = now();
void refresh(next).finally(() => {
void refresh(next, nextRefreshGrant).finally(() => {
running = false;
schedule();
});
@@ -77,14 +78,16 @@ export const createMergedReadModelRefreshQueue = (
};
return {
request: (invalidation) => {
request: (invalidation, refreshGrant) => {
pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation;
pendingRefreshGrant = refreshGrant;
hasPending = true;
schedule();
},
cancelPending: () => {
hasPending = false;
pending = createEmptyRealtimeReadModelInvalidation();
pendingRefreshGrant = '';
if (timer !== null) {
clearTimer(timer);
timer = null;
@@ -0,0 +1,21 @@
export const REALTIME_ACCESS_GRANT_CONTEXT_KEY = 'realtimeAccessGrant';
export const createRealtimeRequestOptions = (refreshGrant: string | null | undefined) =>
refreshGrant
? {
context: {
[REALTIME_ACCESS_GRANT_CONTEXT_KEY]: refreshGrant,
},
}
: undefined;
export const resolveBatchRealtimeAccessGrant = (
operations: ReadonlyArray<{ context: Record<string, unknown> }>
): string | undefined => {
if (operations.length === 0) return undefined;
const first = operations[0]?.context[REALTIME_ACCESS_GRANT_CONTEXT_KEY];
if (typeof first !== 'string' || first.length === 0) return undefined;
return operations.every((operation) => operation.context[REALTIME_ACCESS_GRANT_CONTEXT_KEY] === first)
? first
: undefined;
};
+8 -3
View File
@@ -1,6 +1,7 @@
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
import { REALTIME_ACCESS_GRANT_HEADER, trpcJsonBodyHttpClientOptions } from '@sammo-ts/common';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/game-api';
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
const getGameToken = (): string | null => {
if (typeof window === 'undefined') {
@@ -15,9 +16,13 @@ export const trpc = createTRPCProxyClient<AppRouter>({
httpBatchLink({
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
...trpcJsonBodyHttpClientOptions,
headers() {
headers({ opList }) {
const token = getGameToken();
return token ? { authorization: `Bearer ${token}` } : {};
const refreshGrant = resolveBatchRealtimeAccessGrant(opList);
return {
...(token ? { authorization: `Bearer ${token}` } : {}),
...(refreshGrant ? { [REALTIME_ACCESS_GRANT_HEADER]: refreshGrant } : {}),
};
},
}),
],