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:
@@ -15,6 +15,8 @@ const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
|
||||
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
||||
const realtimeAccessGrantHeader = 'x-sammo-realtime-access-grant';
|
||||
const fixtureRealtimeAccessGrant = 'fixture-realtime-grant';
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
@@ -69,6 +71,7 @@ type NavigationFixture = {
|
||||
url: string;
|
||||
body: unknown;
|
||||
}>;
|
||||
dashboardGrantHeaders?: Array<string | null>;
|
||||
};
|
||||
|
||||
type JsonPatchOperation = {
|
||||
@@ -400,7 +403,13 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
}
|
||||
if (operation === 'dashboard.getContextBundleDelta') {
|
||||
state.generalMeCalls += 1;
|
||||
if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) {
|
||||
const refreshGrant = route.request().headers()[realtimeAccessGrantHeader] ?? null;
|
||||
(state.dashboardGrantHeaders ??= []).push(refreshGrant);
|
||||
if (
|
||||
state.accessLimitAfterCalls !== undefined &&
|
||||
state.generalMeCalls > state.accessLimitAfterCalls &&
|
||||
refreshGrant !== fixtureRealtimeAccessGrant
|
||||
) {
|
||||
return errorResponse(
|
||||
operation,
|
||||
'접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
|
||||
@@ -604,7 +613,15 @@ const installRealtimeHarness = async (page: Page) => {
|
||||
configurable: true,
|
||||
value: (type: string, payload: unknown) => {
|
||||
TestEventSource.latest?.dispatchEvent(
|
||||
new MessageEvent(type, { data: JSON.stringify({ type, ...((payload as object) ?? {}) }) })
|
||||
new MessageEvent(type, {
|
||||
data: JSON.stringify({
|
||||
type,
|
||||
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
|
||||
? { refreshGrant: 'fixture-realtime-grant' }
|
||||
: {}),
|
||||
...((payload as object) ?? {}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -2620,6 +2637,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
.toBe(true);
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
|
||||
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
|
||||
expect(state.dashboardGrantHeaders).toContain(null);
|
||||
|
||||
const operationsBeforeTournament = state.operations.length;
|
||||
state.stage = 1;
|
||||
@@ -2629,6 +2647,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
|
||||
await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u);
|
||||
expect(state.dashboardGrantHeaders?.at(-1)).toBe(fixtureRealtimeAccessGrant);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const general = document.querySelector('[data-main-target="general"]');
|
||||
@@ -2949,7 +2968,15 @@ test('access limit stops automatic main refresh and closes realtime until a manu
|
||||
.toBe(true);
|
||||
|
||||
const operationsBeforeLimit = state.operations.length;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true }));
|
||||
await page.evaluate(
|
||||
(invalidation) => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'readModelInvalidated',
|
||||
{ invalidation, refreshGrant: 'expired-grant' }
|
||||
);
|
||||
},
|
||||
readModelInvalidation({ records: true, map: true })
|
||||
);
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.');
|
||||
await expect
|
||||
@@ -2972,6 +2999,7 @@ test('access limit stops automatic main refresh and closes realtime until a manu
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
expect(state.dashboardGrantHeaders?.at(-1)).toBeNull();
|
||||
});
|
||||
|
||||
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -207,10 +207,10 @@ void test('merges browser-safe boolean invalidations and starts at most once per
|
||||
let nowMs = 0;
|
||||
let nextTimerId = 1;
|
||||
const timers = new Map<number, { callback: () => void; at: number }>();
|
||||
const observed: Array<{ context: boolean; records: boolean }> = [];
|
||||
const observed: Array<{ context: boolean; records: boolean; refreshGrant: string }> = [];
|
||||
const queue = createMergedReadModelRefreshQueue(
|
||||
async (invalidation) => {
|
||||
observed.push({ context: invalidation.context, records: invalidation.records });
|
||||
async (invalidation, refreshGrant) => {
|
||||
observed.push({ context: invalidation.context, records: invalidation.records, refreshGrant });
|
||||
},
|
||||
{
|
||||
minIntervalMs: 1_000,
|
||||
@@ -232,13 +232,13 @@ void test('merges browser-safe boolean invalidations and starts at most once per
|
||||
}
|
||||
};
|
||||
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }, 'grant-a');
|
||||
runDueTimers();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(observed, [{ context: true, records: false }]);
|
||||
assert.deepEqual(observed, [{ context: true, records: false, refreshGrant: 'grant-a' }]);
|
||||
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }, 'grant-b');
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true }, 'grant-c');
|
||||
nowMs = 999;
|
||||
runDueTimers();
|
||||
assert.equal(observed.length, 1);
|
||||
@@ -246,7 +246,7 @@ void test('merges browser-safe boolean invalidations and starts at most once per
|
||||
runDueTimers();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(observed, [
|
||||
{ context: true, records: false },
|
||||
{ context: true, records: true },
|
||||
{ context: true, records: false, refreshGrant: 'grant-a' },
|
||||
{ context: true, records: true, refreshGrant: 'grant-c' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createRealtimeRequestOptions,
|
||||
REALTIME_ACCESS_GRANT_CONTEXT_KEY,
|
||||
resolveBatchRealtimeAccessGrant,
|
||||
} from '../src/utils/realtimeAccessGrant.ts';
|
||||
|
||||
void test('adds a realtime grant only to server-signaled request options', () => {
|
||||
assert.deepEqual(createRealtimeRequestOptions('grant-a'), {
|
||||
context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' },
|
||||
});
|
||||
assert.equal(createRealtimeRequestOptions(undefined), undefined);
|
||||
});
|
||||
|
||||
void test('sets a batch grant only when every operation carries the same proof', () => {
|
||||
assert.equal(
|
||||
resolveBatchRealtimeAccessGrant([
|
||||
{ context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } },
|
||||
{ context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } },
|
||||
]),
|
||||
'grant-a'
|
||||
);
|
||||
assert.equal(
|
||||
resolveBatchRealtimeAccessGrant([
|
||||
{ context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } },
|
||||
{ context: {} },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
resolveBatchRealtimeAccessGrant([
|
||||
{ context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } },
|
||||
{ context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-b' } },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user