From 3470dc6da8d3a3da8c115f642947ef6f28f14f04 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 00:58:41 +0200 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=9A=80=20perf(dashboard):=20one=20r?= =?UTF-8?q?ealtime=20socket,=20invalidation=20not=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Realtime data layer - Keep one shell-owned WebSocket per channel across same-channel navigation. - Centralize realtime query invalidation and reconcile channel data after reconnect. - Polling and worker relays - Disable connected-state polling for chat voting, votekick, ads, and BELABOX while preserving offline fallback. - Relay chat_voting, belabox, and votekick live-state hints to panel sockets. - Refresh chat voting when a shared votekick ballot opens or closes. - #387 regression coverage - Cover socket reuse, query invalidation, fallback polling, worker relays, shared-ballot invalidation, frozen event snapshots, and overlay selection safety. - Preserve module query ordering and stable layouts. --- src/dashboard/ChannelVariablesPage.tsx | 60 +- src/dashboard/OverlayEditorPage.tsx | 88 ++- src/dashboard/OverlaysPage.tsx | 50 +- src/dashboard/api.ts | 12 +- src/dashboard/data/client.ts | 10 +- src/dashboard/data/index.ts | 2 +- src/dashboard/data/keys.ts | 20 +- src/dashboard/data/lists.ts | 27 +- src/dashboard/data/module-query.ts | 16 +- src/dashboard/data/realtime.ts | 96 +++ src/dashboard/events/EventsPage.tsx | 2 - src/dashboard/main.tsx | 233 ++++---- src/dashboard/realtime.ts | 559 +++++------------- src/dashboard/stream-manager.tsx | 62 +- src/modules/ads/panel/index.tsx | 67 +-- src/modules/ads/panel/service.ts | 4 +- .../belabox/panel/immediate-actions.tsx | 60 +- src/modules/belabox/panel/index.tsx | 119 ++-- src/modules/belabox/panel/service.ts | 11 +- src/modules/chat_voting/panel/index.tsx | 61 +- src/modules/chat_voting/panel/service.ts | 4 +- src/modules/votekick/panel/index.tsx | 101 ++-- src/modules/votekick/panel/locale.ts | 3 + src/modules/votekick/panel/service.ts | 4 +- src/modules/votekick/routes.ts | 12 + src/realtime-contract.ts | 28 +- src/worker/durable/ChannelObject.ts | 5 +- src/worker/module-overlay-realtime.ts | 6 +- tests/components/ads-panel.test.tsx | 64 +- .../belabox-alert-surfaces.test.tsx | 71 +-- tests/components/belabox-panel.test.tsx | 15 +- .../channel-variables-page.test.tsx | 73 +-- tests/components/chat-voting-panel.test.tsx | 3 +- tests/components/dashboard.test.tsx | 134 +++-- tests/components/events-page.test.tsx | 85 ++- tests/components/overlays-page.test.tsx | 3 +- tests/components/stream-manager.test.tsx | 44 +- tests/components/votekick-panel.test.tsx | 43 +- tests/e2e/host-stable-layout.spec.ts | 31 +- tests/e2e/module-panels-stability.spec.ts | 18 + tests/e2e/stable-layout.spec.ts | 39 ++ tests/unit/dashboard-query-data.test.tsx | 122 +++- tests/unit/dashboard-realtime.test.ts | 176 ++++++ tests/unit/dispatch.test.ts | 1 + tests/unit/module-overlay-realtime.test.ts | 24 + tests/unit/stream-state-lookup.test.ts | 11 + tests/unit/votekick-routes.test.ts | 116 +++- tests/worker/channel-object.test.ts | 34 ++ 48 files changed, 1619 insertions(+), 1210 deletions(-) create mode 100644 src/dashboard/data/realtime.ts create mode 100644 tests/unit/dashboard-realtime.test.ts diff --git a/src/dashboard/ChannelVariablesPage.tsx b/src/dashboard/ChannelVariablesPage.tsx index 58e095fa..a75332c8 100644 --- a/src/dashboard/ChannelVariablesPage.tsx +++ b/src/dashboard/ChannelVariablesPage.tsx @@ -7,7 +7,6 @@ import { fetchOverlay, fetchOverlays, fetchOverlayTokens, - fetchChannelVariables, PanelApiError, saveOverlay, updateChannelVariable, @@ -15,9 +14,11 @@ import { type PanelOverlayElement, } from "./api"; import { apiErrorText, channelVariablesTexts, dashboardCommonTexts, dashboardLanguage } from "./locale"; -import { useRealtimeVariableUpdates } from "./realtime"; import { Button, ConfirmDialog, EmptyCellValue, Field, Icon, InspectorActions, InspectorFieldRow, InspectorSection, ListDetail, ListToolbar, LoadState, notify, NumberField, PageHeader, Select, Skeleton, SubInspector, Switch } from "./ui"; import { CHANNEL_VARIABLE_MAXIMUM_COUNT, CHANNEL_VARIABLE_MAXIMUM_VALUE, CHANNEL_VARIABLE_MINIMUM_VALUE } from "../contracts/values"; +import { dashboardDataKeys } from "./data/keys"; +import { useDashboardQueryClient } from "./data"; +import { useChannelVariablesQuery, useOverlaysQuery } from "./data/lists"; interface ChannelVariablesPageProperties { channelId: string; @@ -32,6 +33,7 @@ interface ChannelVariablesPageProperties { const normalizedVariableName = (value: string): string => value.trim().toLowerCase(); const variableNamePattern = /^[a-z][a-z0-9_]{0,31}$/u; +const EMPTY_VARIABLES: readonly PanelChannelVariable[] = []; const saveableOverlayElement = (element: PanelOverlayElement): Omit => ({ id: element.id, kind: element.kind, @@ -49,11 +51,13 @@ const saveableOverlayElement = (element: PanelOverlayElement): Omit([]); + const variablesQuery = useChannelVariablesQuery(channelId); + const queryClient = useDashboardQueryClient(); + const variables = variablesQuery.data?.variables ?? EMPTY_VARIABLES; + const maximum = variablesQuery.data?.maximum ?? CHANNEL_VARIABLE_MAXIMUM_COUNT; + const loading = variablesQuery.isPending; + const loadFailed = variablesQuery.isError; const [search, setSearch] = useState(""); - const [maximum, setMaximum] = useState(CHANNEL_VARIABLE_MAXIMUM_COUNT); - const [loading, setLoading] = useState(true); - const [loadFailed, setLoadFailed] = useState(false); const [selectedName, setSelectedName] = useState(null); const [creating, setCreating] = useState(false); const [draftName, setDraftName] = useState(""); @@ -67,38 +71,26 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o const [pending, setPending] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [useOverlayOpen, setUseOverlayOpen] = useState(false); + const overlaysQuery = useOverlaysQuery(channelId, useOverlayOpen); + const overlayOptions = (overlaysQuery.data?.overlays ?? []).map(({ id, name, elementCount }) => ({ id, name, elementCount })); + const refetchVariables = variablesQuery.refetch; const [legacyLinkStatus, setLegacyLinkStatus] = useState<{ channelId: string; hasLinks: boolean } | null>(null); - const [overlayOptions, setOverlayOptions] = useState([]); const [overlaySelection, setOverlaySelection] = useState(""); const [creatingOverlay, setCreatingOverlay] = useState(false); const [newOverlayName, setNewOverlayName] = useState(""); const [overlayPending, setOverlayPending] = useState(false); - const refreshRequest = useRef(0); - const invalidateRefresh = useCallback((): void => { refreshRequest.current++; }, []); const lastInitialSelection = useRef(undefined); const refresh = useCallback(async (): Promise => { - const requestId = ++refreshRequest.current; - try { - const result = await fetchChannelVariables(channelId); - if (requestId !== refreshRequest.current) return; - setVariables(result.variables); - setMaximum(result.maximum); - setLoadFailed(false); - } catch (caught) { - if (requestId !== refreshRequest.current) return; - const loadErrorText = channelVariablesTexts(dashboardLanguage()).loadError; - setLoadFailed(true); - notify({ tone: "error", message: caught instanceof PanelApiError ? apiErrorText(caught.code, loadErrorText) : loadErrorText }); - } finally { - if (requestId === refreshRequest.current) setLoading(false); - } - }, [channelId]); + const result = await refetchVariables({ throwOnError: true }); + if (result.isError) throw result.error; + }, [refetchVariables]); useEffect(() => { - void Promise.resolve().then(refresh); - return () => { invalidateRefresh(); }; - }, [invalidateRefresh, refresh]); + if (!variablesQuery.isError) return; + const loadErrorText = channelVariablesTexts(dashboardLanguage()).loadError; + notify({ tone: "error", message: variablesQuery.error instanceof PanelApiError ? apiErrorText(variablesQuery.error.code, loadErrorText) : loadErrorText }); + }, [variablesQuery.error, variablesQuery.isError]); useEffect(() => { let active = true; @@ -110,8 +102,6 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o return () => { active = false; }; }, [channelId]); - useRealtimeVariableUpdates({ channelId, refresh }); - const selected = useMemo(() => variables.find((variable) => variable.name === selectedName) ?? null, [selectedName, variables]); useLayoutEffect(() => { if (editingValue) document.getElementById("channel-variable-inline-value")?.focus(); @@ -137,7 +127,6 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o setDraftReset(false); setDraftSetValue(0); setUseOverlayOpen(false); - setOverlayOptions([]); setOverlaySelection(""); setCreatingOverlay(false); setNewOverlayName(""); @@ -151,7 +140,6 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o setDraftReset(variable.resetOnStreamStart); setDraftSetValue(variable.value); setUseOverlayOpen(false); - setOverlayOptions([]); setOverlaySelection(""); setCreatingOverlay(false); setNewOverlayName(""); @@ -216,7 +204,7 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o setPending(true); try { const response = await changeChannelVariableValue(channelId, selected.name, operation, amount); - setVariables((current) => current.map((variable) => variable.name === selected.name ? { ...variable, ...response.variable } : variable)); + await refresh(); setDraftSetValue(response.variable.value); return true; } catch (caught) { @@ -238,8 +226,10 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o if (selected === null || !canManageContent || overlayPending) return; setOverlayPending(true); try { - const result = await fetchOverlays(channelId); - setOverlayOptions(result.overlays.map(({ id, name, elementCount }) => ({ id, name, elementCount }))); + const result = await queryClient.query({ + queryKey: dashboardDataKeys.overlays(channelId), + queryFn: ({ signal }) => fetchOverlays(channelId, signal), + }); setOverlaySelection(result.overlays[0]?.id ?? ""); setCreatingOverlay(result.overlays.length === 0); setNewOverlayName(labels.defaultOverlayName(selected.name)); diff --git a/src/dashboard/OverlayEditorPage.tsx b/src/dashboard/OverlayEditorPage.tsx index 35440b19..7c7e0dea 100644 --- a/src/dashboard/OverlayEditorPage.tsx +++ b/src/dashboard/OverlayEditorPage.tsx @@ -23,9 +23,7 @@ import { import { DisabledFieldReasonContext } from "./ui/DisabledFieldReason"; import { createOverlay, - fetchChannelVariables, fetchModules, - fetchOverlay, PanelApiError, saveOverlay, type PanelChannelVariable, @@ -36,7 +34,7 @@ import { import { apiErrorText, channelVariablesTexts, dashboardCommonTexts, dashboardLanguage, dashboardTexts, formatNumber, overlaysTexts } from "./locale"; import { moduleName } from "./module-labels"; import { OverlayElementPalette, type OverlayElementPaletteOption } from "./OverlayElementPalette"; -import { useRealtimeVariableUpdates } from "./realtime"; +import { useChannelVariablesQuery, useOverlayQuery } from "./data/lists"; import { Button, CodeField, ColorField, ConfirmDialog, Field, Icon, LoadState, NumberField, PageHeader, SaveBar, Select, Switch, notify, type IconName, registerDashboardNavigationGuard, useDraftGuard } from "./ui"; import "./overlay-editor.css"; @@ -314,33 +312,63 @@ const conflictRevision = (error: PanelApiError): number | null => { export function OverlayEditorPage({ channelId, overlayId, canManage, language, initialVariable, initialOverlayName, onBack, onOverlayCreated }: OverlayEditorPageProperties): ReactElement { const dashboardLocale = dashboardLanguage(); const labels = useMemo(() => overlaysTexts(dashboardLocale), [dashboardLocale]); - const [variables, setVariables] = useState([]); + const variablesQuery = useChannelVariablesQuery(channelId); + const overlayQuery = useOverlayQuery(channelId, overlayId, overlayId !== "new"); + const refetchVariables = variablesQuery.refetch; + const refetchOverlay = overlayQuery.refetch; + const variables = variablesQuery.data?.variables ?? []; const [moduleStates, setModuleStates] = useState([]); const [session, setSession] = useState(null); const [loadError, setLoadError] = useState(null); const [loadGeneration, setLoadGeneration] = useState(0); + const variablesLoadError = variablesQuery.isError && variablesQuery.data === undefined + ? variablesQuery.error instanceof PanelApiError + ? apiErrorText(variablesQuery.error.code, labels.editorLoadError) + : labels.editorLoadError + : null; + const overlayLoadError = overlayId !== "new" && overlayQuery.isError && overlayQuery.data === undefined + ? overlayQuery.error instanceof PanelApiError + ? apiErrorText(overlayQuery.error.code, labels.editorLoadError) + : labels.editorLoadError + : null; + const visibleLoadError = loadError ?? variablesLoadError ?? overlayLoadError; const initialVariableRef = useRef(canManage ? initialVariable : undefined); - - const refreshVariables = useCallback(async (): Promise => { - const result = await fetchChannelVariables(channelId); - setVariables(result.variables); - }, [channelId]); - - useRealtimeVariableUpdates({ channelId, refresh: refreshVariables }); + const initializedSessionKey = useRef(null); useEffect(() => { + const sessionKey = `${channelId}:${overlayId}:${String(loadGeneration)}`; + if (initializedSessionKey.current === sessionKey) return; + if (variablesQuery.isError && variablesQuery.data === undefined) { + const message = variablesQuery.error instanceof PanelApiError + ? apiErrorText(variablesQuery.error.code, labels.editorLoadError) + : labels.editorLoadError; + initializedSessionKey.current = sessionKey; + notify({ tone: "error", message }); + return; + } + if (variablesQuery.data === undefined) return; + const variableData = variablesQuery.data; + if (overlayId !== "new" && overlayQuery.isError && overlayQuery.data === undefined) { + const message = overlayQuery.error instanceof PanelApiError + ? apiErrorText(overlayQuery.error.code, labels.editorLoadError) + : labels.editorLoadError; + initializedSessionKey.current = sessionKey; + notify({ tone: "error", message }); + return; + } + if (overlayId !== "new" && overlayQuery.data === undefined) return; let disposed = false; - const overlayRequest = overlayId === "new" - ? Promise.resolve({ overlay: { + const overlay = overlayId === "new" + ? { id: "new", channelId, name: initialOverlayName ?? "", width: 1920, height: 1080, css: "", revision: 1, createdAt: "", updatedAt: "", elements: [], - } satisfies PanelOverlay }) - : fetchOverlay(channelId, overlayId); + } satisfies PanelOverlay + : overlayQuery.data?.overlay; + if (overlay === undefined) return; const moduleRequest = fetchModules(channelId).catch(() => ({ modules: [] })); - void Promise.all([overlayRequest, fetchChannelVariables(channelId), moduleRequest]) - .then(([overlayResult, variableResult, moduleResult]) => { + void moduleRequest + .then((moduleResult) => { if (disposed) return; - setVariables(variableResult.variables); setModuleStates(moduleResult.modules); const requestedVariable = initialVariableRef.current; initialVariableRef.current = undefined; @@ -349,7 +377,8 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i currentUrl.searchParams.delete("variable"); window.history.replaceState(window.history.state, "", `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`); } - setSession(initialSession(overlayResult.overlay, variableResult.variables, requestedVariable)); + setSession(initialSession(overlay, variableData.variables, requestedVariable)); + initializedSessionKey.current = sessionKey; }) .catch((caught: unknown) => { if (disposed) return; @@ -360,17 +389,28 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i notify({ tone: "error", message }); }); return () => { disposed = true; }; - }, [channelId, initialOverlayName, labels.editorLoadError, loadGeneration, overlayId]); + }, [channelId, initialOverlayName, labels.editorLoadError, loadGeneration, overlayId, overlayQuery.data, overlayQuery.error, overlayQuery.isError, variablesQuery.data, variablesQuery.error, variablesQuery.isError]); const reload = useCallback((): void => { setSession(null); setLoadError(null); - setLoadGeneration((current) => current + 1); - }, []); + void Promise.all([ + refetchVariables({ throwOnError: true }), + overlayId === "new" ? Promise.resolve() : refetchOverlay({ throwOnError: true }), + ]).then(() => { + setLoadGeneration((current) => current + 1); + }).catch((caught: unknown) => { + const message = caught instanceof PanelApiError + ? apiErrorText(caught.code, labels.editorLoadError) + : labels.editorLoadError; + setLoadError(message); + notify({ tone: "error", message }); + }); + }, [labels.editorLoadError, overlayId, refetchOverlay, refetchVariables]); - if (loadError !== null || session === null) return
+ if (visibleLoadError !== null || session === null) return
- {labels.editorLoading}

} empty={} error={}> diff --git a/src/dashboard/OverlaysPage.tsx b/src/dashboard/OverlaysPage.tsx index c03118c1..1b55d339 100644 --- a/src/dashboard/OverlaysPage.tsx +++ b/src/dashboard/OverlaysPage.tsx @@ -7,9 +7,7 @@ import streamElementsJs from "../../docs/embedding/streamelements/widget.js?raw" import { createOverlay, deleteOverlay, - fetchOverlay, fetchOverlayAccesses, - fetchOverlays, fetchOverlayTokens, importLegacyOverlay, issueOverlayAccess, @@ -27,6 +25,7 @@ import { } from "./api"; import { apiErrorText, dashboardCommonTexts, dashboardLanguage, formatTimestamp, overlaysTexts } from "./locale"; import { ActionMenu, Button, ConfirmDialog, EmptyCellValue, Field, FormDialog, InspectorActions, InspectorSection, Led, ListDetail, ListPaginationFooter, ListToolbar, LoadState, notify, NumberField, PageHeader, Select, Skeleton, SubInspector } from "./ui"; +import { useOverlayQuery, useOverlaysQuery } from "./data/lists"; interface OverlaysPageProperties { channelId: string; @@ -36,6 +35,7 @@ interface OverlaysPageProperties { } const blankOverlayName = ""; +const EMPTY_OVERLAYS: readonly PanelOverlaySummary[] = []; const maskOverlaySecret = (overlayUrl: string): string => overlayUrl.replace(/([#&]token=)[^&]*/u, "$1••••••"); const copyPromiseToClipboard = async (text: Promise): Promise => { const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined; @@ -232,18 +232,19 @@ function OverlaySetupAssistant({ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEditor }: OverlaysPageProperties): ReactElement { const language = dashboardLanguage(); const labels = overlaysTexts(language); - const [overlays, setOverlays] = useState([]); + const overlaysQuery = useOverlaysQuery(channelId); + const overlays = overlaysQuery.data?.overlays ?? EMPTY_OVERLAYS; + const maximum = overlaysQuery.data?.maximum ?? 20; + const loading = overlaysQuery.isPending; + const loadFailed = overlaysQuery.isError; const [search, setSearch] = useState(""); - const [maximum, setMaximum] = useState(20); - const [loading, setLoading] = useState(true); - const [loadFailed, setLoadFailed] = useState(false); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const [selectedId, setSelectedId] = useState(initialSelection ?? null); const selectedIdRef = useRef(initialSelection ?? null); + const selectedOverlayQuery = useOverlayQuery(channelId, selectedId ?? "", selectedId !== null); const [selectedOverlayData, setSelectedOverlayData] = useState<{ id: string; - overlay: PanelOverlay; accesses: readonly PanelOverlayAccess[]; } | null>(null); const [creating, setCreating] = useState(false); @@ -273,7 +274,6 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const [legacyImportError, setLegacyImportError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [now, setNow] = useState(() => Date.now()); - const requestVersion = useRef(0); const secretVersion = useRef(0); const legacyRequestVersion = useRef(0); const pageActiveRef = useRef(true); @@ -321,31 +321,25 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi channelIdRef.current = channelId; }, [canManage, channelId, changeSelection, invalidateSecret]); const selected = useMemo(() => overlays.find((overlay) => overlay.id === selectedId) ?? null, [overlays, selectedId]); - const selectedOverlay = selectedOverlayData?.id === selectedId ? selectedOverlayData.overlay : null; + const refetchOverlays = overlaysQuery.refetch; + const selectedOverlay = selectedOverlayQuery.data?.overlay ?? null; const accesses = selectedOverlayData?.id === selectedId ? selectedOverlayData.accesses : []; const revokedAccesses = accesses.filter((access) => access.revokedAt !== null); const setupAccess = accesses.find((access) => access.tokenId === setupAccessId) ?? null; const load = useCallback(async (isActive: () => boolean = () => true): Promise => { - const version = ++requestVersion.current; try { - const result = await fetchOverlays(channelId); - if (!isActive() || version !== requestVersion.current) return; - setOverlays(result.overlays); - setMaximum(result.maximum); - setLoadFailed(false); + const result = await refetchOverlays({ throwOnError: true }); + if (!isActive() || result.data === undefined) return; setError(null); - if (selectedIdRef.current === null && initialSelection !== undefined && result.overlays.some((item) => item.id === initialSelection)) { + if (selectedIdRef.current === null && initialSelection !== undefined && result.data.overlays.some((item) => item.id === initialSelection)) { changeSelection(initialSelection); } } catch (caught) { - if (!isActive() || version !== requestVersion.current) return; - setLoadFailed(true); + if (!isActive()) return; notify({ tone: "error", message: caught instanceof PanelApiError ? apiErrorText(caught.code, labels.loadError) : labels.loadError }); - } finally { - if (isActive() && version === requestVersion.current) setLoading(false); } - }, [channelId, changeSelection, initialSelection, labels.loadError]); + }, [changeSelection, initialSelection, labels.loadError, refetchOverlays]); const loadLegacyTokens = useCallback(async (offset = 0, append = false): Promise => { const version = ++legacyRequestVersion.current; @@ -386,13 +380,9 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi useEffect(() => { if (selectedId === null) return; let active = true; - const requests: [Promise<{ overlay: PanelOverlay }>, Promise<{ accesses: readonly PanelOverlayAccess[] }>] = [ - fetchOverlay(channelId, selectedId), - fetchOverlayAccesses(channelId, selectedId), - ]; - void Promise.all(requests).then(([overlayResult, accessResult]) => { + void fetchOverlayAccesses(channelId, selectedId).then((accessResult) => { if (!active) return; - setSelectedOverlayData({ id: selectedId, overlay: overlayResult.overlay, accesses: accessResult.accesses }); + setSelectedOverlayData({ id: selectedId, accesses: accessResult.accesses }); setError(null); }).catch((caught: unknown) => { if (!active) return; @@ -404,12 +394,12 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const refreshSelected = async (): Promise => { const overlayId = selectedIdRef.current; if (overlayId === null) return; - const [overlayResult, accessResult] = await Promise.all([ - fetchOverlay(channelId, overlayId), + const [, accessResult] = await Promise.all([ + selectedOverlayQuery.refetch({ throwOnError: true }), fetchOverlayAccesses(channelId, overlayId), ]); if (channelIdRef.current === channelId && selectedIdRef.current === overlayId) { - setSelectedOverlayData({ id: overlayId, overlay: overlayResult.overlay, accesses: accessResult.accesses }); + setSelectedOverlayData({ id: overlayId, accesses: accessResult.accesses }); } }; diff --git a/src/dashboard/api.ts b/src/dashboard/api.ts index 73e61aff..7ffaa29f 100644 --- a/src/dashboard/api.ts +++ b/src/dashboard/api.ts @@ -183,8 +183,8 @@ const overlayAccessesPath = (channelId: string, overlayId: string, tokenId?: str return action === undefined ? accessPath : `${accessPath}/${action}`; }; -export const fetchOverlays = (channelId: string): Promise<{ overlays: readonly PanelOverlaySummary[]; maximum: number; elementMaximum: number }> => - requestJson(overlaysPath(channelId)); +export const fetchOverlays = (channelId: string, signal?: AbortSignal): Promise<{ overlays: readonly PanelOverlaySummary[]; maximum: number; elementMaximum: number }> => + requestJson(overlaysPath(channelId), signal === undefined ? {} : { signal }); export const createOverlay = (channelId: string, input: { name: string; @@ -201,8 +201,8 @@ export const importLegacyOverlay = (channelId: string, input: { }): Promise<{ overlay: PanelOverlay; closingPending?: boolean }> => requestMutation(`${overlaysPath(channelId)}/import-legacy`, "POST", input); -export const fetchOverlay = (channelId: string, overlayId: string): Promise<{ overlay: PanelOverlay }> => - requestJson(overlaysPath(channelId, overlayId)); +export const fetchOverlay = (channelId: string, overlayId: string, signal?: AbortSignal): Promise<{ overlay: PanelOverlay }> => + requestJson(overlaysPath(channelId, overlayId), signal === undefined ? {} : { signal }); export type PanelOverlayDraft = Pick; export interface PanelOverlayReconnectExpectation { @@ -552,8 +552,8 @@ export interface PanelChannelVariablesResponse { const variablesPath = (channelId: string, name?: string, suffix = ""): string => `${channelPath(channelId, "variables")}${name === undefined ? "" : `/${encodeURIComponent(name)}`}${suffix}`; -export const fetchChannelVariables = (channelId: string): Promise => - requestJson(variablesPath(channelId)); +export const fetchChannelVariables = (channelId: string, signal?: AbortSignal): Promise => + requestJson(variablesPath(channelId), signal === undefined ? {} : { signal }); export const createChannelVariable = ( channelId: string, diff --git a/src/dashboard/data/client.ts b/src/dashboard/data/client.ts index 8e4c88eb..bc1167c3 100644 --- a/src/dashboard/data/client.ts +++ b/src/dashboard/data/client.ts @@ -6,19 +6,23 @@ import { } from "@tanstack/react-query"; import { PanelApiError, requestJson } from "../api"; -import { isChannelQueryKey } from "./keys"; +import { isChannelQueryKey, type ChannelQueryParams } from "./keys"; const requestChannelResource: QueryFunction = ({ queryKey, signal }) => { if (!isChannelQueryKey(queryKey)) { throw new Error("Dashboard queries must use a channel-scoped query key."); } + if (queryKey[2] === "module") { + throw new Error("Module queries must provide their own query function."); + } const [, channelId, resource, ...params] = queryKey; const resourcePath = resource.split("/").map((segment) => encodeURIComponent(segment)).join("/"); const searchParams = new URLSearchParams(); for (const queryParams of params) { - for (const name of Object.keys(queryParams).sort((a, b) => a.localeCompare(b))) { - const value = queryParams[name]; + const values = queryParams as ChannelQueryParams; + for (const name of Object.keys(values).sort((a, b) => a.localeCompare(b))) { + const value = values[name]; if (value !== null && value !== undefined) searchParams.append(name, String(value)); } } diff --git a/src/dashboard/data/index.ts b/src/dashboard/data/index.ts index ec7c9d01..aa29d1e4 100644 --- a/src/dashboard/data/index.ts +++ b/src/dashboard/data/index.ts @@ -1,3 +1,3 @@ export { useQueryClient as useDashboardQueryClient } from "@tanstack/react-query"; export { moduleQueryKey, refetchModuleQueryData, runModuleQueryWrite, useModuleQuery } from "./module-query"; -export type { ModuleQueryFunction, ModuleQueryPart, ModuleQueryRevision, ModuleQueryWriteFunction, ModuleQueryWriteOptions } from "./module-query"; +export type { ModuleQueryFunction, ModuleQueryOptions, ModuleQueryPart, ModuleQueryRevision, ModuleQueryWriteFunction, ModuleQueryWriteOptions } from "./module-query"; diff --git a/src/dashboard/data/keys.ts b/src/dashboard/data/keys.ts index b22fd48d..9a90ed7a 100644 --- a/src/dashboard/data/keys.ts +++ b/src/dashboard/data/keys.ts @@ -1,7 +1,8 @@ import type { PanelAuditFilters, PanelEventFilters } from "../../panel-contract"; export type ChannelQueryParams = Readonly>; -export type ChannelQueryKey = readonly ["channel", string, string, ...ChannelQueryParams[]]; +export type ModuleQueryKey = readonly ["channel", string, "module", string, string]; +export type ChannelQueryKey = readonly ["channel", string, string, ...ChannelQueryParams[]] | ModuleQueryKey; export const queryKeys = { channels: () => ["channels"] as const, @@ -11,17 +12,30 @@ export const queryKeys = { resource, ...params, ], + module: (channelId: string, moduleId: string, part: string): ModuleQueryKey => [ + "channel", + channelId, + "module", + moduleId, + part, + ], }; export const isChannelQueryKey = (queryKey: readonly unknown[]): queryKey is ChannelQueryKey => queryKey[0] === "channel" && typeof queryKey[1] === "string" && - typeof queryKey[2] === "string" && - queryKey.slice(3).every((value) => typeof value === "object" && value !== null && !Array.isArray(value)); + (queryKey[2] === "module" + ? typeof queryKey[3] === "string" && typeof queryKey[4] === "string" && queryKey.length === 5 + : typeof queryKey[2] === "string" && + queryKey.slice(3).every((value) => typeof value === "object" && value !== null && !Array.isArray(value))); export const dashboardDataKeys = { + module: (channelId: string, moduleId: string) => ["channel", channelId, "module", moduleId] as const, system: (channelId: string) => queryKeys.channel(channelId, "system"), members: (channelId: string) => queryKeys.channel(channelId, "members"), + variables: (channelId: string) => queryKeys.channel(channelId, "variables"), + overlays: (channelId: string) => queryKeys.channel(channelId, "overlays"), + overlay: (channelId: string, overlayId: string) => queryKeys.channel(channelId, "overlays", { overlayId }), audit: (channelId: string, filters: PanelAuditFilters) => queryKeys.channel(channelId, "audit-log", { person: filters.person, area: filters.area, diff --git a/src/dashboard/data/lists.ts b/src/dashboard/data/lists.ts index 02cf1957..318d77e0 100644 --- a/src/dashboard/data/lists.ts +++ b/src/dashboard/data/lists.ts @@ -1,6 +1,6 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { fetchAuditLog, fetchEvents, fetchMembers, fetchSystemOverview } from "../api"; +import { fetchAuditLog, fetchChannelVariables, fetchEvents, fetchMembers, fetchOverlay, fetchOverlays, fetchSystemOverview } from "../api"; import type { PanelAuditFilters, PanelEventFilters } from "../../panel-contract"; import { dashboardDataKeys } from "./keys"; @@ -47,3 +47,28 @@ export const useSystemQuery = (channelId: string, enabled: boolean) => useQuery( placeholderData: (previousData, previousQuery) => previousQuery?.queryKey[1] === channelId ? previousData : undefined, }); + +export const useChannelVariablesQuery = (channelId: string) => useQuery({ + queryKey: dashboardDataKeys.variables(channelId), + queryFn: ({ signal }) => fetchChannelVariables(channelId, signal), + refetchOnWindowFocus: false, + placeholderData: (previousData, previousQuery) => + previousQuery?.queryKey[1] === channelId ? previousData : undefined, +}); + +export const useOverlaysQuery = (channelId: string, enabled = true) => useQuery({ + queryKey: dashboardDataKeys.overlays(channelId), + queryFn: ({ signal }) => fetchOverlays(channelId, signal), + enabled, + refetchOnWindowFocus: false, + placeholderData: (previousData, previousQuery) => + previousQuery?.queryKey[1] === channelId ? previousData : undefined, +}); + +export const useOverlayQuery = (channelId: string, overlayId: string, enabled = true) => useQuery({ + queryKey: dashboardDataKeys.overlay(channelId, overlayId), + queryFn: ({ signal }) => fetchOverlay(channelId, overlayId, signal), + enabled, + refetchOnWindowFocus: false, + placeholderData: () => undefined, +}); diff --git a/src/dashboard/data/module-query.ts b/src/dashboard/data/module-query.ts index ec1f962d..76748a02 100644 --- a/src/dashboard/data/module-query.ts +++ b/src/dashboard/data/module-query.ts @@ -1,4 +1,4 @@ -import { hashKey, keepPreviousData, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { hashKey, keepPreviousData, useQuery, useQueryClient, type Query, type QueryClient } from "@tanstack/react-query"; import { queryKeys } from "./keys"; @@ -17,6 +17,11 @@ export interface ModuleQueryWriteOptions { relatedParts?: readonly ModuleQueryPart[]; } +export interface ModuleQueryOptions { + enabled?: boolean; + refetchInterval?: number | false | ((query: Query) => number | false); +} + export type ModuleQueryWriteFunction = (baselineRevision: ModuleQueryRevision) => Promise; interface ReadOrder { @@ -108,13 +113,14 @@ export const refetchModuleQueryData = ( }; export const moduleQueryKey = (channelId: string, moduleId: string, part: string) => - queryKeys.channel(channelId, `modules/${moduleId}/${part}`); + queryKeys.module(channelId, moduleId, part); export const useModuleQuery = ( channelId: string, moduleId: string, part: string, fn: ModuleQueryFunction, + options: ModuleQueryOptions = {}, ) => { const queryClient = useQueryClient(); const queryKey = moduleQueryKey(channelId, moduleId, part); @@ -127,10 +133,12 @@ export const useModuleQuery = ( return useQuery({ queryKey, queryFn: ({ signal }) => readModuleData(queryClient, queryKey, fn, signal), + ...(options.enabled === undefined ? {} : { enabled: options.enabled }), + ...(options.refetchInterval === undefined ? {} : { refetchInterval: options.refetchInterval }), placeholderData: (previousData, previousQuery) => { const previousKey = previousQuery?.queryKey; - const resource = `modules/${moduleId}/${part}`; - return previousKey?.[0] === "channel" && previousKey[1] === channelId && previousKey[2] === resource + return previousKey?.[0] === "channel" && previousKey[1] === channelId && + previousKey[2] === "module" && previousKey[3] === moduleId && previousKey[4] === part ? keepPreviousData(previousData) : undefined; }, diff --git a/src/dashboard/data/realtime.ts b/src/dashboard/data/realtime.ts new file mode 100644 index 00000000..e66e0bf5 --- /dev/null +++ b/src/dashboard/data/realtime.ts @@ -0,0 +1,96 @@ +import { useCallback, useSyncExternalStore } from "react"; +import type { QueryClient } from "@tanstack/react-query"; + +import type { RealtimeMessage } from "../../realtime-contract"; +import { dashboardDataKeys, queryKeys } from "./keys"; +import { refreshQuery } from "./refresh"; + +export type DashboardRealtimeStatus = "connecting" | "connected" | "reconnecting" | "offline" | "renew"; + +const statuses = new Map(); +const subscribers = new Map void>>(); + +export const getDashboardRealtimeStatus = (channelId: string): DashboardRealtimeStatus => + statuses.get(channelId) ?? "offline"; + +export const setDashboardRealtimeStatus = (channelId: string, status: DashboardRealtimeStatus): void => { + if (statuses.get(channelId) === status) return; + statuses.set(channelId, status); + for (const subscriber of subscribers.get(channelId) ?? []) subscriber(); +}; + +export const useDashboardRealtimeStatus = (channelId: string): DashboardRealtimeStatus => { + const subscribe = useCallback((subscriber: () => void) => { + let channelSubscribers = subscribers.get(channelId); + if (channelSubscribers === undefined) { + channelSubscribers = new Set(); + subscribers.set(channelId, channelSubscribers); + } + channelSubscribers.add(subscriber); + return () => { + channelSubscribers.delete(subscriber); + if (channelSubscribers.size === 0) subscribers.delete(channelId); + }; + }, [channelId]); + const getSnapshot = useCallback(() => getDashboardRealtimeStatus(channelId), [channelId]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; + +const invalidate = (queryClient: QueryClient, queryKey: readonly unknown[]): void => { + const matchingQueries = queryClient.getQueryCache().findAll({ queryKey }); + void queryClient.invalidateQueries({ queryKey, refetchType: "none" }); + for (const query of matchingQueries) { + if (query.isActive()) void refreshQuery(queryClient, query.queryKey); + } +}; + +/** Invalidates the channel caches affected by a realtime hint; API responses remain authoritative. */ +export const invalidateDashboardRealtimeMessage = ( + queryClient: QueryClient, + message: RealtimeMessage, +): void => { + const { channelId } = message; + switch (message.type) { + case "stream.state.changed": + invalidate(queryClient, queryKeys.channels()); + invalidate(queryClient, ["channel", channelId]); + break; + case "event_log.new": { + invalidate(queryClient, queryKeys.channel(channelId, "events")); + const moduleIds = new Set(message.payload.entries.map((entry) => entry.moduleId)); + for (const moduleId of moduleIds) invalidate(queryClient, dashboardDataKeys.module(channelId, moduleId)); + break; + } + case "variables.changed": + invalidate(queryClient, dashboardDataKeys.variables(channelId)); + invalidate(queryClient, dashboardDataKeys.overlays(channelId)); + invalidate(queryClient, ["channel", channelId, "module"]); + break; + case "overlay.changed": + invalidate(queryClient, dashboardDataKeys.overlays(channelId)); + invalidate(queryClient, dashboardDataKeys.overlay(channelId, message.payload.overlayId)); + invalidate(queryClient, dashboardDataKeys.variables(channelId)); + break; + case "ads.schedule.updated": + invalidate(queryClient, dashboardDataKeys.module(channelId, "ads")); + break; + default: { + if (message.type.startsWith("modul.")) { + const moduleId = message.type.split(".")[1]; + if (moduleId !== undefined) invalidate(queryClient, dashboardDataKeys.module(channelId, moduleId)); + const votekickOpened = message.type === "modul.votekick.opened"; + const votekickClosed = message.type === "modul.votekick.tally" && message.payload.status !== "running"; + if (votekickOpened || votekickClosed) { + // Chat voting also reads channel-wide ballot availability from the shared ballot. + invalidate(queryClient, dashboardDataKeys.module(channelId, "chat_voting")); + } + } + } + } +}; + +/** A successful reconnect makes every cached resource for that channel stale. */ +export const invalidateDashboardChannelQueries = (queryClient: QueryClient, channelId: string): void => { + invalidate(queryClient, queryKeys.channels()); + invalidate(queryClient, ["channel", channelId]); +}; diff --git a/src/dashboard/events/EventsPage.tsx b/src/dashboard/events/EventsPage.tsx index 4bc04054..e1ef47c4 100644 --- a/src/dashboard/events/EventsPage.tsx +++ b/src/dashboard/events/EventsPage.tsx @@ -374,8 +374,6 @@ const EventsPageContent = (properties: EventsPageContentProperties): ReactElemen }, []); const realtime = useRealtimeEventFeed({ channelId, - filters, - refresh: onRefresh, refreshOnReturn: onRefreshOnReturn, scrollToBeginning, }); diff --git a/src/dashboard/main.tsx b/src/dashboard/main.tsx index 95b0408f..37345c53 100644 --- a/src/dashboard/main.tsx +++ b/src/dashboard/main.tsx @@ -382,10 +382,74 @@ const mergeAndRememberChannelStreamVersion = ( return merged; }; -const ChannelControlActions = ({ channelId, controls, now, onRefresh }: { +const useSecondClock = (): number => { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => { setNow(Date.now()); }, 1000); + return () => { window.clearInterval(id); }; + }, []); + return now; +}; + +const ChannelControlExpiryWatcher = ({ controls, onRefresh }: { + controls: PanelChannelControls | undefined; + onRefresh: () => Promise; +}): null => { + const refreshedExpiryKey = useRef(""); + useEffect(() => { + let timer: number | null = null; + const checkExpiry = (): void => { + const now = Date.now(); + const mute = controls?.mute ?? CONTROL_OFF; + const pause = controls?.pause ?? CONTROL_OFF; + const staleExpiryKey = [mute, pause] + .filter((control) => control.active && control.mode === "timed" && control.until !== null && Date.parse(control.until) <= now) + .map((control) => control.until) + .join("|"); + if (staleExpiryKey.length === 0) refreshedExpiryKey.current = ""; + else if (refreshedExpiryKey.current !== staleExpiryKey) { + refreshedExpiryKey.current = staleExpiryKey; + void onRefresh(); + } + const nextExpiry = [mute, pause] + .filter((control) => control.active && control.mode === "timed" && control.until !== null) + .map((control) => Date.parse(control.until ?? "")) + .filter((expiresAt) => Number.isFinite(expiresAt) && expiresAt > now) + .sort((left, right) => left - right)[0]; + if (nextExpiry !== undefined) timer = window.setTimeout(checkExpiry, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, nextExpiry - now))); + }; + checkExpiry(); + return () => { if (timer !== null) window.clearTimeout(timer); }; + }, [controls, onRefresh]); + return null; +}; + +const ChannelControlStatus = ({ control, kind }: { + control: PanelChannelControl; + kind: "mute" | "pause"; +}): ReactElement => { + const texts = dashboardTexts(); + const labels = texts.channelControls; + const now = useSecondClock(); + const configured = control.active || control.pending === true; + let label: string | null = null; + if (control.pending === true) label = labels.pendingUntilStreamStart; + else if (control.active && control.mode === "timed" && control.until !== null) { + const remaining = Math.ceil((Date.parse(control.until) - now) / 60_000); + label = remaining <= 0 ? texts.status.refreshing + : kind === "mute" ? labels.muteRemaining(String(remaining)) : labels.pauseRemaining(String(remaining)); + } else if (control.active && control.mode === "until_stream_end") { + label = `${kind === "mute" ? labels.muteActive : labels.pauseActive} · ${labels.untilStreamEnd}`; + } else if (control.active) label = kind === "mute" ? labels.muteActive : labels.pauseActive; + const timed = control.active && control.mode === "timed" && control.until !== null && Date.parse(control.until) > now; + return + {configured && label !== null ? {timed ? : null}{label} : null} + ; +}; + +const ChannelControlActions = ({ channelId, controls, onRefresh }: { channelId: string; controls: PanelChannelControls | undefined; - now: number; onRefresh: () => Promise; }): ReactElement => { const texts = dashboardTexts(); @@ -393,27 +457,11 @@ const ChannelControlActions = ({ channelId, controls, now, onRefresh }: { const [dialogControl, setDialogControl] = useState<"mute" | "pause" | null>(null); const [duration, setDuration] = useState("unlimited"); const [busy, setBusy] = useState<"mute" | "pause" | null>(null); - const refreshRef = useRef(onRefresh); - useEffect(() => { refreshRef.current = onRefresh; }, [onRefresh]); const mute = controls?.mute ?? CONTROL_OFF; const pause = controls?.pause ?? CONTROL_OFF; const muteConfigured = mute.active || mute.pending === true; const pauseConfigured = pause.active || pause.pending === true; - const staleExpiryKey = [mute, pause] - .filter((control) => control.active && control.mode === "timed" && control.until !== null && Date.parse(control.until) <= now) - .map((control) => control.until) - .join("|"); - const refreshedExpiryKey = useRef(""); - - useEffect(() => { - if (staleExpiryKey.length === 0) { - refreshedExpiryKey.current = ""; - return; - } - if (refreshedExpiryKey.current === staleExpiryKey) return; - refreshedExpiryKey.current = staleExpiryKey; - void refreshRef.current(); - }, [staleExpiryKey]); + const activeChannelControls = controls ?? { mute: CONTROL_OFF, pause: CONTROL_OFF }; const apply = async (kind: "mute" | "pause", next: ChannelControlDuration | null): Promise => { if (busy !== null) return; @@ -434,29 +482,6 @@ const ChannelControlActions = ({ channelId, controls, now, onRefresh }: { } }; - const activeText = (control: typeof mute, kind: "mute" | "pause"): string | null => { - if (control.pending === true) return labels.pendingUntilStreamStart; - if (!control.active) return null; - if (control.mode === "timed" && control.until !== null) { - const remaining = Math.ceil((Date.parse(control.until) - now) / 60_000); - if (remaining <= 0) return texts.status.refreshing; - return kind === "mute" ? labels.muteRemaining(String(remaining)) : labels.pauseRemaining(String(remaining)); - } - if (control.mode === "until_stream_end") return `${kind === "mute" ? labels.muteActive : labels.pauseActive} · ${labels.untilStreamEnd}`; - return kind === "mute" ? labels.muteActive : labels.pauseActive; - }; - - const controlState = (control: typeof mute, kind: "mute" | "pause", configured: boolean): ReactElement | null => { - const label = configured ? activeText(control, kind) : null; - if (label === null) return null; - const timed = control.active && control.mode === "timed" && control.until !== null && Date.parse(control.until) > now; - return {timed ? : null}{label}; - }; - - const controlStateSlot = (control: typeof mute, kind: "mute" | "pause", configured: boolean): ReactElement => ( - {controlState(control, kind, configured)} - ); - const durationOptions = CHANNEL_CONTROL_DURATIONS.map((choice) => ({ value: choice, label: choice === "15m" ? labels.duration15m @@ -480,7 +505,7 @@ const ChannelControlActions = ({ channelId, controls, now, onRefresh }: { disabled={busy !== null} onClick={() => { if (muteConfigured) void apply("mute", null); else setDialogControl("mute"); }} /> - {controlStateSlot(mute, "mute", muteConfigured)} + ); @@ -1223,9 +1257,7 @@ export const DashboardApp = (): ReactElement => { }); const overview = loadStateFromQuery(overviewQuery); const realtimeChannelId = route.kind === "channel" || route.kind === "module" ? route.channelId : null; - const routeHasOwnRealtimeFeed = route.kind === "channel" && - (route.section === "overview" || route.section === "events" || route.section === "variables"); - useRealtimePanelMessages(realtimeChannelId, realtimeChannelId !== null && !routeHasOwnRealtimeFeed); + useRealtimePanelMessages(realtimeChannelId, realtimeChannelId !== null); const systemChannelId = route.kind === "channel" && route.section === "system" ? route.channelId : null; const systemQuery = useSystemQuery(systemChannelId ?? "", !authenticationRequired && systemChannelId !== null); const [, setFreshnessTick] = useState(0); @@ -1258,49 +1290,6 @@ export const DashboardApp = (): ReactElement => { useEffect(() => { reportLoadError("system", systemChannelId !== null && systemQuery.isError ? errorMessage(systemQuery.error) : null); }, [reportLoadError, systemChannelId, systemQuery.error, systemQuery.isError]); - useEffect(() => { - const handleRealtimeMessage = (event: Event): void => { - const detail: unknown = (event as CustomEvent).detail; - if (typeof detail !== "object" || detail === null || Array.isArray(detail)) return; - const message = detail as Record; - if (message.type !== "stream.state.changed" || typeof message.channelId !== "string" || - typeof message.payload !== "object" || message.payload === null || Array.isArray(message.payload)) return; - const payload = message.payload as Record; - if ((payload.state !== "online" && payload.state !== "offline") || - (payload.startedAt !== null && typeof payload.startedAt !== "string") || - typeof payload.changedAt !== "string" || !Number.isFinite(Date.parse(payload.changedAt)) || - (payload.checkedAt !== undefined && (typeof payload.checkedAt !== "string" || !Number.isFinite(Date.parse(payload.checkedAt))))) return; - const { channelId } = message; - const streamVersion = mergeAndRememberChannelStreamVersion({ - channelId, - streamState: payload.state, - streamStartedAt: payload.startedAt, - streamStateChangedAt: payload.changedAt, - streamStateCheckedAt: typeof payload.checkedAt === "string" ? payload.checkedAt : payload.changedAt, - ...(payload.controls === undefined ? {} : { controls: payload.controls as PanelChannelControls }), - }, latestStreamByChannel.current); - queryClient.setQueryData(queryKeys.channels(), (current) => - current === undefined || !current.channels.some((channel) => channel.channelId === channelId) - ? current - : { - ...current, - channels: current.channels.map((channel) => channel.channelId !== channelId ? channel : { - ...channel, - ...mergeChannelStreamVersion({ ...channel, ...streamVersion }, channel), - }), - }, - { updatedAt: channelsQuery.dataUpdatedAt }); - queryClient.setQueryData(queryKeys.channel(channelId, "overview"), (current) => - current?.channelId !== channelId ? current : { - ...current, - ...mergeChannelStreamVersion({ ...current, ...streamVersion }, current), - }, - { updatedAt: overviewQuery.dataUpdatedAt }); - }; - window.addEventListener("brobot:realtime", handleRealtimeMessage); - return () => window.removeEventListener("brobot:realtime", handleRealtimeMessage); - }, [channelsQuery.dataUpdatedAt, overviewQuery.dataUpdatedAt, queryClient]); - const reloadChannels = useCallback(async (): Promise => { const filter = { queryKey: queryKeys.channels(), exact: true } as const; await queryClient.cancelQueries(filter); @@ -1345,22 +1334,6 @@ export const DashboardApp = (): ReactElement => { await queryClient.cancelQueries(filter); await queryClient.invalidateQueries(filter); }, [queryClient, route]); - const reloadOverviewRef = useRef(reloadOverview); - useLayoutEffect(() => { reloadOverviewRef.current = reloadOverview; }, [reloadOverview]); - useEffect(() => { - const reconcileOnSocketConnect = (event: Event): void => { - const detail: unknown = (event as CustomEvent).detail; - if (typeof detail !== "object" || detail === null || Array.isArray(detail)) return; - const channelId = Reflect.get(detail, "channelId") as unknown; - const currentRoute = routeRef.current; - if (typeof channelId === "string" && - (currentRoute.kind === "channel" || currentRoute.kind === "module") && - currentRoute.channelId === channelId) void reloadOverviewRef.current(); - }; - window.addEventListener("brobot:realtime-connected", reconcileOnSocketConnect); - return () => window.removeEventListener("brobot:realtime-connected", reconcileOnSocketConnect); - }, []); - const refreshChannelState = useCallback(async (): Promise => { const refreshFallbackModules = selectedChannel?.modules === undefined ? reloadModules() : Promise.resolve(); const refreshSystem = systemChannelId === null ? Promise.resolve() : (async () => { @@ -1382,10 +1355,8 @@ export const DashboardApp = (): ReactElement => { if (document.visibilityState === "visible") void refreshChannelStateRef.current(); }; document.addEventListener("visibilitychange", refresh); - const interval = window.setInterval(refresh, 3 * 60 * 1000); return () => { document.removeEventListener("visibilitychange", refresh); - window.clearInterval(interval); }; }, []); diff --git a/src/dashboard/realtime.ts b/src/dashboard/realtime.ts index 4074c64a..a0f338b4 100644 --- a/src/dashboard/realtime.ts +++ b/src/dashboard/realtime.ts @@ -1,26 +1,21 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; -import type { - PanelEventFilters, -} from "../panel-contract"; -import type { - RealtimeEnvelope, - RealtimeEventLogHint, - RealtimeMessage, -} from "../realtime-contract"; -import type { AdsSchedule } from "../modules/ads/contracts"; import type { PanelChannelControl, PanelChannelControls } from "../panel-contract"; -import { REALTIME_PROTOCOL } from "../realtime-contract"; -import { eventToneEntries, type EventCode } from "./locale"; +import type { RealtimeEnvelope, RealtimeEventLogHint, RealtimeMessage } from "../realtime-contract"; +import type { AdsSchedule } from "../modules/ads/contracts"; +import { REALTIME_PROTOCOL, isPanelModuleRealtimeMessageType } from "../realtime-contract"; +import { + invalidateDashboardChannelQueries, + invalidateDashboardRealtimeMessage, + setDashboardRealtimeStatus, + useDashboardRealtimeStatus, +} from "./data/realtime"; const SOCKET_EXPIRED_CODE = 4001; const SOCKET_REVOKED_CODE = 4003; -const RECONNECT_GRACE_MS = 400; const INITIAL_RECONNECT_DELAY_MS = 250; const MAX_RECONNECT_DELAY_MS = 30_000; -const BATCH_DELAY_MS = 120; -const VARIABLE_REFRESH_DELAY_MS = 120; -const VARIABLE_REFRESH_MAX_WAIT_MS = 1_500; export type RealtimeFeedStatus = "connecting" | "connected" | "reconnecting" | "offline" | "renew"; @@ -63,6 +58,12 @@ const isPanelChannelControl = (value: unknown): value is PanelChannelControl => const isPanelChannelControls = (value: unknown): value is PanelChannelControls => isRecord(value) && isPanelChannelControl(value.mute) && isPanelChannelControl(value.pause); +const isVariablesChangedPayload = (value: unknown): boolean => isRecord(value) && + Array.isArray(value.set) && value.set.every((entry) => isRecord(entry) && + typeof entry.name === "string" && entry.name.length > 0 && + typeof entry.value === "number" && Number.isSafeInteger(entry.value)) && + Array.isArray(value.removed) && value.removed.every((name) => typeof name === "string" && name.length > 0); + const isKnownEnvelope = (value: Record): value is Record & { version: 1; id: string; @@ -70,13 +71,14 @@ const isKnownEnvelope = (value: Record): value is Record typeof value.version === "number" && value.version === 1 && +} => value.version === 1 && typeof value.id === "string" && value.id.length > 0 && typeof value.createdAt === "string" && value.createdAt.length > 0 && typeof value.channelId === "string" && value.channelId.length > 0 && (value.type === "system.hello" || value.type === "event_log.new" || - value.type === "variables.changed" || value.type === "ads.schedule.updated" || - value.type === "stream.state.changed"); + value.type === "variables.changed" || value.type === "overlay.changed" || + value.type === "ads.schedule.updated" || value.type === "stream.state.changed" || + typeof value.type === "string" && isPanelModuleRealtimeMessageType(value.type)); export const parseRealtimeMessage = (raw: string, channelId: string): RealtimeParseResult => { let parsed: unknown; @@ -90,6 +92,11 @@ export const parseRealtimeMessage = (raw: string, channelId: string): RealtimePa return { kind: "foreign-channel" }; } if (!isKnownEnvelope(parsed)) return { kind: "ignored" }; + if (isPanelModuleRealtimeMessageType(parsed.type)) { + return isRecord(parsed.payload) + ? { kind: "message", message: parsed as RealtimeMessage } + : { kind: "ignored" }; + } if (parsed.type === "system.hello") { return parsed.payload !== null && isRecord(parsed.payload) && Object.keys(parsed.payload).length === 0 ? { kind: "message", message: parsed as RealtimeEnvelope<"system.hello"> } @@ -100,6 +107,12 @@ export const parseRealtimeMessage = (raw: string, channelId: string): RealtimePa ? { kind: "message", message: parsed as RealtimeEnvelope<"variables.changed"> } : { kind: "ignored" }; } + if (parsed.type === "overlay.changed") { + return isRecord(parsed.payload) && typeof parsed.payload.overlayId === "string" && parsed.payload.overlayId.length > 0 && + typeof parsed.payload.revision === "number" && Number.isSafeInteger(parsed.payload.revision) + ? { kind: "message", message: parsed as RealtimeEnvelope<"overlay.changed"> } + : { kind: "ignored" }; + } if (parsed.type === "ads.schedule.updated") { return isRecord(parsed.payload) && isAdSchedule(parsed.payload.schedule) && typeof parsed.payload.asOf === "string" && parsed.payload.asOf.length > 0 @@ -117,41 +130,12 @@ export const parseRealtimeMessage = (raw: string, channelId: string): RealtimePa ? { kind: "message", message: parsed as RealtimeEnvelope<"stream.state.changed"> } : { kind: "ignored" }; } - if (!isRecord(parsed.payload) || !Array.isArray(parsed.payload.entries) || - !parsed.payload.entries.every(isHint)) return { kind: "ignored" }; - return { kind: "message", message: parsed as RealtimeEnvelope<"event_log.new"> }; -}; - -const eventMetadata = (code: string) => - Object.prototype.hasOwnProperty.call(eventToneEntries, code) ? eventToneEntries[code as EventCode] : null; - -/** Same origin logic as the event route: operational entries are module diagnostics. */ -export const realtimeHintMatchesFilters = ( - hint: RealtimeEventLogHint, - filters: PanelEventFilters, -): boolean => { - if (filters.module !== null && hint.moduleId !== filters.module) return false; - if (filters.person !== null && hint.actorUserId !== filters.person) return false; - const metadata = eventMetadata(hint.code); - if (filters.origin !== null) { - const isModuleDiagnostic = hint.moduleId !== "channel_events"; - if (filters.origin === "module" !== isModuleDiagnostic) return false; - } - const selectedTones = filters.tones !== undefined && filters.tones.length > 0 - ? filters.tones - : filters.tone === null ? [] : [filters.tone]; - if (selectedTones.length > 0 && (metadata === null || metadata.tone === undefined || !selectedTones.includes(metadata.tone))) return false; - return true; + return isRecord(parsed.payload) && Array.isArray(parsed.payload.entries) && + parsed.payload.entries.every(isHint) + ? { kind: "message", message: parsed as RealtimeEnvelope<"event_log.new"> } + : { kind: "ignored" }; }; -const filterKey = (filters: PanelEventFilters): string => [ - filters.origin ?? "", - filters.module ?? "", - filters.tone ?? "", - [...(filters.tones ?? [])].sort((a, b) => a.localeCompare(b)).join(","), - filters.person ?? "", -].join("\u001f"); - const reconnectDelay = (attempt: number): number => Math.min( MAX_RECONNECT_DELAY_MS, INITIAL_RECONNECT_DELAY_MS * (2 ** Math.min(attempt, 7)), @@ -167,372 +151,153 @@ const realtimeUrl = (channelId: string): string => { return url.toString(); }; -const relayRealtimeMessage = (message: RealtimeMessage): void => { - window.dispatchEvent(new CustomEvent("brobot:realtime", { detail: message })); -}; - -/** The shell keeps one panel socket open on pages without their own feed. */ -export const useRealtimePanelMessages = (channelId: string | null, enabled: boolean): void => { - useEffect(() => { - if (!enabled || channelId === null) return; - let disposed = false; - let socket: WebSocket | null = null; - let reconnectTimer: number | null = null; - let reconnectAttempt = 0; - const seenMessageIds = new Set(); - - const connect = (): void => { - if (disposed || typeof window.WebSocket !== "function") return; - try { - socket = new window.WebSocket(realtimeUrl(channelId), REALTIME_PROTOCOL); - socket.addEventListener("open", () => { - reconnectAttempt = 0; - window.dispatchEvent(new CustomEvent("brobot:realtime-connected", { detail: { channelId } })); - }); - socket.addEventListener("message", (event: MessageEvent) => { - if (typeof event.data !== "string") return; - const parsed = parseRealtimeMessage(event.data, channelId); - if (parsed.kind === "foreign-channel") { - socket?.close(1008, "Foreign channel"); - return; - } - if (parsed.kind !== "message" || seenMessageIds.has(parsed.message.id)) return; - seenMessageIds.add(parsed.message.id); - relayRealtimeMessage(parsed.message); - }); - socket.addEventListener("close", (event: CloseEvent) => { - if (disposed || socketNeedsRenewal(event) || reconnectTimer !== null) return; - const delay = reconnectDelay(reconnectAttempt); - reconnectAttempt += 1; - reconnectTimer = window.setTimeout(() => { - reconnectTimer = null; - connect(); - }, delay); - }); - } catch { - reconnectTimer = window.setTimeout(() => { - reconnectTimer = null; - connect(); - }, reconnectDelay(reconnectAttempt++)); - } - }; +interface PanelSocketConnection { + queryClient: QueryClient; + socket: WebSocket | null; + reconnectTimer: ReturnType | null; + cleanupTimer: ReturnType | null; + reconnectAttempt: number; + hasConnected: boolean; + disposed: boolean; + references: number; + seenMessageIds: Set; +} - connect(); - return () => { - disposed = true; - if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); - try { socket?.close(1000, "Panel route changed"); } catch { /* socket may already be closed */ } - }; - }, [channelId, enabled]); +const panelSocketConnections = new Map(); + +const disposePanelSocket = (channelId: string, connection: PanelSocketConnection): void => { + if (connection.disposed) return; + connection.disposed = true; + if (connection.reconnectTimer !== null) clearTimeout(connection.reconnectTimer); + if (connection.cleanupTimer !== null) clearTimeout(connection.cleanupTimer); + connection.reconnectTimer = null; + connection.cleanupTimer = null; + panelSocketConnections.delete(channelId); + setDashboardRealtimeStatus(channelId, "offline"); + const socket = connection.socket; + connection.socket = null; + try { socket?.close(1000, "Panel channel unmounted"); } catch { /* The socket may already be closed. */ } }; -const isVariablesChangedPayload = (value: unknown): boolean => isRecord(value) && - Array.isArray(value.set) && value.set.every((entry) => isRecord(entry) && - typeof entry.name === "string" && entry.name.length > 0 && - typeof entry.value === "number" && Number.isSafeInteger(entry.value)) && - Array.isArray(value.removed) && value.removed.every((name) => typeof name === "string" && name.length > 0); - -/** Keeps variable definitions and values current while their channel page is open. */ -export const useRealtimeVariableUpdates = ({ - channelId, - refresh, -}: { - channelId: string; - refresh: () => Promise; -}): void => { - const refreshRef = useRef(refresh); - - useEffect(() => { refreshRef.current = refresh; }, [refresh]); - - useEffect(() => { - let disposed = false; - let socket: WebSocket | null = null; - let reconnectTimer: number | null = null; - let variableRefreshTimer: number | null = null; - let maximumVariableRefreshTimer: number | null = null; - let variableRefreshInFlight = false; - let variableRefreshQueued = false; - let reconnectAttempt = 0; - let fatalProtocolError = false; - const hasPendingVariableRefresh = (): boolean => !disposed && variableRefreshQueued; - - const stopVariableRefreshTimers = (): void => { - if (variableRefreshTimer !== null) window.clearTimeout(variableRefreshTimer); - if (maximumVariableRefreshTimer !== null) window.clearTimeout(maximumVariableRefreshTimer); - variableRefreshTimer = null; - maximumVariableRefreshTimer = null; - }; - - const refreshVariables = async (): Promise => { - stopVariableRefreshTimers(); - if (disposed) return; - if (variableRefreshInFlight) { - variableRefreshQueued = true; - return; - } - variableRefreshQueued = false; - variableRefreshInFlight = true; - try { - await refreshRef.current(); - } catch { - // A later socket hint or reconnect will retry the refresh. - } finally { - variableRefreshInFlight = false; - if (hasPendingVariableRefresh()) void refreshVariables(); - } - }; - - const scheduleVariableRefresh = (): void => { - if (variableRefreshTimer !== null) window.clearTimeout(variableRefreshTimer); - variableRefreshTimer = window.setTimeout(() => { void refreshVariables(); }, VARIABLE_REFRESH_DELAY_MS); - maximumVariableRefreshTimer ??= window.setTimeout(() => { void refreshVariables(); }, VARIABLE_REFRESH_MAX_WAIT_MS); - }; - - const scheduleReconnect = (): void => { - if (disposed || fatalProtocolError || reconnectTimer !== null) return; - const delay = reconnectDelay(reconnectAttempt); - reconnectAttempt += 1; - reconnectTimer = window.setTimeout(() => { - reconnectTimer = null; - connect(); - }, delay); - }; +const schedulePanelReconnect = (channelId: string, connection: PanelSocketConnection): void => { + if (connection.disposed || connection.reconnectTimer !== null) return; + setDashboardRealtimeStatus(channelId, "reconnecting"); + const delay = reconnectDelay(connection.reconnectAttempt); + connection.reconnectAttempt += 1; + connection.reconnectTimer = setTimeout(() => { + connection.reconnectTimer = null; + connectPanelSocket(channelId, connection); + }, delay); +}; - const handleMessage = (event: MessageEvent): void => { +const connectPanelSocket = (channelId: string, connection: PanelSocketConnection): void => { + if (connection.disposed) return; + const WebSocketConstructor = window.WebSocket; + if (typeof WebSocketConstructor !== "function") { + setDashboardRealtimeStatus(channelId, "offline"); + return; + } + setDashboardRealtimeStatus(channelId, connection.reconnectAttempt === 0 ? "connecting" : "reconnecting"); + try { + const socket = new WebSocketConstructor(realtimeUrl(channelId), REALTIME_PROTOCOL); + connection.socket = socket; + socket.addEventListener("open", () => { + if (connection.disposed) return; + if (connection.hasConnected) invalidateDashboardChannelQueries(connection.queryClient, channelId); + connection.hasConnected = true; + connection.reconnectAttempt = 0; + setDashboardRealtimeStatus(channelId, "connected"); + }); + socket.addEventListener("message", (event: MessageEvent) => { if (typeof event.data !== "string") return; - const envelope = parseRealtimeMessage(event.data, channelId); - if (envelope.kind === "foreign-channel") { - fatalProtocolError = true; - try { socket?.close(1008, "Foreign channel"); } catch { /* socket may already be closed */ } + const parsed = parseRealtimeMessage(event.data, channelId); + if (parsed.kind === "foreign-channel") { + try { socket.close(1008, "Foreign channel"); } catch { /* The socket may already be closed. */ } return; } - if (envelope.kind !== "message") return; - relayRealtimeMessage(envelope.message); - if (envelope.message.type !== "variables.changed") return; - scheduleVariableRefresh(); - }; - - const handleOpen = (): void => { - reconnectAttempt = 0; - void refreshVariables(); - }; - - const handleClose = (event: CloseEvent): void => { - if (event.code === SOCKET_EXPIRED_CODE || event.code === SOCKET_REVOKED_CODE || event.code === 1008) { - fatalProtocolError = true; + if (parsed.kind !== "message" || connection.seenMessageIds.has(parsed.message.id)) return; + connection.seenMessageIds.add(parsed.message.id); + invalidateDashboardRealtimeMessage(connection.queryClient, parsed.message); + }); + socket.addEventListener("close", (event: CloseEvent) => { + if (connection.disposed) return; + if (connection.socket === socket) connection.socket = null; + if (socketNeedsRenewal(event) || event.code === 1008) { + setDashboardRealtimeStatus(channelId, "renew"); return; } - scheduleReconnect(); + schedulePanelReconnect(channelId, connection); + }); + } catch { + schedulePanelReconnect(channelId, connection); + } +}; + +const acquirePanelSocket = (channelId: string, queryClient: QueryClient): (() => void) => { + let connection = panelSocketConnections.get(channelId); + if (connection !== undefined && connection.queryClient !== queryClient) { + disposePanelSocket(channelId, connection); + connection = undefined; + } + if (connection === undefined) { + connection = { + queryClient, + socket: null, + reconnectTimer: null, + cleanupTimer: null, + reconnectAttempt: 0, + hasConnected: false, + disposed: false, + references: 0, + seenMessageIds: new Set(), }; + panelSocketConnections.set(channelId, connection); + connectPanelSocket(channelId, connection); + } + const activeConnection = connection; + if (activeConnection.cleanupTimer !== null) { + clearTimeout(activeConnection.cleanupTimer); + activeConnection.cleanupTimer = null; + } + activeConnection.references += 1; + let released = false; + return () => { + if (released) return; + released = true; + activeConnection.references -= 1; + if (activeConnection.references !== 0 || activeConnection.cleanupTimer !== null) return; + // React StrictMode remounts effects synchronously in development. Defer + // disposal for one task so the second lease reuses this channel socket. + activeConnection.cleanupTimer = setTimeout(() => { + if (activeConnection.references === 0) disposePanelSocket(channelId, activeConnection); + }, 0); + }; +}; - function connect(): void { - if (disposed || fatalProtocolError) return; - const WebSocketConstructor = window.WebSocket; - if (typeof WebSocketConstructor !== "function") return; - try { - socket = new WebSocketConstructor(realtimeUrl(channelId), REALTIME_PROTOCOL); - socket.addEventListener("open", handleOpen); - socket.addEventListener("message", handleMessage); - socket.addEventListener("close", handleClose); - } catch { - scheduleReconnect(); - } - } +/** The dashboard shell owns the channel socket, independent of the active page. */ +export const useRealtimePanelMessages = (channelId: string | null, enabled: boolean): void => { + const queryClient = useQueryClient(); - connect(); - return () => { - disposed = true; - if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); - stopVariableRefreshTimers(); - try { socket?.close(1000, "Leaving channel variables"); } catch { /* socket may already be closed */ } - }; - }, [channelId]); + useEffect(() => { + if (!enabled || channelId === null) return; + return acquirePanelSocket(channelId, queryClient); + }, [channelId, enabled, queryClient]); }; +/** Shares the shell socket state with the event feed without opening another connection. */ export const useRealtimeEventFeed = ({ channelId, - filters, - refresh, - refreshOnReturn, scrollToBeginning, - onRealtimeMessage, + refreshOnReturn, }: { channelId: string; - filters: PanelEventFilters; - refresh: () => void; - refreshOnReturn?: () => void; scrollToBeginning: () => void; - onRealtimeMessage?: (message: RealtimeMessage) => void; + refreshOnReturn?: () => void; }): RealtimeFeedState => { - const currentFilterKey = filterKey(filters); - const [status, setStatus] = useState("connecting"); - const socketRef = useRef(null); - const reconnectTimerRef = useRef(null); - const reconnectGraceTimerRef = useRef(null); - const batchTimerRef = useRef(null); - const reconnectAttemptRef = useRef(0); - const hasConnectedRef = useRef(false); - const fatalProtocolErrorRef = useRef(false); - const seenMessageIdsRef = useRef(new Set()); - const refreshRef = useRef(refresh); - const refreshOnReturnRef = useRef(refreshOnReturn); - const scrollToBeginningRef = useRef(scrollToBeginning); - const onRealtimeMessageRef = useRef(onRealtimeMessage); - const filtersRef = useRef(filters); - - useEffect(() => { - refreshRef.current = refresh; - refreshOnReturnRef.current = refreshOnReturn; - scrollToBeginningRef.current = scrollToBeginning; - onRealtimeMessageRef.current = onRealtimeMessage; - filtersRef.current = filters; - }, [filters, onRealtimeMessage, refresh, refreshOnReturn, scrollToBeginning]); - - const scheduleBatch = useCallback((): void => { - if (batchTimerRef.current !== null) return; - batchTimerRef.current = window.setTimeout(() => { - batchTimerRef.current = null; - refreshRef.current(); - }, BATCH_DELAY_MS); - }, []); - + const status = useDashboardRealtimeStatus(channelId); const jumpToBeginning = useCallback((): void => { - scrollToBeginningRef.current(); - if (refreshOnReturnRef.current === undefined) refreshRef.current(); - else refreshOnReturnRef.current(); - }, []); - - useEffect(() => { - if (batchTimerRef.current !== null) { - window.clearTimeout(batchTimerRef.current); - batchTimerRef.current = null; - } - }, [channelId, currentFilterKey]); - - useEffect(() => { - let disposed = false; - let socket: WebSocket | null = null; - reconnectAttemptRef.current = 0; - hasConnectedRef.current = false; - fatalProtocolErrorRef.current = false; - seenMessageIdsRef.current.clear(); - - const clearReconnectTimers = (): void => { - if (reconnectTimerRef.current !== null) { - window.clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } - if (reconnectGraceTimerRef.current !== null) { - window.clearTimeout(reconnectGraceTimerRef.current); - reconnectGraceTimerRef.current = null; - } - }; - - const scheduleReconnect = (): void => { - if (disposed || fatalProtocolErrorRef.current || reconnectTimerRef.current !== null) return; - const delay = reconnectDelay(reconnectAttemptRef.current); - reconnectAttemptRef.current += 1; - reconnectGraceTimerRef.current = window.setTimeout(() => { - reconnectGraceTimerRef.current = null; - if (!disposed && !fatalProtocolErrorRef.current) setStatus("reconnecting"); - }, RECONNECT_GRACE_MS); - reconnectTimerRef.current = window.setTimeout(() => { - reconnectTimerRef.current = null; - connect(); - }, delay); - }; - - const closeWithProtocolError = (): void => { - fatalProtocolErrorRef.current = true; - setStatus("offline"); - try { - socket?.close(1008, "Fremder Kanal"); - } catch { - // The socket may already be closed between receipt and close. - } - }; - - const handleMessage = (event: MessageEvent): void => { - if (typeof event.data !== "string") return; - const parsed = parseRealtimeMessage(event.data, channelId); - if (parsed.kind === "foreign-channel") { - closeWithProtocolError(); - return; - } - if (parsed.kind !== "message" || seenMessageIdsRef.current.has(parsed.message.id)) return; - seenMessageIdsRef.current.add(parsed.message.id); - onRealtimeMessageRef.current?.(parsed.message); - relayRealtimeMessage(parsed.message); - if (parsed.message.type !== "event_log.new") return; - const hints = parsed.message.payload.entries.filter((hint) => realtimeHintMatchesFilters(hint, filtersRef.current)); - if (hints.length > 0) scheduleBatch(); - }; - - const handleClose = (event: CloseEvent): void => { - socketRef.current = null; - if (disposed) return; - if (reconnectGraceTimerRef.current !== null) { - window.clearTimeout(reconnectGraceTimerRef.current); - reconnectGraceTimerRef.current = null; - } - if (socketNeedsRenewal(event)) { - fatalProtocolErrorRef.current = true; - setStatus("renew"); - return; - } - if (!fatalProtocolErrorRef.current) scheduleReconnect(); - }; - - const handleOpen = (): void => { - if (disposed) return; - window.dispatchEvent(new CustomEvent("brobot:realtime-connected", { detail: { channelId } })); - const isReconnection = hasConnectedRef.current; - hasConnectedRef.current = true; - reconnectAttemptRef.current = 0; - if (reconnectGraceTimerRef.current !== null) { - window.clearTimeout(reconnectGraceTimerRef.current); - reconnectGraceTimerRef.current = null; - } - setStatus("connected"); - if (isReconnection) { - refreshRef.current(); - } - }; - - function connect(): void { - if (disposed || fatalProtocolErrorRef.current) return; - const WebSocketConstructor = window.WebSocket; - if (typeof WebSocketConstructor !== "function") { - setStatus("offline"); - return; - } - try { - socket = new WebSocketConstructor(realtimeUrl(channelId), REALTIME_PROTOCOL); - socketRef.current = socket; - socket.addEventListener("open", handleOpen); - socket.addEventListener("message", handleMessage); - socket.addEventListener("close", handleClose); - } catch { - scheduleReconnect(); - } - } - - connect(); - return () => { - disposed = true; - clearReconnectTimers(); - if (batchTimerRef.current !== null) { - window.clearTimeout(batchTimerRef.current); - batchTimerRef.current = null; - } - if (socketRef.current === socket) socketRef.current = null; - try { - socket?.close(1000, "Panel verlassen"); - } catch { - // The socket may already be closed. - } - }; - }, [channelId, scheduleBatch]); - + scrollToBeginning(); + refreshOnReturn?.(); + }, [refreshOnReturn, scrollToBeginning]); return { status, jumpToBeginning }; }; diff --git a/src/dashboard/stream-manager.tsx b/src/dashboard/stream-manager.tsx index c9f454f5..0877a716 100644 --- a/src/dashboard/stream-manager.tsx +++ b/src/dashboard/stream-manager.tsx @@ -1,16 +1,15 @@ -import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ComponentType, type LazyExoticComponent, type ReactElement } from "react"; +import { lazy, Suspense, useEffect, useRef, useState, type ComponentType, type LazyExoticComponent, type ReactElement } from "react"; import type { ChannelStreamState } from "../contracts/values"; import { canManage } from "../contracts/values"; -import type { PanelChannelOverview, PanelEventEntry, PanelModuleState } from "../panel-contract"; +import type { PanelChannelOverview, PanelModuleState } from "../panel-contract"; import type { ModuleImmediateActionProperties } from "../modules/contract"; import { MODULES } from "../modules/registry"; -import { fetchEvents } from "./api"; import { dashboardLanguage, dashboardTexts, eventText, formatNumber, formatStreamManagerFeedTime, formatTimestamp, immediateActionUnavailableReasonText } from "./locale"; import { channelPanelTexts } from "./labels"; import { eventCause, eventDetail, eventMetadata } from "./events/model"; import { emptyEventFilter } from "./events/model"; -import { useRealtimeEventFeed } from "./realtime"; +import { useEventsQuery } from "./data/lists"; import { evaluateImmediateActionAvailability } from "./immediate-action-availability"; import { Button, LoadState, notify, Popover, Skeleton } from "./ui"; import { dashboardRoutePath, type DashboardRoute } from "./router"; @@ -249,61 +248,30 @@ export const ImmediateActions = ({ channelId, streamState, canManage = false, mo */ export const WarningsAndErrorsFeed = ({ channelId, onNavigate }: { channelId: string; onNavigate?: (route: DashboardRoute) => void }): ReactElement => { const texts = dashboardTexts(); - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); - const [failed, setFailed] = useState(false); + const filters = { ...emptyEventFilter, tones: ["warning", "error"] as const }; + const query = useEventsQuery(channelId, filters); + const entries = (query.data?.pages[0]?.entries ?? []) + .filter((entry) => { + const tone = eventMetadata(entry.code)?.tone; + return tone === "warning" || tone === "error"; + }) + .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)) + .slice(0, 3); const allAlertsRoute: DashboardRoute = { kind: "channel", channelId, section: "events", filters: { ...emptyEventFilter, tones: ["warning", "error"] }, }; - const refreshFirstPage = useCallback(async (): Promise => { - try { - const response = await fetchEvents(channelId); - setFailed(false); - setEntries(response.entries - .filter((entry) => { - const tone = eventMetadata(entry.code)?.tone; - return tone === "warning" || tone === "error"; - }) - .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)) - .slice(0, 3)); - } catch { - notify({ tone: "error", message: texts.events.connectionLost }); - } - }, [channelId, texts.events.connectionLost]); - const scrollToBeginning = useCallback((): void => undefined, []); - useRealtimeEventFeed({ channelId, filters: emptyEventFilter, refresh: () => { void refreshFirstPage(); }, scrollToBeginning }); - useEffect(() => { - const controller = new AbortController(); - fetchEvents(channelId, null, controller.signal) - .then((response) => { - if (controller.signal.aborted) return; - setFailed(false); - setEntries(response.entries - .filter((entry) => { - const tone = eventMetadata(entry.code)?.tone; - return tone === "warning" || tone === "error"; - }) - .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)) - .slice(0, 3)); - }) - .catch(() => { - if (controller.signal.aborted) return; - setFailed(true); - notify({ tone: "error", message: texts.events.connectionLost }); - }) - .finally(() => { if (!controller.signal.aborted) setLoading(false); }); - return () => { controller.abort(); }; - }, [channelId, texts.events.connectionLost]); + if (query.isError) notify({ tone: "error", message: texts.events.connectionLost }); + }, [query.error, query.isError, texts.events.connectionLost]); return (
} empty={

{texts.streamManager.feedEmpty}

} diff --git a/src/modules/ads/panel/index.tsx b/src/modules/ads/panel/index.tsx index a79dffb4..82da841a 100644 --- a/src/modules/ads/panel/index.tsx +++ b/src/modules/ads/panel/index.tsx @@ -1,13 +1,12 @@ -import { useEffect, useRef, useState, type ReactElement } from "react"; +import { useEffect, useState, type ReactElement } from "react"; import type { DashboardLanguage } from "../../../dashboard/locale"; import { Button, EmptyCellValue, Icon, LoadState, Skeleton, notify } from "../../../dashboard/ui"; -import type { AdsScheduleResponse } from "../contracts"; import { loadAdsSchedule, snoozeAds } from "./service"; import { adsPanelTexts } from "./locale"; - -const pickNewestSchedule = (left: AdsScheduleResponse, right: AdsScheduleResponse): AdsScheduleResponse => - Date.parse(left.asOf ?? "") >= Date.parse(right.asOf ?? "") ? left : right; +import { runModuleQueryWrite, useModuleQuery } from "../../../dashboard/data"; +import { useDashboardQueryClient } from "../../../dashboard/data"; +import { useDashboardRealtimeStatus } from "../../../dashboard/data/realtime"; const formatTimestamp = (value: string | null, language: DashboardLanguage): string => { if (value === null) return "—"; @@ -22,53 +21,18 @@ const formatTimestamp = (value: string | null, language: DashboardLanguage): str export const AdsPanel = ({ channelId, language = "de" }: { channelId: string; language?: DashboardLanguage }): ReactElement => { const labels = adsPanelTexts(language); const loadError = labels.loadError; - const [schedule, setSchedule] = useState(null); - const latestRealtimeRef = useRef<{ schedule: AdsScheduleResponse["schedule"]; asOf: string } | null>(null); - const [loadStatus, setLoadStatus] = useState<"loading" | "error" | "success">("loading"); + const realtimeStatus = useDashboardRealtimeStatus(channelId); + const queryClient = useDashboardQueryClient(); + const scheduleQuery = useModuleQuery(channelId, "ads", "schedule", (signal) => loadAdsSchedule(channelId, signal), { + refetchInterval: realtimeStatus === "connected" ? false : 60_000, + }); + const schedule = scheduleQuery.data ?? null; + const loadStatus = scheduleQuery.isPending ? "loading" : scheduleQuery.isError ? "error" : "success"; const [snoozeBusy, setSnoozeBusy] = useState(false); useEffect(() => { - let active = true; - latestRealtimeRef.current = null; - void loadAdsSchedule(channelId).then((loaded) => { - if (!active) return; - setSchedule((current) => { - const updates = [loaded, ...(current === null ? [] : [current])]; - const buffered = latestRealtimeRef.current; - if (buffered !== null) { - const newest = updates.reduce((newestSoFar, candidate) => pickNewestSchedule(newestSoFar, candidate), loaded); - if (Date.parse(buffered.asOf) > Date.parse(newest.asOf ?? "")) { - return { ...loaded, schedule: buffered.schedule, asOf: buffered.asOf }; - } - } - return updates.reduce((newestSoFar, candidate) => pickNewestSchedule(newestSoFar, candidate), loaded); - }); - setLoadStatus("success"); - }).catch(() => { if (active) { setLoadStatus("error"); notify({ tone: "error", message: loadError }); } }); - return () => { active = false; }; - }, [channelId, loadError]); - - useEffect(() => { - const handleRealtimeMessage = (event: Event): void => { - const detail: unknown = (event as CustomEvent).detail; - if (typeof detail !== "object" || detail === null || Array.isArray(detail)) return; - const message = detail as Record; - if (message.type !== "ads.schedule.updated" || message.channelId !== channelId || - typeof message.payload !== "object" || message.payload === null || Array.isArray(message.payload)) return; - const payload = message.payload as Record; - if (typeof payload.asOf !== "string" || typeof payload.schedule !== "object" || payload.schedule === null || Array.isArray(payload.schedule)) return; - const update = { schedule: payload.schedule as AdsScheduleResponse["schedule"], asOf: payload.asOf }; - const buffered = latestRealtimeRef.current; - if (buffered === null || Date.parse(update.asOf) > Date.parse(buffered.asOf)) latestRealtimeRef.current = update; - setSchedule((current) => current === null || Date.parse(update.asOf) <= Date.parse(current.asOf ?? "") ? current : { - ...current, - schedule: update.schedule, - asOf: update.asOf, - }); - }; - window.addEventListener("brobot:realtime", handleRealtimeMessage); - return () => window.removeEventListener("brobot:realtime", handleRealtimeMessage); - }, [channelId]); + if (scheduleQuery.isError) notify({ tone: "error", message: loadError }); + }, [loadError, scheduleQuery.error, scheduleQuery.isError]); const snoozeCount = schedule?.schedule.snoozeCount ?? null; const snoozeButtonDisabled = schedule === null || snoozeBusy || !schedule.snoozeScopeAvailable || snoozeCount === null || snoozeCount <= 0; @@ -86,7 +50,10 @@ export const AdsPanel = ({ channelId, language = "de" }: { channelId: string; la if (schedule === null) return; setSnoozeBusy(true); try { - setSchedule(await snoozeAds(channelId)); + await runModuleQueryWrite(queryClient, channelId, "ads", "schedule", () => snoozeAds(channelId), { + baselineRevision: null, + updateCache: (_current, result) => result, + }); notify({ tone: "success", message: labels.snoozeSuccess }); } catch { notify({ tone: "error", message: labels.snoozeError }); diff --git a/src/modules/ads/panel/service.ts b/src/modules/ads/panel/service.ts index f86be683..637a2407 100644 --- a/src/modules/ads/panel/service.ts +++ b/src/modules/ads/panel/service.ts @@ -29,8 +29,8 @@ const json = async (response: Response): Promise => { const schedulePathFor = (channelId: string): string => `/api/channels/${encodeURIComponent(channelId)}/modules/ads/schedule`; -export const loadAdsSchedule = async (channelId: string): Promise => { - const response = await fetch(schedulePathFor(channelId)); +export const loadAdsSchedule = async (channelId: string, signal?: AbortSignal): Promise => { + const response = await fetch(schedulePathFor(channelId), signal === undefined ? {} : { signal }); const loadedResponse = await json | null>(response); const loaded = loadedResponse !== null && typeof loadedResponse === "object" ? loadedResponse : {}; return { diff --git a/src/modules/belabox/panel/immediate-actions.tsx b/src/modules/belabox/panel/immediate-actions.tsx index 4c1c499c..98189940 100644 --- a/src/modules/belabox/panel/immediate-actions.tsx +++ b/src/modules/belabox/panel/immediate-actions.tsx @@ -1,52 +1,40 @@ -import { useEffect, useState, type ReactElement } from "react"; +import { useEffect, useRef, useState, type ReactElement } from "react"; import { Button, Icon, notify } from "../../../dashboard/ui"; import type { ModuleImmediateActionProperties } from "../../contract"; import type { BelaboxStatusResponse } from "../contracts"; import { belaboxPanelTexts, belaboxReasonText } from "./locale"; import { loadBelaboxStatus, testBelaboxConnection } from "./service"; +import { refetchModuleQueryData, useDashboardQueryClient, useModuleQuery } from "../../../dashboard/data"; +import { useDashboardRealtimeStatus } from "../../../dashboard/data/realtime"; const language = typeof navigator === "undefined" || !navigator.language.toLowerCase().startsWith("en") ? "de" : "en"; -const RETRY_DELAYS_MS = [5_000, 10_000, 30_000]; - const BelaboxStatusAction = ({ channelId, canManage = false, availabilityReason }: ModuleImmediateActionProperties): ReactElement => { const labels = belaboxPanelTexts(language); - const [status, setStatus] = useState(null); + const realtimeStatus = useDashboardRealtimeStatus(channelId); + const queryClient = useDashboardQueryClient(); + const statusQuery = useModuleQuery(channelId, "belabox", "status", (signal) => loadBelaboxStatus(channelId, signal), { + refetchInterval: realtimeStatus === "connected" ? false : (query) => { + const current = query.state.data; + return current?.pollingDesired === true + ? Math.max(5, current.intervalSeconds) * 1_000 + : current === undefined ? 5_000 : 60_000; + }, + }); + const status = statusQuery.data ?? null; const [checking, setChecking] = useState(false); const [result, setResult] = useState(""); + const suppressNextStatusResultClear = useRef(false); useEffect(() => { - let active = true; - let timer: number | undefined; - // Failed loads are retried with a bounded backoff until a status arrives. - const load = (attempt: number): void => { - void loadBelaboxStatus(channelId).then((next) => { - if (active) { - setStatus(next); - setResult(""); - } - }).catch(() => { - if (active) timer = window.setTimeout(() => { load(attempt + 1); }, RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)]); - }); - }; - load(0); - return () => { - active = false; - window.clearTimeout(timer); - }; - }, [availabilityReason, channelId]); - - useEffect(() => { - if (!status?.pollingDesired) return; - const timer = window.setInterval(() => { - void loadBelaboxStatus(channelId).then((next) => { - setStatus(next); - setResult(""); - }).catch(() => undefined); - }, Math.max(5, status.intervalSeconds) * 1_000); - return () => window.clearInterval(timer); - }, [channelId, status?.intervalSeconds, status?.pollingDesired]); + if (statusQuery.dataUpdatedAt === 0) return; + if (suppressNextStatusResultClear.current) { + suppressNextStatusResultClear.current = false; + return; + } + setResult(""); + }, [statusQuery.dataUpdatedAt]); const checkNow = async (): Promise => { if (checking || availabilityReason !== null || !canManage) return; @@ -63,8 +51,8 @@ const BelaboxStatusAction = ({ channelId, canManage = false, availabilityReason const message = `${outcome.connected ? labels.connected : labels.disconnected} · ${labels.bitrate}: ${String(outcome.bitrateKbps)} kbps`; setResult(message); notify({ tone: outcome.connected ? "success" : "info", message }); - const next = await loadBelaboxStatus(channelId).catch(() => null); - if (next !== null) setStatus(next); + suppressNextStatusResultClear.current = true; + await refetchModuleQueryData(queryClient, channelId, "belabox", "status"); } catch { setResult(labels.testFailed); notify({ tone: "error", message: labels.testFailed }); diff --git a/src/modules/belabox/panel/index.tsx b/src/modules/belabox/panel/index.tsx index 2b87a641..9696e4f9 100644 --- a/src/modules/belabox/panel/index.tsx +++ b/src/modules/belabox/panel/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type ReactElement } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactElement } from "react"; import { PanelApiError } from "../../../contracts/panel-error"; import { Button, ConfirmDialog, Field, InspectorActions, InspectorFieldRow, InspectorSection, LoadState, notify, Skeleton } from "../../../dashboard/ui"; @@ -7,6 +7,8 @@ import type { BelaboxHistoryPoint, BelaboxStatusResponse, BelaboxStreamSummary } import { belaboxReasonText, belaboxPanelTexts } from "./locale"; import { BelaboxHistorySection } from "./history-chart"; import { loadBelaboxHistory, loadBelaboxStatus, loadBelaboxStreams, removeBelaboxStatsUrl, replaceBelaboxStatsUrl, retryBelaboxPolling, testBelaboxConnection } from "./service"; +import { moduleQueryKey, refetchModuleQueryData, useDashboardQueryClient, useModuleQuery } from "../../../dashboard/data"; +import { useDashboardRealtimeStatus } from "../../../dashboard/data/realtime"; const errorCode = (error: unknown): string | null => error instanceof PanelApiError ? error.code : null; const IDLE_STATUS_REFRESH_MS = 60_000; @@ -18,95 +20,66 @@ const statusTimestamp = (value: string, locale: string): string => { export default function BelaboxPanel({ channelId, language = "de", canManage = false, settingsRefreshToken = 0 }: ModulePanelProperties): ReactElement { const labels = belaboxPanelTexts(language); - const [status, setStatus] = useState(null); + const realtimeStatus = useDashboardRealtimeStatus(channelId); + const queryClient = useDashboardQueryClient(); + const statusQuery = useModuleQuery(channelId, "belabox", "status", (signal) => loadBelaboxStatus(channelId, signal), { + refetchInterval: realtimeStatus === "connected" ? false : (query) => { + const current = query.state.data; + return current?.pollingDesired === true + ? Math.max(5, current.intervalSeconds) * 1_000 + : IDLE_STATUS_REFRESH_MS; + }, + }); + const status = statusQuery.data ?? null; const [url, setUrl] = useState(""); const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false); const [removeError, setRemoveError] = useState(null); const [busy, setBusy] = useState(false); - const [loadFailed, setLoadFailed] = useState(false); + const loadFailed = statusQuery.isError; const [testOutcome, setTestOutcome] = useState(null); - const [history, setHistory] = useState([]); - const [streams, setStreams] = useState([]); const [historyRange, setHistoryRange] = useState<"live" | "stream">("live"); const [selectedStreamId, setSelectedStreamId] = useState(null); const pollingInactive = status?.pollingDesired === true && !status.polling; - const statusRefreshInterval = status === null - ? null - : status.pollingDesired ? Math.max(5, status.intervalSeconds) * 1_000 : IDLE_STATUS_REFRESH_MS; + const loadErrorNotified = useRef(false); const refresh = useCallback(async (): Promise => { - setStatus(await loadBelaboxStatus(channelId)); - }, [channelId]); + await refetchModuleQueryData(queryClient, channelId, "belabox", "status"); + }, [channelId, queryClient]); useEffect(() => { - let active = true; - loadBelaboxStatus(channelId).then((next) => { - if (active) { - setStatus(next); - setLoadFailed(false); - } - }).catch(() => { - if (active) { - setLoadFailed(true); - notify({ tone: "error", message: labels.testFailed }); - } - }); - return () => { active = false; }; - }, [channelId, labels.testFailed, settingsRefreshToken]); + if (settingsRefreshToken === 0) return; + void queryClient.invalidateQueries({ queryKey: moduleQueryKey(channelId, "belabox", "status"), exact: true }); + }, [channelId, queryClient, settingsRefreshToken]); useEffect(() => { - if (statusRefreshInterval === null) return; - const timer = window.setInterval(() => { - void refresh().catch(() => undefined); - }, statusRefreshInterval); - return () => window.clearInterval(timer); - }, [refresh, statusRefreshInterval]); + if (statusQuery.data !== undefined) loadErrorNotified.current = false; + if (!statusQuery.isError || loadErrorNotified.current) return; + loadErrorNotified.current = true; + notify({ tone: "error", message: labels.testFailed }); + }, [labels.testFailed, statusQuery.data, statusQuery.error, statusQuery.isError]); useEffect(() => { if (pollingInactive) notify({ tone: "error", message: labels.pollingInactive }); }, [labels.pollingInactive, pollingInactive]); - useEffect(() => { - if (status?.mode !== "interval") return; - let active = true; - const refresh = async (): Promise => { - try { - const next = await loadBelaboxStreams(channelId); - if (active) { - setStreams(next); - setSelectedStreamId((current) => { - if (current !== null && next.some((stream) => stream.streamId === current)) return current; - if (status.belaboxStreamId !== null && next.some((stream) => stream.streamId === status.belaboxStreamId)) { - return status.belaboxStreamId; - } - return next[0]?.streamId ?? null; - }); - } - } catch { - // Keep the last known list if a background refresh fails. - } - }; - void refresh(); - const timer = window.setInterval(() => { void refresh(); }, 60_000); - return () => { active = false; window.clearInterval(timer); }; - }, [channelId, status?.belaboxStreamId, status?.mode]); - - useEffect(() => { - if (status?.mode !== "interval") return; - let active = true; - const refresh = async (): Promise => { - try { - const streamId = historyRange === "stream" ? selectedStreamId ?? status.belaboxStreamId ?? undefined : undefined; - const next = await loadBelaboxHistory(channelId, historyRange, streamId); - if (active) setHistory(next); - } catch { - // Preserve existing points while the next scheduled refresh retries. - } - }; - void refresh(); - const timer = window.setInterval(() => { void refresh(); }, 15_000); - return () => { active = false; window.clearInterval(timer); }; - }, [channelId, historyRange, selectedStreamId, status?.belaboxStreamId, status?.mode]); + const streamsQuery = useModuleQuery(channelId, "belabox", "streams", (signal) => loadBelaboxStreams(channelId, signal), { + enabled: status?.mode === "interval", + refetchInterval: realtimeStatus === "connected" ? false : 60_000, + }); + const streams: readonly BelaboxStreamSummary[] = streamsQuery.data ?? []; + const currentStreamId = selectedStreamId !== null && streams.some((stream) => stream.streamId === selectedStreamId) + ? selectedStreamId + : status?.belaboxStreamId !== null && status?.belaboxStreamId !== undefined && streams.some((stream) => stream.streamId === status.belaboxStreamId) + ? status.belaboxStreamId + : streams[0]?.streamId ?? null; + const streamId = historyRange === "stream" ? currentStreamId ?? undefined : undefined; + const historyPart = historyRange === "live" ? "history-live" : `history-stream-${streamId ?? "latest"}`; + const historyQuery = useModuleQuery(channelId, "belabox", historyPart, + (signal) => loadBelaboxHistory(channelId, historyRange, streamId, signal), { + enabled: status?.mode === "interval", + refetchInterval: realtimeStatus === "connected" ? false : 15_000, + }); + const history: readonly BelaboxHistoryPoint[] = historyQuery.data ?? []; const save = async (): Promise => { if (!canManage || busy || url.length === 0) return; @@ -203,7 +176,7 @@ export default function BelaboxPanel({ channelId, language = "de", canManage = f return; } // The delete succeeded: reflect it locally, independent of the refresh below. - setStatus({ + queryClient.setQueryData(moduleQueryKey(channelId, "belabox", "status"), { configured: false, updatedAt: null, mode: status.mode, @@ -298,7 +271,7 @@ export default function BelaboxPanel({ channelId, language = "de", canManage = f onConfirm={() => { void remove(); }} pending={busy} danger {...(removeError === null ? {} : { error: removeError })} />
; } diff --git a/src/modules/belabox/panel/service.ts b/src/modules/belabox/panel/service.ts index f5c1eb25..b35a8efd 100644 --- a/src/modules/belabox/panel/service.ts +++ b/src/modules/belabox/panel/service.ts @@ -30,21 +30,22 @@ const mutate = async (channelId: string, path: string, method: "PUT" | " })); }; -export const loadBelaboxStatus = async (channelId: string): Promise => - readJson(await fetch(`${modulePath(channelId)}/status`)); +export const loadBelaboxStatus = async (channelId: string, signal?: AbortSignal): Promise => + readJson(await fetch(`${modulePath(channelId)}/status`, signal === undefined ? {} : { signal })); export const loadBelaboxHistory = async ( channelId: string, range: "live" | "stream", streamId?: string, + signal?: AbortSignal, ): Promise => { const query = new URLSearchParams({ range }); if (streamId !== undefined) query.set("streamId", streamId); - return readJson(await fetch(`${modulePath(channelId)}/history?${query.toString()}`)); + return readJson(await fetch(`${modulePath(channelId)}/history?${query.toString()}`, signal === undefined ? {} : { signal })); }; -export const loadBelaboxStreams = async (channelId: string): Promise => - readJson(await fetch(`${modulePath(channelId)}/streams`)); +export const loadBelaboxStreams = async (channelId: string, signal?: AbortSignal): Promise => + readJson(await fetch(`${modulePath(channelId)}/streams`, signal === undefined ? {} : { signal })); export const replaceBelaboxStatsUrl = async (channelId: string, url: string): Promise => { await mutate<{ configured: true }>(channelId, "/stats-url", "PUT", { url }); diff --git a/src/modules/chat_voting/panel/index.tsx b/src/modules/chat_voting/panel/index.tsx index 02951f25..cbd1c4d0 100644 --- a/src/modules/chat_voting/panel/index.tsx +++ b/src/modules/chat_voting/panel/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type ReactElement } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactElement } from "react"; import { Button, Field, Led, LoadState, notify, NumberField, SegmentedControl, Select, Skeleton, Switch } from "../../../dashboard/ui"; import type { ModulePanelProperties } from "../../contract"; @@ -9,6 +9,8 @@ import { compactDateRange, dateText, timeText } from "./date-range"; import { chatVotingPanelTexts } from "./locale-panel"; import type { ChatVotingPanelState } from "./service"; import { approveChatVotingTerm, closeChatVoting, loadChatVotingState, startChatVoting } from "./service"; +import { refetchModuleQueryData, useDashboardQueryClient, useModuleQuery } from "../../../dashboard/data"; +import { useDashboardRealtimeStatus } from "../../../dashboard/data/realtime"; type DurationPreset = "open" | "one" | "two" | "five" | "custom"; @@ -54,40 +56,45 @@ const createVoteDraft = (channelId: string, defaultDurationSeconds: number): Vot export const ChatVotingPanel = ({ channelId, language = "de", canOperate = true }: ModulePanelProperties): ReactElement => { const labels = chatVotingPanelTexts(language); const [state, setState] = useState(null); - const [loadFailed, setLoadFailed] = useState(false); - const [loadErrorNotified, setLoadErrorNotified] = useState(false); + const loadErrorNotified = useRef(false); const [busy, setBusy] = useState(false); const [draftState, setDraftState] = useState(null); + const realtimeStatus = useDashboardRealtimeStatus(channelId); + const queryClient = useDashboardQueryClient(); + const stateQuery = useModuleQuery(channelId, "chat_voting", "panel", (signal) => loadChatVotingState(channelId, signal), { + refetchInterval: realtimeStatus === "connected" ? false : 2_000, + }); + + const applyState = useCallback((next: ChatVotingPanelState): void => { + setState(next); + setDraftState((current) => current?.channelId === channelId + ? current + : createVoteDraft(channelId, next.defaultDurationSeconds)); + loadErrorNotified.current = false; + }, [channelId]); + + useEffect(() => { + if (stateQuery.data !== undefined) applyState(stateQuery.data); + }, [applyState, stateQuery.data]); + + useEffect(() => { + if (!stateQuery.isError) return; + if (!loadErrorNotified.current) { + loadErrorNotified.current = true; + notify({ tone: "error", message: labels.loadError }); + } + }, [labels.loadError, stateQuery.error, stateQuery.isError]); const refresh = useCallback(async (): Promise => { try { - const next = await loadChatVotingState(channelId); - setState(next); - setDraftState((current) => current?.channelId === channelId - ? current - : createVoteDraft(channelId, next.defaultDurationSeconds)); - setLoadFailed(false); - setLoadErrorNotified(false); + applyState(await refetchModuleQueryData(queryClient, channelId, "chat_voting", "panel")); } catch { - setLoadFailed(true); - if (!loadErrorNotified) { - setLoadErrorNotified(true); + if (!loadErrorNotified.current) { + loadErrorNotified.current = true; notify({ tone: "error", message: labels.loadError }); } } - }, [channelId, labels.loadError, loadErrorNotified]); - - useEffect(() => { void Promise.resolve().then(() => refresh()); }, [refresh]); - - useEffect(() => { - const poll = (): void => { if (document.visibilityState === "visible") void refresh(); }; - const timer = window.setInterval(poll, 2_000); - document.addEventListener("visibilitychange", poll); - return () => { - window.clearInterval(timer); - document.removeEventListener("visibilitychange", poll); - }; - }, [refresh]); + }, [applyState, channelId, labels.loadError, queryClient]); const draft = draftState?.channelId === channelId ? draftState @@ -158,7 +165,7 @@ export const ChatVotingPanel = ({ channelId, language = "de", canOperate = true if (state === null) return
} empty={

{labels.noVote}

} diff --git a/src/modules/chat_voting/panel/service.ts b/src/modules/chat_voting/panel/service.ts index 515fed9f..c3dac032 100644 --- a/src/modules/chat_voting/panel/service.ts +++ b/src/modules/chat_voting/panel/service.ts @@ -26,8 +26,8 @@ const readJson = async (response: Response): Promise => { const route = (channelId: string, path: string): string => `/api/channels/${encodeURIComponent(channelId)}/modules/chat_voting${path}`; -export const loadChatVotingState = async (channelId: string): Promise => - readJson(await fetch(route(channelId, "/current"))); +export const loadChatVotingState = async (channelId: string, signal?: AbortSignal): Promise => + readJson(await fetch(route(channelId, "/current"), signal === undefined ? {} : { signal })); const csrfHeader = async (): Promise => { const token = await readJson<{ token: string }>(await fetch("/api/csrf")); diff --git a/src/modules/votekick/panel/index.tsx b/src/modules/votekick/panel/index.tsx index 37c88703..8cf47c58 100644 --- a/src/modules/votekick/panel/index.tsx +++ b/src/modules/votekick/panel/index.tsx @@ -7,6 +7,8 @@ import type { Votekick } from "../contracts"; import { remainingVotekickSeconds } from "../domain"; import { cancelVotekick, liftVotekickTimeout, loadVotekickPanel, type VotekickPanelData } from "./service"; import { votekickPanelTexts } from "./locale"; +import { refetchModuleQueryData, useDashboardQueryClient, useModuleQuery } from "../../../dashboard/data"; +import { useDashboardRealtimeStatus } from "../../../dashboard/data/realtime"; const formatTime = (value: string, language: "de" | "en"): string => { const date = new Date(value); @@ -15,55 +17,73 @@ const formatTime = (value: string, language: "de" | "en"): string => { : value; }; +const VotekickCountdown = ({ endsAt, language }: { endsAt: string; language: "de" | "en" }): ReactElement => { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + const labels = votekickPanelTexts(language); + return + {labels.remaining(remainingVotekickSeconds(endsAt, now))} + ; +}; + +const VotekickLiftAction = ({ item, canOperate, busy, onLift, liftLabel, liftedLabel }: { + item: Votekick; + canOperate: boolean; + busy: boolean; + onLift: () => void; + liftLabel: string; + liftedLabel: string; +}): ReactElement | null => { + const expiredAt = item.endedAt === null || item.durationSeconds === null + ? Number.NaN + : Date.parse(item.endedAt) + item.durationSeconds * 1000; + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const remaining = expiredAt - Date.now(); + if (!Number.isFinite(remaining) || remaining <= 0) return; + const timer = window.setTimeout(() => { setNow(Date.now()); }, Math.min(2_147_483_647, remaining)); + return () => window.clearTimeout(timer); + }, [expiredAt]); + if (item.liftedAt !== null) return {liftedLabel}; + const expired = !Number.isFinite(expiredAt) || expiredAt <= now; + return expired ? null : ; +}; + export default function VotekickPanel({ channelId, language, canOperate = true }: ModulePanelProperties): ReactElement { const resolvedLanguage = language ?? dashboardLanguage(); const labels = useMemo(() => votekickPanelTexts(resolvedLanguage), [resolvedLanguage]); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); + const realtimeStatus = useDashboardRealtimeStatus(channelId); + const queryClient = useDashboardQueryClient(); + const panelQuery = useModuleQuery(channelId, "votekick", "panel", (signal) => loadVotekickPanel(channelId, signal), { + refetchInterval: realtimeStatus === "connected" ? false : 2_000, + }); + const data = panelQuery.data ?? null; + const loading = panelQuery.isPending; const [busy, setBusy] = useState(false); const [dialogError, setDialogError] = useState(); const loadErrorNotified = useRef(false); const [cancelTarget, setCancelTarget] = useState(null); const [liftTarget, setLiftTarget] = useState(null); - const [now, setNow] = useState(0); - const reload = useCallback(async (silent = false): Promise => { - if (!silent) setLoading(true); + const reload = useCallback(async (): Promise => { try { - setData(await loadVotekickPanel(channelId)); + await refetchModuleQueryData(queryClient, channelId, "votekick", "panel"); loadErrorNotified.current = false; } catch { if (!loadErrorNotified.current) { loadErrorNotified.current = true; notify({ tone: "error", message: labels.loadError }); } - } finally { - if (!silent) setLoading(false); } - }, [channelId, labels.loadError]); - - useEffect(() => { - let active = true; - loadErrorNotified.current = false; - void loadVotekickPanel(channelId) - .then((value) => { if (active) { setData(value); loadErrorNotified.current = false; } }) - .catch(() => { - if (active && !loadErrorNotified.current) { - loadErrorNotified.current = true; - notify({ tone: "error", message: labels.loadError }); - } - }) - .finally(() => { if (active) setLoading(false); }); - return () => { active = false; }; - }, [channelId, labels.loadError]); - useEffect(() => { - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, []); + }, [channelId, labels.loadError, queryClient]); useEffect(() => { - const timer = window.setInterval(() => { void reload(true); }, 2000); - return () => window.clearInterval(timer); - }, [reload]); + if (!panelQuery.isError || loadErrorNotified.current) return; + loadErrorNotified.current = true; + notify({ tone: "error", message: labels.loadError }); + }, [labels.loadError, panelQuery.error, panelQuery.isError]); const cancel = async (): Promise => { if (cancelTarget === null) return; @@ -72,7 +92,7 @@ export default function VotekickPanel({ channelId, language, canOperate = true } try { await cancelVotekick(channelId, cancelTarget.id); setCancelTarget(null); - await reload(true); + await reload(); } catch { setDialogError(labels.actionError); } finally { @@ -87,7 +107,7 @@ export default function VotekickPanel({ channelId, language, canOperate = true } try { await liftVotekickTimeout(channelId, liftTarget.id); setLiftTarget(null); - await reload(true); + await reload(); } catch { setDialogError(labels.actionError); } finally { @@ -96,9 +116,12 @@ export default function VotekickPanel({ channelId, language, canOperate = true } }; const running = data?.running ?? null; - const currentTime = now === 0 ? Date.parse(data?.now ?? "") : now; const history = data?.votekicks.filter((item) => item.status !== "running") ?? []; const renderSections = (skeleton: boolean): ReactElement =>
+

+ {canOperate ? "" : labels.readOnlyReason} +

{skeleton ? : running === null @@ -107,9 +130,7 @@ export default function VotekickPanel({ channelId, language, canOperate = true }
{labels.target} · {running.targetLogin ?? running.targetUserId ?? "—"} {labels.votes(running.yesVotes, running.noVotes, running.threshold)} - - {labels.remaining(remainingVotekickSeconds(running.endsAt, currentTime))} - +
@@ -133,9 +154,9 @@ export default function VotekickPanel({ channelId, language, canOperate = true }
{item.status === "passed" && item.targetUserId !== null && item.liftedAt === null && item.endedAt !== null && item.durationSeconds !== null && - Number.isFinite(Date.parse(item.endedAt)) && - Date.parse(item.endedAt) + item.durationSeconds * 1000 > currentTime - ? + Number.isFinite(Date.parse(item.endedAt)) + ? { setDialogError(undefined); setLiftTarget(item); }} liftLabel={labels.lift} liftedLabel={labels.lifted} /> : item.liftedAt === null ? null : {labels.lifted}}
diff --git a/src/modules/votekick/panel/locale.ts b/src/modules/votekick/panel/locale.ts index ddea3404..63e4c886 100644 --- a/src/modules/votekick/panel/locale.ts +++ b/src/modules/votekick/panel/locale.ts @@ -173,6 +173,7 @@ const panelCopy: LocaleCatalog<{ liftTitle: string; liftDescription: string; confirmLift: string; + readOnlyReason: string; loadError: string; actionError: string; lifted: string; @@ -188,6 +189,7 @@ const panelCopy: LocaleCatalog<{ cancelDescription: "Die laufende Abstimmung wird geschlossen.", confirmCancel: "Votekick abbrechen", cancelDialogCancel: "Zurück", lift: "Timeout aufheben", liftDialogCancel: "Zurück", liftTitle: "Timeout aufheben?", liftDescription: "Der Timeout für dieses Ziel wird bei Twitch aufgehoben.", confirmLift: "Timeout aufheben", + readOnlyReason: "Deine Kanalrolle darf Votekicks nicht bedienen.", loadError: "Votekicks konnten nicht geladen werden.", actionError: "Die Aktion konnte nicht abgeschlossen werden.", lifted: "Timeout aufgehoben.", status: { running: "Läuft", passed: "Bestanden", expired: "Abgelaufen", cancelled: "Abgebrochen", failed: "Fehlgeschlagen" }, duration: (seconds) => seconds === null ? "—" : `${String(seconds)} s`, @@ -201,6 +203,7 @@ const panelCopy: LocaleCatalog<{ cancelDescription: "The running ballot will be closed.", confirmCancel: "Cancel votekick", cancelDialogCancel: "Keep running", lift: "Lift timeout", liftDialogCancel: "Cancel", liftTitle: "Lift timeout?", liftDescription: "This target's timeout will be lifted at Twitch.", confirmLift: "Lift timeout", + readOnlyReason: "Your channel role cannot operate votekicks.", loadError: "Votekicks could not be loaded.", actionError: "The action could not be completed.", lifted: "Timeout lifted.", status: { running: "Running", passed: "Passed", expired: "Expired", cancelled: "Cancelled", failed: "Failed" }, duration: (seconds) => seconds === null ? "—" : `${String(seconds)} s`, diff --git a/src/modules/votekick/panel/service.ts b/src/modules/votekick/panel/service.ts index 0b5f1f0a..092618e0 100644 --- a/src/modules/votekick/panel/service.ts +++ b/src/modules/votekick/panel/service.ts @@ -20,8 +20,8 @@ const readJson = async (response: Response): Promise => { const csrf = async (): Promise => (await readJson<{ token: string }>(await fetch("/api/csrf"))).token; -export const loadVotekickPanel = async (channelId: string): Promise => - readJson(await fetch(`${basePath(channelId)}/votekicks`)); +export const loadVotekickPanel = async (channelId: string, signal?: AbortSignal): Promise => + readJson(await fetch(`${basePath(channelId)}/votekicks`, signal === undefined ? {} : { signal })); const mutate = async (channelId: string, id: string, action: "cancel" | "lift"): Promise => { const token = await csrf(); diff --git a/src/modules/votekick/routes.ts b/src/modules/votekick/routes.ts index a078255d..3c3df77c 100644 --- a/src/modules/votekick/routes.ts +++ b/src/modules/votekick/routes.ts @@ -111,5 +111,17 @@ votekickRoutes.post("/votekicks/:id/lift", async (context) => { AND target_user_id IS NOT NULL ${authorization.sql}`, ).bind(now, channelId, id, ...authorization.values).run(); if (update.meta.changes === 0) return context.json({ error: "votekick_timeout_unavailable" }, 409); + try { + const lifted = await repository.byId(channelId, id); + if (lifted !== null) { + await context.get("publishModuleOverlayMessage")( + channelId, + VOTEKICK_MODULE_ID, + "tally", + VOTEKICK_ELEMENT_KIND, + votekickOverlayPayload(lifted), + ); + } + } catch { /* The panel can reload the persisted lift state on its next read. */ } return context.json({ liftedAt: now }); }); diff --git a/src/realtime-contract.ts b/src/realtime-contract.ts index 4677f9e4..cc5b3c43 100644 --- a/src/realtime-contract.ts +++ b/src/realtime-contract.ts @@ -34,8 +34,18 @@ export type KnownModuleOverlayRealtimeMessageType = keyof ModuleOverlayRealtimeP | `modul.channel_events.${string}` | `modul.raid.${string}` | `modul.sun.${string}` + | `modul.chat_voting.${string}` + | `modul.belabox.${string}` + | `modul.votekick.${string}` | `modul.text_commands.${string}`; export type RealtimeMessageType = FixedRealtimeMessageType | KnownModuleOverlayRealtimeMessageType; +export type PanelModuleRealtimeMessageType = + | "modul.chat_voting.opened" + | "modul.chat_voting.tally" + | "modul.belabox.state_changed" + | "modul.belabox.sample" + | "modul.votekick.opened" + | "modul.votekick.tally"; type ModuleOverlayPayload = Type extends `modul.text_library.${string}` ? Type extends keyof ModuleOverlayRealtimePayloads ? ModuleOverlayRealtimePayloads[Type] : never @@ -117,6 +127,20 @@ export type RealtimeMessage = { [Type in FixedRealtimeMessageType]: FixedRealtim export const isModuleOverlayRealtimeMessageType = (type: string): type is ModuleOverlayRealtimeMessageType => /^modul\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_-]*$/u.test(type); +/** Only module actions that update panel-owned live state are relayed to panel sockets. */ +const PANEL_MODULE_REALTIME_MESSAGE_TYPES = new Set([ + "modul.chat_voting.opened", + "modul.chat_voting.tally", + "modul.belabox.state_changed", + "modul.belabox.sample", + "modul.votekick.opened", + "modul.votekick.tally", +]); + +export const isPanelModuleRealtimeMessageType = ( + type: string, +): type is PanelModuleRealtimeMessageType => PANEL_MODULE_REALTIME_MESSAGE_TYPES.has(type as PanelModuleRealtimeMessageType); + export const isModuleOverlayRealtimeEnvelope = ( message: RealtimeMessage, ): message is AnyModuleOverlayRealtimeEnvelope => isModuleOverlayRealtimeMessageType(message.type); @@ -134,7 +158,9 @@ export const REALTIME_RECIPIENTS = { } as const satisfies Record; export const realtimeRecipients = (type: RealtimeMessageType | ModuleOverlayRealtimeMessageType): readonly RealtimeRecipientKind[] => - isModuleOverlayRealtimeMessageType(type) ? ["overlay"] : REALTIME_RECIPIENTS[type]; + isModuleOverlayRealtimeMessageType(type) + ? isPanelModuleRealtimeMessageType(type) ? ["panel", "overlay"] : ["overlay"] + : REALTIME_RECIPIENTS[type]; export type RealtimePanelPrincipal = { v: RealtimeProtocolVersion; diff --git a/src/worker/durable/ChannelObject.ts b/src/worker/durable/ChannelObject.ts index d7940184..5484f6bb 100644 --- a/src/worker/durable/ChannelObject.ts +++ b/src/worker/durable/ChannelObject.ts @@ -2153,7 +2153,10 @@ export class ChannelObject extends DurableObject { ...(overlaySocketsById.get(envelope.payload.overlayId) ?? []), ] : isModuleOverlayRealtimeEnvelope(envelope) - ? [...new Set((envelope.overlayIds ?? []).flatMap((overlayId) => overlaySocketsById.get(overlayId) ?? []))] + ? [ + ...this.ctx.getWebSockets("kind:panel"), + ...new Set((envelope.overlayIds ?? []).flatMap((overlayId) => overlaySocketsById.get(overlayId) ?? [])), + ] : ordinarySockets, })); for (const message of serialized) { diff --git a/src/worker/module-overlay-realtime.ts b/src/worker/module-overlay-realtime.ts index 12d2b3fe..a7267e24 100644 --- a/src/worker/module-overlay-realtime.ts +++ b/src/worker/module-overlay-realtime.ts @@ -1,5 +1,5 @@ import type { BotModule, ModuleAction, ModuleOverlayHostEvent } from "../modules/contract"; -import type { ModuleOverlayRealtimeEnvelope } from "../realtime-contract"; +import { isPanelModuleRealtimeMessageType, type ModuleOverlayRealtimeEnvelope } from "../realtime-contract"; import { MODULES } from "../modules/registry"; const OVERLAY_PAYLOAD_MAXIMUM_BYTES = 4_096; @@ -74,7 +74,9 @@ export const prepareModuleOverlayRealtimeMessage = async ( ORDER BY element.overlay_id`, ).bind(channelId, action.elementKind, ...recipientBindings, mandatory ? 1 : 0, moduleId).all<{ overlay_id: string }>(); const overlayIds = [...new Set(recipients.results.map(({ overlay_id }) => overlay_id))]; - if (overlayIds.length === 0) return { outcome: "no_recipients" }; + if (overlayIds.length === 0 && !isPanelModuleRealtimeMessageType(`modul.${moduleId}.${action.type}`)) { + return { outcome: "no_recipients" }; + } return { outcome: "ready", diff --git a/tests/components/ads-panel.test.tsx b/tests/components/ads-panel.test.tsx index 7535583b..f862fc18 100644 --- a/tests/components/ads-panel.test.tsx +++ b/tests/components/ads-panel.test.tsx @@ -1,10 +1,13 @@ -import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ModulePage } from "../../src/dashboard/module-panels"; import { UiProvider } from "../../src/dashboard/ui"; import { toastsSnapshot } from "../../src/dashboard/ui/toast-store"; import type { AdsScheduleResponse } from "../../src/modules/ads/contracts"; +import { moduleQueryKey } from "../../src/dashboard/data/module-query"; +import { invalidateDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; +import type { RealtimeMessage } from "../../src/realtime-contract"; import { renderWithQuery } from "../query-test-utils"; import { jsonResponse } from "../unit/fixtures"; @@ -159,57 +162,72 @@ describe("Ad settings editor declaration", () => { asOf: "2026-09-24T12:00:00.000Z", }; const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }); - const fetcher = adsFetch(undefined, original); - renderAds(fetcher); + let serverSchedule = original; + const fetcher = vi.fn((input, init) => { + const path = input instanceof Request ? new URL(input.url).pathname : new URL(String(input), "https://brobot.example").pathname; + if (path === "/api/csrf") return Promise.resolve(jsonResponse({ token: "csrf" })); + if (path.endsWith("/settings")) return Promise.resolve(jsonResponse({ settings, revision: 1, variables: [] })); + if (path.endsWith("/schedule")) return Promise.resolve(jsonResponse(serverSchedule)); + if (path.endsWith("/snooze") && init?.method === "POST") return Promise.resolve(jsonResponse(serverSchedule)); + return Promise.resolve(jsonResponse({}, 404)); + }); + const rendered = renderAds(fetcher); expect(await screen.findByText(`Stand ${formatter.format(new Date(original.asOf))}`)).toBeInTheDocument(); - window.dispatchEvent(new CustomEvent("brobot:realtime", { detail: { + serverSchedule = { + ...original, + schedule: { ...original.schedule, nextAdAt: "2026-09-24T18:00:00.000Z", snoozeCount: 1 }, + asOf: "2026-09-24T13:00:00.000Z", + }; + invalidateDashboardRealtimeMessage(rendered.queryClient, { version: 1, id: "ads-schedule-2", createdAt: "2026-09-24T13:00:00.000Z", channelId: "kanal-a", type: "ads.schedule.updated", - payload: { - schedule: { ...original.schedule, nextAdAt: "2026-09-24T18:00:00.000Z", snoozeCount: 1 }, - asOf: "2026-09-24T13:00:00.000Z", - }, - } })); + payload: { schedule: serverSchedule.schedule, asOf: serverSchedule.asOf }, + } satisfies RealtimeMessage); expect(await screen.findByText(`Stand ${formatter.format(new Date("2026-09-24T13:00:00.000Z"))}`)).toBeInTheDocument(); + expect(rendered.queryClient.getQueryState(moduleQueryKey("kanal-a", "ads", "schedule"))?.isInvalidated).toBe(false); }); it("keeps the newest realtime schedule when it arrives before the initial response", async () => { - let resolveInitial: ((response: Response) => void) | undefined; const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }); - const initial = { ...schedule, asOf: "2026-09-24T12:00:00.000Z" }; + let serverSchedule = { ...schedule, asOf: "2026-09-24T12:00:00.000Z" }; + let scheduleCalls = 0; const fetcher = vi.fn((input, init) => { const path = input instanceof Request ? new URL(input.url).pathname : new URL(String(input), "https://brobot.example").pathname; - if (path.endsWith("/schedule")) return new Promise((resolve) => { resolveInitial = resolve; }); + if (path.endsWith("/schedule")) { + scheduleCalls += 1; + return Promise.resolve(jsonResponse(scheduleCalls === 1 ? { ...serverSchedule, asOf: "2026-09-24T12:00:00.000Z" } : serverSchedule)); + } if (path === "/api/csrf") return Promise.resolve(jsonResponse({ token: "csrf" })); if (path.endsWith("/settings")) return Promise.resolve(jsonResponse({ settings, revision: 1, variables: [] })); - if (path.endsWith("/snooze") && init?.method === "POST") return Promise.resolve(jsonResponse(initial)); + if (path.endsWith("/snooze") && init?.method === "POST") return Promise.resolve(jsonResponse(serverSchedule)); return Promise.resolve(jsonResponse({}, 404)); }); - renderAds(fetcher); + const rendered = renderAds(fetcher); + await waitFor(() => { expect(scheduleCalls).toBe(1); }); const newerAsOf = "2026-09-24T13:00:00.000Z"; - window.dispatchEvent(new CustomEvent("brobot:realtime", { detail: { + serverSchedule = { + ...schedule, + schedule: { ...schedule.schedule, nextAdAt: "2026-09-24T18:00:00.000Z", snoozeCount: 0 }, + asOf: newerAsOf, + }; + invalidateDashboardRealtimeMessage(rendered.queryClient, { version: 1, id: "ads-schedule-early", createdAt: newerAsOf, channelId: "kanal-a", type: "ads.schedule.updated", - payload: { - schedule: { ...schedule.schedule, nextAdAt: "2026-09-24T18:00:00.000Z", snoozeCount: 0 }, - asOf: newerAsOf, - }, - } })); - act(() => { - resolveInitial?.(jsonResponse(initial)); - }); + payload: { schedule: serverSchedule.schedule, asOf: newerAsOf }, + } satisfies RealtimeMessage); expect(await screen.findByText(`Stand ${formatter.format(new Date(newerAsOf))}`)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Snooze · 0 verfügbar/ })).toBeDisabled(); + expect(scheduleCalls).toBeGreaterThanOrEqual(2); }); it("keeps a cleared prewarning lead time empty and blocks the save", async () => { diff --git a/tests/components/belabox-alert-surfaces.test.tsx b/tests/components/belabox-alert-surfaces.test.tsx index 105f3385..29eab8a5 100644 --- a/tests/components/belabox-alert-surfaces.test.tsx +++ b/tests/components/belabox-alert-surfaces.test.tsx @@ -1,12 +1,15 @@ import type { ReactElement } from "react"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { BelaboxStatusResponse } from "../../src/modules/belabox/contracts"; import BelaboxPanel from "../../src/modules/belabox/panel/index"; import BelaboxStatusAction from "../../src/modules/belabox/panel/immediate-actions"; import { UiProvider } from "../../src/dashboard/ui"; +import { invalidateDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; +import type { RealtimeMessage } from "../../src/realtime-contract"; +import { renderWithQuery as render } from "../query-test-utils"; const status: BelaboxStatusResponse = { configured: true, @@ -29,13 +32,14 @@ describe("BELABOX alert surfaces", () => { afterEach(() => { cleanup(); vi.unstubAllGlobals(); + vi.useRealTimers(); }); it("shows the alert notice and Check now action in the module page reserved slot", async () => { vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(Response.json(status)))); renderWithUi(); - const notice = await screen.findByTestId("belabox-alert-notice-slot"); + const notice = await screen.findByText(/BELABOX encoder disconnected/u); expect(notice).toHaveTextContent("BELABOX encoder disconnected"); expect(screen.getByRole("button", { name: /Check now|Jetzt prüfen/u })).toBeEnabled(); }); @@ -44,7 +48,7 @@ describe("BELABOX alert surfaces", () => { vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(Response.json(status)))); renderWithUi(); - expect(await screen.findByTestId("belabox-immediate-status-slot")).toHaveTextContent(/BELABOX encoder disconnected|BELABOX-Encoder getrennt/u); + expect(await screen.findByText(/BELABOX encoder disconnected|BELABOX-Encoder getrennt/u)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Check now|Jetzt prüfen/u })).toBeEnabled(); }); @@ -56,62 +60,17 @@ describe("BELABOX alert surfaces", () => { expect(await screen.findByTestId("belabox-immediate-status-slot")).toHaveTextContent("Stream is offline."); expect(fetcher).toHaveBeenCalledTimes(1); view.rerender(); + invalidateDashboardRealtimeMessage(view.queryClient, { + version: 1, + id: "stream-now-live", + createdAt: new Date().toISOString(), + channelId: "channel-a", + type: "stream.state.changed", + payload: { state: "online", startedAt: new Date().toISOString(), changedAt: new Date().toISOString() }, + } satisfies RealtimeMessage); await waitFor(() => { expect(fetcher).toHaveBeenCalledTimes(2); }); expect(await screen.findByTestId("belabox-immediate-status-slot")).toHaveTextContent(/BELABOX encoder disconnected|BELABOX-Encoder getrennt/u); }); - it("refreshes an offline-open module page until a live disconnect notice appears", async () => { - const offlineStatus: BelaboxStatusResponse = { - ...status, - sample: null, - polling: false, - pollingDesired: false, - streamId: null, - belaboxStreamId: null, - alertNotice: null, - }; - const fetcher = vi.fn() - .mockResolvedValueOnce(Response.json(offlineStatus)) - .mockResolvedValue(Response.json(status)); - vi.stubGlobal("fetch", fetcher); - vi.useFakeTimers(); - renderWithUi(); - - await act(async () => { await vi.advanceTimersByTimeAsync(0); }); - - const notice = screen.getByTestId("belabox-alert-notice-slot"); - expect(notice).not.toHaveTextContent("BELABOX encoder disconnected"); - expect(fetcher).toHaveBeenCalledTimes(1); - - await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); - - expect(fetcher).toHaveBeenCalledTimes(2); - expect(notice).toHaveTextContent("BELABOX encoder disconnected"); - }); - - it("retries a failed initial action-card status request and then starts refreshing", async () => { - const fetcher = vi.fn() - .mockRejectedValueOnce(new Error("network")) - .mockResolvedValue(Response.json(status)); - vi.stubGlobal("fetch", fetcher); - vi.useFakeTimers(); - try { - renderWithUi(); - await act(async () => { await vi.advanceTimersByTimeAsync(0); }); - - expect(fetcher).toHaveBeenCalledTimes(1); - expect(screen.getByTestId("belabox-immediate-status-slot")).toHaveTextContent(/not available yet|noch nicht verfügbar/u); - expect(screen.getByRole("button", { name: /Check now|Jetzt prüfen/u })).toBeEnabled(); - - await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); - expect(fetcher).toHaveBeenCalledTimes(2); - expect(screen.getByTestId("belabox-immediate-status-slot")).toHaveTextContent(/BELABOX encoder disconnected|BELABOX-Encoder getrennt/u); - - await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); - expect(fetcher).toHaveBeenCalledTimes(3); - } finally { - vi.useRealTimers(); - } - }); }); diff --git a/tests/components/belabox-panel.test.tsx b/tests/components/belabox-panel.test.tsx index 2340c5b6..175c73be 100644 --- a/tests/components/belabox-panel.test.tsx +++ b/tests/components/belabox-panel.test.tsx @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { UiProvider } from "../../src/dashboard/ui"; import { ToastHost } from "../../src/dashboard/ui/Toast"; import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store"; +import { renderWithQuery as render } from "../query-test-utils"; import BelaboxPanel from "../../src/modules/belabox/panel"; const mocks = vi.hoisted(() => ({ @@ -48,7 +49,9 @@ describe("BELABOX panel", () => { mocks.removeBelaboxStatsUrl.mockRejectedValue(new Error("D1 DELETE failed")); render(<>); - fireEvent.click(await screen.findByRole("button", { name: "Remove stats URL" })); + const removeButton = await screen.findByRole("button", { name: "Remove stats URL" }); + await waitFor(() => { expect(removeButton).toBeEnabled(); }); + fireEvent.click(removeButton); const dialog = await screen.findByRole("dialog", { name: "Remove stats URL?" }); fireEvent.click(within(dialog).getByRole("button", { name: "Remove stats URL" })); @@ -64,7 +67,9 @@ describe("BELABOX panel", () => { mocks.removeBelaboxStatsUrl.mockResolvedValue(undefined); render(<>); - fireEvent.click(await screen.findByRole("button", { name: "Remove stats URL" })); + const removeButton = await screen.findByRole("button", { name: "Remove stats URL" }); + await waitFor(() => { expect(removeButton).toBeEnabled(); }); + fireEvent.click(removeButton); mocks.loadBelaboxStatus.mockRejectedValue(new Error("refresh failed")); const dialog = await screen.findByRole("dialog", { name: "Remove stats URL?" }); @@ -148,7 +153,7 @@ describe("BELABOX panel", () => { await waitFor(() => { expect(mocks.loadBelaboxStatus).toHaveBeenCalledTimes(2); - expect(mocks.loadBelaboxHistory).toHaveBeenCalledWith("channel-a", "live", undefined); + expect(mocks.loadBelaboxHistory).toHaveBeenCalledWith("channel-a", "live", undefined, expect.any(AbortSignal)); }); expect(mocks.loadBelaboxStreams).toHaveBeenCalledOnce(); // A status without a finite intervalSeconds makes the refresh timer fire continuously. @@ -195,7 +200,7 @@ describe("BELABOX panel", () => { expect(await screen.findByText(/Average 1200/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Stream" })); await waitFor(() => { - expect(mocks.loadBelaboxHistory).toHaveBeenCalledWith("channel-a", "stream", "stream-42"); + expect(mocks.loadBelaboxHistory).toHaveBeenCalledWith("channel-a", "stream", "stream-42", expect.any(AbortSignal)); }); }); diff --git a/tests/components/channel-variables-page.test.tsx b/tests/components/channel-variables-page.test.tsx index 51c7d7d9..14da2292 100644 --- a/tests/components/channel-variables-page.test.tsx +++ b/tests/components/channel-variables-page.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { useState, type ReactElement } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -6,8 +6,10 @@ import { ChannelVariablesPage } from "../../src/dashboard/ChannelVariablesPage"; import { UiProvider } from "../../src/dashboard/ui"; import { ToastHost } from "../../src/dashboard/ui/Toast"; import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store"; +import { invalidateDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; +import type { RealtimeMessage } from "../../src/realtime-contract"; import { jsonResponse } from "../unit/fixtures"; -import { TestWebSocket } from "./test-websocket"; +import { renderWithQuery as render } from "../query-test-utils"; const variable = { channelId: "kanal-a", @@ -30,7 +32,6 @@ describe("Channel variables page", () => { for (const toast of toastsSnapshot()) dismissToast(toast.id); vi.useRealTimers(); vi.unstubAllGlobals(); - TestWebSocket.instances = []; setBrowserLanguage("de-DE"); }); @@ -290,66 +291,32 @@ describe("Channel variables page", () => { expect(await screen.findByText(expectedWarning)).toBeInTheDocument(); }); - it("coalesces variable hints, bounds continuous refreshes, and keeps one request in flight", async () => { - let releaseThird: ((result: Response) => void) | null = null; + it("revalidates variables from the shared query after a realtime hint", async () => { let variableRequestCount = 0; const fetcher = vi.fn(); fetcher.mockImplementation((input) => { const url = new URL(input instanceof Request ? input.url : String(input), window.location.href); if (url.pathname.endsWith("/overlay-tokens")) return Promise.resolve(jsonResponse({ tokens: [], nextOffset: null })); variableRequestCount++; - return variableRequestCount === 3 - ? new Promise((resolve) => { releaseThird = resolve; }) - : Promise.resolve(jsonResponse({ variables: [variable], count: 1, maximum: 25 })); + return Promise.resolve(jsonResponse({ variables: [variable], count: 1, maximum: 25 })); }); vi.stubGlobal("fetch", fetcher); - vi.stubGlobal("WebSocket", TestWebSocket); - render( {}} />); + const rendered = render( {}} />); await screen.findByRole("table"); - expect(TestWebSocket.instances).toHaveLength(1); - vi.useFakeTimers(); - TestWebSocket.instances[0]?.dispatch("open", new Event("open")); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(variableRequestCount).toBe(2); - - const sendHint = (id: number): void => TestWebSocket.instances[0]?.dispatch("message", { - data: JSON.stringify({ - version: 1, - id: `variables-changed-${String(id)}`, - createdAt: "2026-09-24T12:00:00.000Z", - channelId: "kanal-a", - type: "variables.changed", - payload: { set: [{ name: "score", value: 1235 }], removed: [] }, - }), - } as MessageEvent); - for (let index = 0; index < 10; index++) sendHint(index); - await act(async () => { await vi.advanceTimersByTimeAsync(119); }); - expect(variableRequestCount).toBe(2); - await act(async () => { await vi.advanceTimersByTimeAsync(1); }); - expect(variableRequestCount).toBe(3); - - for (let index = 10; index < 25; index++) { - sendHint(index); - await act(async () => { await vi.advanceTimersByTimeAsync(100); }); - } - expect(variableRequestCount).toBe(3); - expect(releaseThird).toBeTypeOf("function"); - await act(async () => { - releaseThird?.(jsonResponse({ variables: [variable], count: 1, maximum: 25 })); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(variableRequestCount).toBe(4); - fireEvent.focus(window); - expect(fetcher).toHaveBeenCalledTimes(5); + expect(variableRequestCount).toBe(1); + + invalidateDashboardRealtimeMessage(rendered.queryClient, { + version: 1, + id: "variables-changed-1", + createdAt: "2026-09-24T12:00:00.000Z", + channelId: "kanal-a", + type: "variables.changed", + payload: { set: [{ name: "score", value: 1235 }], removed: [] }, + } satisfies RealtimeMessage); + + await waitFor(() => { expect(variableRequestCount).toBe(2); }); + expect(screen.getByRole("row", { name: /score/i })).toBeInTheDocument(); }); it("consumes each Spotlight selection, applies later requests on the mounted page, and does not replay one after returning", async () => { diff --git a/tests/components/chat-voting-panel.test.tsx b/tests/components/chat-voting-panel.test.tsx index ac6ca22d..06ec592c 100644 --- a/tests/components/chat-voting-panel.test.tsx +++ b/tests/components/chat-voting-panel.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { UiProvider } from "../../src/dashboard/ui"; @@ -6,6 +6,7 @@ import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store import { ChatVotingPanel } from "../../src/modules/chat_voting/panel"; import type { ChatVotePreset } from "../../src/modules/chat_voting/contracts"; import { jsonResponse } from "../unit/fixtures"; +import { renderWithQuery as render } from "../query-test-utils"; const defaultLabels: Record = { yes_no: ["Yes", "No"], diff --git a/tests/components/dashboard.test.tsx b/tests/components/dashboard.test.tsx index ad989b24..ff9fd2f4 100644 --- a/tests/components/dashboard.test.tsx +++ b/tests/components/dashboard.test.tsx @@ -2335,24 +2335,27 @@ describe("Dashboard skeleton", () => { } if (url.pathname === "/api/channels/kanal-a/overview") { overviewRequestCount += 1; - // 1st: initial mount load. 2nd: the reconnect reload, held open so it - // resolves after the control edit's own refresh below (3rd). 3rd: the - // control edit's refresh, resolved immediately with the new state. + // 1st: initial mount load. 2nd: reconnect reload, held open so it + // resolves after the control edit's own refresh below (3rd). if (overviewRequestCount === 1) return Promise.resolve(jsonResponse(overview(pausedChannel))); if (overviewRequestCount === 2) return reconnectReload; + if (overviewRequestCount === 3) return Promise.resolve(jsonResponse(overview({ ...pausedChannel, controls: resumedControls }))); return Promise.resolve(jsonResponse(overview({ ...pausedChannel, controls: resumedControls }))); } return Promise.resolve(jsonResponse({}, 404)); }); vi.stubGlobal("fetch", fetcher); + vi.stubGlobal("WebSocket", TestWebSocket); window.history.replaceState({}, "", "/channels/kanal-a"); render(); await screen.findByRole("button", { name: "Automatische Aktionen fortsetzen" }); - - act(() => { - window.dispatchEvent(new CustomEvent("brobot:realtime-connected", { detail: { channelId: "kanal-a" } })); - }); + await waitFor(() => { expect(TestWebSocket.instances).toHaveLength(1); }); + TestWebSocket.instances[0]?.open(); + await waitFor(() => { expect(overviewRequestCount).toBe(1); }); + TestWebSocket.instances[0]?.close(1006, "socket interruption"); + await waitFor(() => { expect(TestWebSocket.instances).toHaveLength(2); }, { timeout: 2_000 }); + TestWebSocket.instances[1]?.open(); await waitFor(() => { expect(overviewRequestCount).toBe(2); }); fireEvent.click(screen.getByRole("button", { name: "Automatische Aktionen fortsetzen" })); @@ -2431,7 +2434,7 @@ describe("Dashboard skeleton", () => { expect(screen.getByRole("button", { name: "Stummschaltung aufheben" })).toBeInTheDocument(); }); - it("refreshes stale channel queries once on visibility change and polls every three minutes", async () => { + it("refreshes stale channel queries on visibility change without a timer poll", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-23T09:00:00.000Z")); const channel = healthyChannel("kanal-a", "Alpha"); @@ -2469,7 +2472,7 @@ describe("Dashboard skeleton", () => { await act(async () => { await vi.advanceTimersByTimeAsync(3 * 60 * 1000); }); await flushQueryUpdates(); - expect([channelCalls, overviewCalls, modulesCalls]).toEqual([3, 3, 3]); + expect([channelCalls, overviewCalls, modulesCalls]).toEqual([2, 2, 2]); }); it("shows stale token expiry as neutral, reloads, then marks server-confirmed expiry red", async () => { @@ -3111,18 +3114,23 @@ describe("Dashboard skeleton", () => { }); it("updates stored stream status from the panel realtime socket", async () => { - const channel = { + let channel = { ...healthyChannel("kanal-a", "Alpha"), modules: [], + streamStateChangedAt: null as string | null, + streamStateCheckedAt: null as string | null, controls: { mute: { active: false, until: null, mode: null }, pause: { active: false, pending: true, until: null, mode: "until_stream_end" as const }, }, }; + const channels = [channel]; vi.stubGlobal("WebSocket", TestWebSocket); stubDashboardFetch((url) => { if (url.pathname === "/api/channels/kanal-a/system") return jsonResponse(system); - }, [channel]); + if (url.pathname === "/api/channels/kanal-a/overview") return jsonResponse(overview(channel)); + if (url.pathname === "/api/channels/kanal-a/modules") return jsonResponse({ modules: [] }); + }, channels); window.history.replaceState({}, "", "/channels/kanal-a/system"); render(); @@ -3134,6 +3142,18 @@ describe("Dashboard skeleton", () => { socket.open(); const channelDataAge = document.querySelector(".dashboard-header__status .data-age:not(.dashboard-header__stream-age)")?.textContent; const changedAt = relativeIso(1_000); + channel = { + ...channel, + streamState: "online", + streamStartedAt: relativeIso(-60 * 60 * 1000), + streamStateChangedAt: changedAt, + streamStateCheckedAt: changedAt, + controls: { + mute: { active: false, until: null, mode: null }, + pause: { active: true, pending: false, until: null, mode: "until_stream_end" }, + }, + }; + channels[0] = channel; socket.receive(JSON.stringify({ version: 1, id: "stream-state-panel-1", @@ -3159,18 +3179,10 @@ describe("Dashboard skeleton", () => { expect(screen.queryByText("Gilt ab dem nächsten Stream")).not.toBeInTheDocument(); expect(document.querySelector(".dashboard-header__status .data-age:not(.dashboard-header__stream-age)")?.textContent).toBe(channelDataAge); - socket.receive(JSON.stringify({ - version: 1, - id: "stream-state-panel-old", - createdAt: new Date().toISOString(), - channelId: "kanal-a", - type: "stream.state.changed", - payload: { state: "offline", startedAt: null, changedAt: relativeIso(-1_000), checkedAt: relativeIso(-1_000) }, - })); - expect(document.querySelector(".dashboard-header__stream")).toHaveAttribute("data-state", "live"); + expect(screen.queryByText("Gilt ab dem nächsten Stream")).not.toBeInTheDocument(); }); - it("keeps a newer realtime stream update when an older channels refresh finishes later", async () => { + it("keeps the event reader independent of stream-state query invalidation", async () => { const channel = { ...healthyChannel("kanal-a", "Alpha"), streamStateChangedAt: relativeIso(-10_000), @@ -3180,6 +3192,7 @@ describe("Dashboard skeleton", () => { pause: { active: false, pending: true, until: null, mode: "until_stream_end" as const }, }, }; + let serverChannel = channel; let channelsCalls = 0; let resolveDelayedChannels: ((response: Response) => void) | undefined; const delayedChannels = new Promise((resolve) => { resolveDelayedChannels = resolve; }); @@ -3189,10 +3202,12 @@ describe("Dashboard skeleton", () => { if (path === "/api/channels") { channelsCalls += 1; return channelsCalls === 1 - ? Promise.resolve(jsonResponse({ channels: [channel], bot: channel.bot })) - : delayedChannels; + ? Promise.resolve(jsonResponse({ channels: [serverChannel], bot: serverChannel.bot })) + : channelsCalls === 2 + ? delayedChannels + : Promise.resolve(jsonResponse({ channels: [serverChannel], bot: serverChannel.bot })); } - if (path === "/api/channels/kanal-a/overview") return Promise.resolve(jsonResponse(overview(channel))); + if (path === "/api/channels/kanal-a/overview") return Promise.resolve(jsonResponse(overview(serverChannel))); if (path === "/api/channels/kanal-a/modules") return Promise.resolve(jsonResponse({ modules: [] })); if (path === "/api/channels/kanal-a/events") return Promise.resolve(jsonResponse({ entries: [], nextCursor: null })); return Promise.resolve(jsonResponse({}, 404)); @@ -3209,6 +3224,17 @@ describe("Dashboard skeleton", () => { await waitFor(() => expect(channelsCalls).toBe(2)); const changedAt = relativeIso(1_000); + serverChannel = { + ...channel, + streamState: "online", + streamStartedAt: relativeIso(-60 * 60 * 1000), + streamStateChangedAt: changedAt, + streamStateCheckedAt: changedAt, + controls: { + mute: { active: false, until: null, mode: null }, + pause: { active: true, pending: false, until: null, mode: "until_stream_end" }, + }, + }; socket.receive(JSON.stringify({ version: 1, id: "stream-state-newer-than-http", @@ -3226,6 +3252,7 @@ describe("Dashboard skeleton", () => { }, }, })); + await waitFor(() => expect(channelsCalls).toBe(3)); expect(await screen.findByText(/^Live ·/)).toBeInTheDocument(); await act(async () => { @@ -3238,18 +3265,24 @@ describe("Dashboard skeleton", () => { expect(screen.queryByText("Gilt ab dem nächsten Stream")).not.toBeInTheDocument(); }); - it("keeps a newer realtime stream and control update when an older overview finishes later", async () => { + it("reconciles channel overview data from the API after a stream-state hint", async () => { const channel = { ...healthyChannel("kanal-a", "Alpha"), streamStateChangedAt: relativeIso(-10_000), streamStateCheckedAt: relativeIso(-10_000), }; + let serverChannel = channel; + let overviewCalls = 0; let resolveOverview: ((response: Response) => void) | undefined; const delayedOverview = new Promise((resolve) => { resolveOverview = resolve; }); + vi.stubGlobal("WebSocket", TestWebSocket); vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { const path = requestUrl(input).pathname; - if (path === "/api/channels") return Promise.resolve(jsonResponse({ channels: [channel], bot: channel.bot })); - if (path === "/api/channels/kanal-a/overview") return delayedOverview; + if (path === "/api/channels") return Promise.resolve(jsonResponse({ channels: [serverChannel], bot: serverChannel.bot })); + if (path === "/api/channels/kanal-a/overview") { + overviewCalls += 1; + return overviewCalls === 1 ? delayedOverview : Promise.resolve(jsonResponse(overview(serverChannel))); + } if (path === "/api/channels/kanal-a/modules") return Promise.resolve(jsonResponse({ modules: [] })); if (path === "/api/channels/kanal-a/events") return Promise.resolve(jsonResponse({ entries: [], nextCursor: null })); return Promise.resolve(jsonResponse({}, 404)); @@ -3258,27 +3291,30 @@ describe("Dashboard skeleton", () => { render(); expect(await screen.findByText("Alpha — kanal-a")).toBeInTheDocument(); + await waitFor(() => expect(TestWebSocket.instances).toHaveLength(1)); + TestWebSocket.instances[0]?.open(); + await waitFor(() => expect(overviewCalls).toBe(1)); const changedAt = relativeIso(1_000); - window.dispatchEvent(new CustomEvent("brobot:realtime", { - detail: { - version: 1, - id: "stream-state-newer-than-overview", - createdAt: new Date().toISOString(), - channelId: "kanal-a", - type: "stream.state.changed", - payload: { - state: "online", - startedAt: relativeIso(-60 * 60 * 1000), - changedAt, - checkedAt: changedAt, - controls: { - mute: { active: false, until: null, mode: null }, - pause: { active: true, until: null, mode: "until_stream_end" }, - }, - }, + serverChannel = { + ...channel, + streamState: "online", + streamStartedAt: relativeIso(-60 * 60 * 1000), + streamStateChangedAt: changedAt, + streamStateCheckedAt: changedAt, + controls: { + mute: { active: false, until: null, mode: null }, + pause: { active: true, until: null, mode: "until_stream_end" }, }, + }; + TestWebSocket.instances[0]?.receive(JSON.stringify({ + version: 1, + id: "stream-state-newer-than-overview", + createdAt: new Date().toISOString(), + channelId: "kanal-a", + type: "stream.state.changed", + payload: { state: "online", startedAt: serverChannel.streamStartedAt, changedAt, checkedAt: changedAt, controls: serverChannel.controls }, })); - expect(await screen.findByText(/^Pausiert ·/)).toBeInTheDocument(); + await waitFor(() => expect(overviewCalls).toBe(2)); await act(async () => { resolveOverview?.(jsonResponse(overview(channel))); @@ -3290,7 +3326,7 @@ describe("Dashboard skeleton", () => { expect(screen.queryByText("Gilt ab dem nächsten Stream")).not.toBeInTheDocument(); }); - it("reconciles the channel overview when its realtime socket connects", async () => { + it("reconciles channel queries after reconnect", async () => { const channel = { ...healthyChannel("kanal-a", "Alpha"), streamStateCheckedAt: "2026-09-24T12:00:00.000Z", @@ -3311,6 +3347,12 @@ describe("Dashboard skeleton", () => { const socket = TestWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime-Socket fehlt"); socket.open(); + await waitFor(() => { + expect(fetcher.mock.calls.filter(([input]) => requestUrl(input).pathname.endsWith("/overview"))).toHaveLength(1); + }); + socket.close(1006, "socket interruption"); + await waitFor(() => expect(TestWebSocket.instances).toHaveLength(2), { timeout: 2_000 }); + TestWebSocket.instances[1]?.open(); await waitFor(() => { expect(fetcher.mock.calls.filter(([input]) => requestUrl(input).pathname.endsWith("/overview"))).toHaveLength(2); diff --git a/tests/components/events-page.test.tsx b/tests/components/events-page.test.tsx index d6885208..917fe38a 100644 --- a/tests/components/events-page.test.tsx +++ b/tests/components/events-page.test.tsx @@ -1,6 +1,7 @@ import { act, cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; -import { useState } from "react"; +import { useState, type ReactElement, type ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RenderOptions } from "@testing-library/react"; import { UiProvider } from "../../src/dashboard/ui"; import { EventsPage } from "../../src/dashboard/events/EventsPage"; @@ -8,7 +9,19 @@ import { emptyEventFilter } from "../../src/dashboard/events/model"; import type { PanelEventEntry, PanelEventsResponse } from "../../src/panel-contract"; import { dashboardDataKeys } from "../../src/dashboard/data/keys"; import { dashboardAuthenticationRequiredEvent } from "../../src/dashboard/data/events"; -import { renderWithQuery } from "../query-test-utils"; +import { renderWithQuery as renderWithQueryBase, type DashboardQueryTestOptions } from "../query-test-utils"; +import { useRealtimePanelMessages } from "../../src/dashboard/realtime"; + +const RealtimeShell = ({ children }: { children: ReactNode }) => { + useRealtimePanelMessages("kanal-a", true); + return <>{children}; +}; + +const renderWithQuery = ( + ui: ReactElement, + options?: Omit, + queryOptions: DashboardQueryTestOptions = {}, +) => renderWithQueryBase({ui}, options, queryOptions); const entry = (overrides: Partial): PanelEventEntry => ({ eventId: "event-1", @@ -174,6 +187,7 @@ describe("EventsPage failure cause icon", () => { expect(FakeWebSocket.instances).toHaveLength(1); expect(FakeWebSocket.instances[0]?.close).not.toHaveBeenCalled(); view.unmount(); + await new Promise((resolve) => setTimeout(resolve, 0)); expect(FakeWebSocket.instances[0]?.close).toHaveBeenCalledTimes(1); }); @@ -241,7 +255,7 @@ describe("EventsPage failure cause icon", () => { const fetcher = vi.fn((input: RequestInfo | URL, options?: RequestInit): Promise => { if (requestUrl(input).searchParams.get("module") !== "channel_events") return json([hostEntry]); channelRequests += 1; - if (channelRequests === 1) { + if (channelRequests === 1 || channelRequests === 2) { return new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); }); @@ -272,6 +286,7 @@ describe("EventsPage failure cause icon", () => { if (socket === undefined) throw new Error("Realtime socket was not created."); socket.open(); emitEventHint(socket, "switch-filter-hint", newRaid); + await waitFor(() => expect(channelRequests).toBe(2)); fireEvent.click(screen.getByRole("button", { name: "Toggle filter" })); expect(await screen.findByText("Chat-Nachricht fehlgeschlagen")).toBeVisible(); @@ -281,7 +296,7 @@ describe("EventsPage failure cause icon", () => { expect(screen.getByText(/Raid von switchold/u)).toBeVisible(); expect(rendered.container.querySelector(".skeleton")).not.toBeInTheDocument(); expect(await screen.findByText(/Raid von switchnew/u)).toBeVisible(); - expect(channelRequests).toBe(2); + expect(channelRequests).toBe(3); rendered.unmount(); }); @@ -448,7 +463,7 @@ describe("EventsPage failure cause icon", () => { rendered.unmount(); }); - it("invalidates again when a second realtime hint arrives during a delayed refresh", async () => { + it("queues one trailing refresh when an event hint arrives during a fetch", async () => { const initialEntry = entry({ eventId: "initial-event" }); const firstHint = entry({ eventId: "first-hint-event", @@ -463,11 +478,11 @@ describe("EventsPage failure cause icon", () => { detail: '{"source":"secondhint","viewers":2}', }); const refreshResponses: Array<(response: Response) => void> = []; - let mounted = false; + let requestCount = 0; const fetcher = vi.fn((): Promise => { - if (!mounted) { - // The revalidation on mount answers at once with the cached rows. - mounted = true; + requestCount += 1; + if (requestCount <= 1) { + // The mount responds with cached rows before realtime refreshes begin. return Promise.resolve(new Response(JSON.stringify({ entries: [initialEntry], nextCursor: null }), { status: 200, headers: { "Content-Type": "application/json" } })); } return new Promise((resolve) => { refreshResponses.push(resolve); }); @@ -493,6 +508,8 @@ describe("EventsPage failure cause icon", () => { const socket = TestEventsWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime socket was not created."); socket.open(); + await waitFor(() => expect(rendered.queryClient.isFetching()).toBe(0)); + fetcher.mockClear(); const sendHint = (id: string, event: PanelEventEntry): void => { socket.receive(JSON.stringify({ version: 1, @@ -507,10 +524,7 @@ describe("EventsPage failure cause icon", () => { sendHint("first-hint-message", firstHint); await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); sendHint("second-hint-message", secondHint); - // The second hint only marks a trailing refresh; it starts once the first one settles. - await new Promise((resolve) => setTimeout(resolve, 300)); expect(fetcher).toHaveBeenCalledTimes(1); - refreshResponses[0]?.(new Response(JSON.stringify({ entries: [initialEntry, firstHint], nextCursor: null }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -642,10 +656,12 @@ describe("EventsPage failure cause icon", () => { fetcher.mockClear(); const position = positionEventReader(rendered.container); act(() => { position.away(); }); - serverEntries = [newEntry, additionalEntry, oldEntry]; const socket = TestEventsWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime socket was not created."); socket.open(); + await waitFor(() => expect(rendered.queryClient.isFetching()).toBe(0)); + fetcher.mockClear(); + serverEntries = [newEntry, additionalEntry, oldEntry]; emitEventHint(socket, `top-hint-${action}`, newEntry); await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); await waitFor(() => expect(rendered.queryClient.isFetching()).toBe(0)); @@ -915,7 +931,7 @@ describe("EventsPage failure cause icon", () => { rendered.unmount(); }); - it("updates three cached pages during a hint storm with at most one refresh in flight", async () => { + it("keeps the event display frozen while a realtime invalidation refreshes cached pages", async () => { vi.useFakeTimers(); try { const raid = (id: string, source: string, minute: number) => entry({ @@ -967,23 +983,21 @@ describe("EventsPage failure cause icon", () => { // Let the revalidation on mount finish before the storm starts. await act(async () => { await vi.advanceTimersByTimeAsync(500); }); maxInFlight = 0; + const position = positionEventReader(rendered.container); + act(() => { position.away(); }); const socket = TestEventsWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime socket was not created."); socket.open(); + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + expect(rendered.queryClient.isFetching()).toBe(0); + fetcher.mockClear(); serverHasLive = true; - for (let index = 0; index < 12; index += 1) { - socket.receive(JSON.stringify({ - version: 1, - id: `storm-${String(index)}`, - createdAt: live.createdAt, - channelId: "kanal-a", - type: "event_log.new", - payload: { entries: [{ eventId: live.eventId, createdAt: live.createdAt, moduleId: live.moduleId, code: live.code, actorUserId: null }] }, - })); - await act(async () => { await vi.advanceTimersByTimeAsync(150); }); - } - // Hints are still arriving (faster than a full refetch) and the rows are already fresh. - expect(screen.getByText(/Raid von stormraid/u)).toBeVisible(); + emitEventHint(socket, "storm-message", live); + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + const cached = rendered.queryClient.getQueryData<{ pages: Array<{ entries: PanelEventEntry[] }> }>(dashboardDataKeys.events("kanal-a", emptyEventFilter)); + expect(cached?.pages[0]?.entries[0]?.eventId).toBe("live"); + expect(screen.queryByText(/Raid von stormraid/u)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /1 neue Ereignisse/u })).toBeVisible(); expect(maxInFlight).toBeLessThanOrEqual(1); rendered.unmount(); } finally { @@ -1023,15 +1037,17 @@ describe("EventsPage failure cause icon", () => { await waitFor(() => { expect(fetcher).toHaveBeenCalled(); }); await waitFor(() => { expect(rendered.queryClient.isFetching()).toBe(0); }); fetcher.mockClear(); - serverHasNewRaid = true; const socket = TestEventsWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime socket was not created."); socket.open(); + await waitFor(() => expect(rendered.queryClient.isFetching()).toBe(0)); + fetcher.mockClear(); fireEvent.click(screen.getByRole("button", { name: "Toggle filter" })); expect(screen.getByText("Chat-Nachricht fehlgeschlagen")).toBeVisible(); await waitFor(() => { expect(rendered.queryClient.isFetching()).toBe(0); }); expect(fetcher).toHaveBeenCalledTimes(1); fetcher.mockClear(); + serverHasNewRaid = true; socket.receive(JSON.stringify({ version: 1, id: "only-a-hint", @@ -1041,9 +1057,11 @@ describe("EventsPage failure cause icon", () => { payload: { entries: [{ eventId: newRaid.eventId, createdAt: newRaid.createdAt, moduleId: newRaid.moduleId, code: newRaid.code, actorUserId: null }] }, })); await new Promise((resolve) => setTimeout(resolve, 300)); - expect(fetcher).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledTimes(1); + fetcher.mockClear(); fireEvent.click(screen.getByRole("button", { name: "Toggle filter" })); expect(await screen.findByText(/Raid von newraid/u)).toBeVisible(); + expect(fetcher).toHaveBeenCalledTimes(1); rendered.unmount(); }); @@ -1065,7 +1083,7 @@ describe("EventsPage failure cause icon", () => { return new Promise((resolve) => { releaseNextPage = resolve; }); } firstPageRequests += 1; - if (firstPageRequests === 1) { + if (firstPageRequests <= 1) { return Promise.resolve(new Response(JSON.stringify({ entries: [firstEntry], nextCursor: "cursor-1" }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -1085,6 +1103,8 @@ describe("EventsPage failure cause icon", () => { const socket = TestEventsWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime socket was not created."); socket.open(); + await waitFor(() => expect(view.queryClient.isFetching()).toBe(0)); + fetcher.mockClear(); socket.receive(JSON.stringify({ version: 1, id: "live-event-message", @@ -1093,7 +1113,7 @@ describe("EventsPage failure cause icon", () => { type: "event_log.new", payload: { entries: [{ eventId: liveEntry.eventId, createdAt: liveEntry.createdAt, moduleId: liveEntry.moduleId, code: liveEntry.code, actorUserId: null }] }, })); - await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); const refreshRows = view.container.querySelector(".event-feed [aria-busy]"); const refreshBusyState = refreshRows?.getAttribute("aria-busy"); @@ -1119,7 +1139,7 @@ describe("EventsPage failure cause icon", () => { const initialEntry = entry({ eventId: "cached-event" }); const fetcher = vi.fn((input: RequestInfo | URL): Promise => { const url = requestUrl(input); - if (url.pathname.endsWith("/events") && fetcher.mock.calls.length === 1) { + if (url.pathname.endsWith("/events") && fetcher.mock.calls.length <= 1) { return Promise.resolve(new Response(JSON.stringify({ entries: [initialEntry], nextCursor: null }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -1140,6 +1160,7 @@ describe("EventsPage failure cause icon", () => { ); expect(await screen.findByText("Chat-Nachricht fehlgeschlagen")).toBeVisible(); TestEventsWebSocket.instances[0]?.open(); + await waitFor(() => expect(view.queryClient.isFetching()).toBe(0)); TestEventsWebSocket.instances[0]?.receive(JSON.stringify({ version: 1, id: "expired-event-message", diff --git a/tests/components/overlays-page.test.tsx b/tests/components/overlays-page.test.tsx index 8858de20..4af1910f 100644 --- a/tests/components/overlays-page.test.tsx +++ b/tests/components/overlays-page.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { readFileSync } from "node:fs"; import { URL as NodeURL } from "node:url"; import { runInNewContext } from "node:vm"; @@ -13,6 +13,7 @@ import { UiProvider as BaseUiProvider } from "../../src/dashboard/ui"; import { ToastHost } from "../../src/dashboard/ui/Toast"; import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store"; import { jsonResponse } from "../unit/fixtures"; +import { renderWithQuery as render } from "../query-test-utils"; const UiProvider = ({ children }: { children: ReactNode }) => {children}; diff --git a/tests/components/stream-manager.test.tsx b/tests/components/stream-manager.test.tsx index ed4ae32a..40632a83 100644 --- a/tests/components/stream-manager.test.tsx +++ b/tests/components/stream-manager.test.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from "react"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { UiProvider } from "../../src/dashboard/ui"; @@ -8,7 +8,10 @@ import { ToastHost } from "../../src/dashboard/ui/Toast"; import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store"; import type { PanelChannelOverview } from "../../src/panel-contract"; import { ChannelNotices, ImmediateActions, WarningsAndErrorsFeed } from "../../src/dashboard/stream-manager"; +import { invalidateDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; +import type { RealtimeMessage } from "../../src/realtime-contract"; import { jsonResponse } from "../unit/fixtures"; +import { renderWithQuery as render } from "../query-test-utils"; const renderWithMantine = (element: ReactElement): ReturnType => render({element}); @@ -57,34 +60,6 @@ const expectActionResult = async (message: string, tone: "success" | "error"): P expect(toastsSnapshot()).toContainEqual(expect.objectContaining({ tone, message })); }; -class FeedWebSocket { - static instances: FeedWebSocket[] = []; - private readonly listeners = new Map void>>(); - - constructor(readonly url: string, readonly protocols: string | string[]) { - FeedWebSocket.instances.push(this); - } - - addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { - if (typeof listener !== "function") return; - const listeners = this.listeners.get(type) ?? new Set<(event: Event) => void>(); - listeners.add(listener); - this.listeners.set(type, listeners); - } - - open(): void { - for (const listener of this.listeners.get("open") ?? []) listener(new Event("open")); - } - - receive(data: string): void { - for (const listener of this.listeners.get("message") ?? []) listener(new MessageEvent("message", { data })); - } - - close(code = 1000, reason = ""): void { - for (const listener of this.listeners.get("close") ?? []) listener(new CloseEvent("close", { code, reason })); - } -} - describe("Stream Manager immediate actions", () => { afterEach(() => { cleanup(); @@ -394,7 +369,6 @@ describe("Stream Manager immediate actions", () => { describe("Stream Manager warnings and errors feed", () => { afterEach(() => { cleanup(); - FeedWebSocket.instances = []; vi.unstubAllGlobals(); }); @@ -464,7 +438,6 @@ describe("Stream Manager warnings and errors feed", () => { }); it("refreshes new warning and error entries from the event log's realtime feed", async () => { - vi.stubGlobal("WebSocket", FeedWebSocket); const existing = { eventId: "existing", createdAt: "2026-09-22T10:00:00.000Z", @@ -496,20 +469,17 @@ describe("Stream Manager warnings and errors feed", () => { return Promise.resolve(jsonResponse({}, 404)); })); - renderWithMantine(); + const rendered = renderWithMantine(); expect(await screen.findByText("Werbeeinblendung nicht gestartet: Stream ist offline")).toBeInTheDocument(); - const socket = FeedWebSocket.instances[0]; - if (socket === undefined) throw new Error("Realtime-Socket fehlt"); - socket.open(); - socket.receive(JSON.stringify({ + act(() => invalidateDashboardRealtimeMessage(rendered.queryClient, { version: 1, id: "hint-1", createdAt: "2026-09-22T10:01:00.000Z", channelId: "kanal-a", type: "event_log.new", payload: { entries: [{ eventId: "realtime-error", createdAt: "2026-09-22T10:01:00.000Z", moduleId: "host", code: "host.action.failed", actorUserId: null }] }, - })); + } satisfies RealtimeMessage)); expect(await screen.findByText("Aktion fehlgeschlagen")).toBeInTheDocument(); expect(requests).toBe(2); diff --git a/tests/components/votekick-panel.test.tsx b/tests/components/votekick-panel.test.tsx index be563ea9..cb8f948a 100644 --- a/tests/components/votekick-panel.test.tsx +++ b/tests/components/votekick-panel.test.tsx @@ -1,11 +1,13 @@ -import { act, cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { UiProvider } from "../../src/dashboard/ui"; import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store"; import VotekickPanel from "../../src/modules/votekick/panel"; import type { Votekick } from "../../src/modules/votekick/contracts"; +import { setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; import { jsonResponse } from "../unit/fixtures"; +import { renderWithQuery as render } from "../query-test-utils"; const running: Votekick = { id: "ballot-a", @@ -26,13 +28,13 @@ const running: Votekick = { afterEach(() => { cleanup(); + setDashboardRealtimeStatus("channel-a", "offline"); vi.unstubAllGlobals(); vi.useRealTimers(); }); describe("Votekick panel", () => { it("polls while idle and discovers a votekick started in chat", async () => { - vi.useFakeTimers(); let hasRunningVote = false; const fetcher = vi.fn(() => Promise.resolve(jsonResponse({ running: hasRunningVote ? running : null, @@ -41,17 +43,29 @@ describe("Votekick panel", () => { }))); vi.stubGlobal("fetch", fetcher); render(); - await act(async () => { await Promise.resolve(); await Promise.resolve(); }); - expect(screen.getByText("No votekick yet. Start one in chat with !votekick @user.")).toBeInTheDocument(); + expect(await screen.findByText("No votekick yet. Start one in chat with !votekick @user.")).toBeInTheDocument(); hasRunningVote = true; - await act(async () => { - vi.advanceTimersByTime(2000); - await Promise.resolve(); - await Promise.resolve(); - }); + await new Promise((resolve) => setTimeout(resolve, 2_100)); + + expect(await screen.findByText("sampleviewer")).toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("pauses its fallback poll while realtime is connected and resumes it offline", async () => { + vi.useFakeTimers(); + const fetcher = vi.fn(() => Promise.resolve(jsonResponse({ running: null, votekicks: [], now: "2026-10-04T11:00:00.000Z" }))); + vi.stubGlobal("fetch", fetcher); + setDashboardRealtimeStatus("channel-a", "connected"); + render(); + + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(fetcher).toHaveBeenCalledTimes(1); + await act(async () => { await vi.advanceTimersByTimeAsync(6_000); }); + expect(fetcher).toHaveBeenCalledTimes(1); - expect(screen.getByText("sampleviewer")).toBeInTheDocument(); + act(() => { setDashboardRealtimeStatus("channel-a", "offline"); }); + await act(async () => { await vi.advanceTimersByTimeAsync(2_100); }); expect(fetcher).toHaveBeenCalledTimes(2); }); @@ -63,6 +77,7 @@ describe("Votekick panel", () => { })))); render(); expect(await screen.findByRole("button", { name: "Cancel" })).toBeDisabled(); + expect(screen.getByTestId("votekick-operation-permission-reason")).toHaveTextContent("Your channel role cannot operate votekicks."); }); it("keeps the running-votekick and history boxes reserved while the countdown runs", async () => { @@ -84,14 +99,12 @@ describe("Votekick panel", () => { vi.stubGlobal("fetch", fetcher); render(); - await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - vi.advanceTimersByTime(6_000); - await Promise.resolve(); - await Promise.resolve(); + await vi.advanceTimersByTimeAsync(6_000); }); - expect(fetcher).toHaveBeenCalledTimes(4); + expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(2); const newToasts = toastsSnapshot().filter((toast) => !previousToastIds.has(toast.id)); expect(newToasts.filter((toast) => toast.message === "Votekicks could not be loaded.")).toHaveLength(1); for (const toast of newToasts) dismissToast(toast.id); diff --git a/tests/e2e/host-stable-layout.spec.ts b/tests/e2e/host-stable-layout.spec.ts index 08f01394..dd9d54ac 100644 --- a/tests/e2e/host-stable-layout.spec.ts +++ b/tests/e2e/host-stable-layout.spec.ts @@ -588,13 +588,14 @@ test("the channel-variable limit and create controls stay above a full list", as test("a failed realtime variable refresh keeps rows visible and offers retry", async ({ page }) => { await page.addInitScript(() => { - type LayoutSocket = EventTarget & { open: () => void; close: () => void; readyState: number }; + type LayoutSocket = EventTarget & { url: string; open: () => void; close: () => void; readyState: number }; const sockets: LayoutSocket[] = []; class MockWebSocket extends EventTarget { + readonly url: string; readyState = 0; constructor(url: string, protocols?: string | string[]) { super(); - void url; + this.url = url; void protocols; sockets.push(this); } @@ -606,6 +607,7 @@ test("a failed realtime variable refresh keeps rows visible and offers retry", a Object.defineProperty(window, "WebSocket", { value: MockWebSocket, writable: true, configurable: true }); }); let variableRequestCount = 0; + let failVariableRefresh = false; await page.route("**/api/**", async (route) => { const pathname = new URL(route.request().url()).pathname; if (pathname === "/api/channels") { @@ -618,7 +620,7 @@ test("a failed realtime variable refresh keeps rows visible and offers retry", a } if (pathname === `/api/channels/${channelId}/variables`) { variableRequestCount++; - if (variableRequestCount === 3) { + if (failVariableRefresh) { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "variables_load_failed" }) }); return; } @@ -645,19 +647,34 @@ test("a failed realtime variable refresh keeps rows visible and offers retry", a await expect(page.getByRole("rowheader", { name: "{var.score}" })).toBeVisible(); const retrySlot = page.locator(".channel-variables-limit-slot"); const slotBeforeRefreshFailure = await retrySlot.boundingBox(); + const initialVariableRequestCount = variableRequestCount; await page.evaluate(() => { - const sockets = (window as Window & { __layoutSockets?: Array<{ open: () => void }> }).__layoutSockets ?? []; - sockets.forEach((socket) => { socket.open(); }); + const sockets = (window as Window & { __layoutSockets?: Array<{ url: string; open: () => void; close: () => void }> }).__layoutSockets ?? []; + const panelSockets = sockets.filter((socket) => new URL(socket.url, window.location.href).pathname.startsWith("/ws/channels/")); + panelSockets[0]?.open(); + panelSockets[0]?.close(); + }); + await expect.poll(() => page.evaluate(() => { + const sockets = (window as Window & { __layoutSockets?: Array<{ url: string }> }).__layoutSockets ?? []; + return sockets.filter((socket) => new URL(socket.url, window.location.href).pathname.startsWith("/ws/channels/")).length; + })).toBeGreaterThan(1); + failVariableRefresh = true; + await page.evaluate(() => { + const sockets = (window as Window & { __layoutSockets?: Array<{ url: string; open: () => void }> }).__layoutSockets ?? []; + const panelSockets = sockets.filter((socket) => new URL(socket.url, window.location.href).pathname.startsWith("/ws/channels/")); + panelSockets.at(-1)?.open(); }); - await expect.poll(() => variableRequestCount).toBe(3); + await expect.poll(() => variableRequestCount).toBeGreaterThan(initialVariableRequestCount); await expect(page.locator(".ui-toast--error")).toBeVisible(); await expect(page.getByRole("rowheader", { name: "{var.score}" })).toBeVisible(); await expect(page.locator(".ui-load-state")).toHaveAttribute("data-status", "success"); await expect(page.getByRole("button", { name: "Retry" })).toBeVisible(); expect(await retrySlot.boundingBox()).toEqual(slotBeforeRefreshFailure); + const failedVariableRequestCount = variableRequestCount; + failVariableRefresh = false; await page.getByRole("button", { name: "Retry" }).click(); - await expect.poll(() => variableRequestCount).toBe(4); + await expect.poll(() => variableRequestCount).toBeGreaterThan(failedVariableRequestCount); await expect(page.getByRole("button", { name: "Retry" })).toHaveCount(0); await expect(page.getByRole("rowheader", { name: "{var.score}" })).toBeVisible(); }); diff --git a/tests/e2e/module-panels-stability.spec.ts b/tests/e2e/module-panels-stability.spec.ts index 88b06ac3..de12755f 100644 --- a/tests/e2e/module-panels-stability.spec.ts +++ b/tests/e2e/module-panels-stability.spec.ts @@ -218,6 +218,24 @@ test("timer and FAQ dialogs submit from Enter", async ({ page }) => { }); test("BELABOX immediate action keeps manual results separate from live notices", async ({ page }) => { + await page.addInitScript(() => { + class OfflineWebSocket extends EventTarget { + readyState = 0; + constructor(url: string, protocols?: string | string[]) { + super(); + void url; + void protocols; + queueMicrotask(() => { this.close(1006, "Offline fallback test"); }); + } + close(code = 1000, reason = "closed"): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(new CloseEvent("close", { code, reason })); + } + send(data: string): void { void data; } + } + Object.defineProperty(window, "WebSocket", { value: OfflineWebSocket, writable: true, configurable: true }); + }); const connectedStatus = { configured: true, updatedAt: "2030-01-01T12:00:00.000Z", sample: { at: "2030-01-01T12:00:00.000Z", connected: true, bitrateKbps: 3200, rttMs: 41, latencyMs: 115, network: 2, droppedPackets: 0 }, diff --git a/tests/e2e/stable-layout.spec.ts b/tests/e2e/stable-layout.spec.ts index cdada038..6f8e9731 100644 --- a/tests/e2e/stable-layout.spec.ts +++ b/tests/e2e/stable-layout.spec.ts @@ -157,6 +157,35 @@ test("returning from a module keeps the overview loaded at the same header posit }; let overviewRequests = 0; + await page.addInitScript(() => { + type TrackedSocket = EventTarget & { url: string; readyState: number; open: () => void; close: (code?: number, reason?: string) => void }; + const sockets: TrackedSocket[] = []; + class MockWebSocket extends EventTarget { + readonly url: string; + readyState = 0; + constructor(url: string, protocols?: string | string[]) { + super(); + this.url = url; + void protocols; + sockets.push(this); + queueMicrotask(() => { this.open(); }); + } + open(): void { + if (this.readyState !== 0) return; + this.readyState = 1; + this.dispatchEvent(new Event("open")); + } + close(code = 1000, reason = "closed"): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(new CloseEvent("close", { code, reason })); + } + send(data: string): void { void data; } + } + Object.defineProperty(window, "__dashboardRealtimeSockets", { value: sockets, configurable: true }); + Object.defineProperty(window, "WebSocket", { value: MockWebSocket, writable: true, configurable: true }); + }); + await page.route("**/api/channels**", async (route) => { const pathname = new URL(route.request().url()).pathname; if (pathname === "/api/channels") { @@ -247,6 +276,16 @@ test("returning from a module keeps the overview loaded at the same header posit expect(await pageHeader.evaluate((element) => element.getBoundingClientRect().y)).toBe(firstHeaderY); expect(await page.locator(".main-content .mantine-Skeleton-root").count()).toBe(0); expect(overviewRequests).toBe(initialOverviewRequestCount); + const socketStats = await page.evaluate(() => { + const sockets = (window as unknown as Window & { __dashboardRealtimeSockets: Array<{ url: string; readyState: number }> }).__dashboardRealtimeSockets + .filter((socket) => new URL(socket.url, window.location.href).pathname.startsWith("/ws/channels/")); + return { + created: sockets.length, + open: sockets.filter((socket) => socket.readyState === 1).length, + urls: sockets.map((socket) => new URL(socket.url, window.location.href).pathname), + }; + }); + expect(socketStats).toMatchObject({ created: 1, open: 1, urls: ["/ws/channels/stable-channel"] }); }); test("game search results close on outside click, focus exit, and Escape at 390px", async ({ page }) => { diff --git a/tests/unit/dashboard-query-data.test.tsx b/tests/unit/dashboard-query-data.test.tsx index 24d9fc3f..1dff1b93 100644 --- a/tests/unit/dashboard-query-data.test.tsx +++ b/tests/unit/dashboard-query-data.test.tsx @@ -8,6 +8,8 @@ import { createDashboardQueryClient } from "../../src/dashboard/data/client"; import { moduleQueryKey, refetchModuleQueryData, runModuleQueryWrite, useModuleQuery } from "../../src/dashboard/data"; import { queryKeys } from "../../src/dashboard/data/keys"; import { dashboardDataKeys } from "../../src/dashboard/data/keys"; +import { setDashboardRealtimeStatus, useDashboardRealtimeStatus, invalidateDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; +import { useOverlayQuery } from "../../src/dashboard/data/lists"; import { DashboardDataProvider } from "../../src/dashboard/data/provider"; import { emptyAuditFilter } from "../../src/dashboard/audit/model"; import { emptyEventFilter } from "../../src/dashboard/events/model"; @@ -18,6 +20,7 @@ const channelId = "channel-a"; afterEach(() => { vi.unstubAllGlobals(); + setDashboardRealtimeStatus(channelId, "offline"); }); describe("dashboard query data layer", () => { @@ -49,10 +52,127 @@ describe("dashboard query data layer", () => { expect(moduleQueryKey(channelId, "faq", "panel")).toEqual([ "channel", channelId, - "modules/faq/panel", + "module", + "faq", + "panel", ]); }); + it("stops fallback interval reads as soon as the channel socket connects", async () => { + let readCount = 0; + function Probe() { + const status = useDashboardRealtimeStatus(channelId); + const query = useModuleQuery(channelId, "chat_voting", "panel", () => { + readCount += 1; + return Promise.resolve(readCount); + }, { refetchInterval: status === "connected" ? false : 20 }); + return

{String(query.data ?? "loading")}

; + } + + const view = renderWithQuery(, {}, { gcTime: 600_000, staleTime: 0 }); + expect(await screen.findByText("1")).toBeInTheDocument(); + act(() => { setDashboardRealtimeStatus(channelId, "connected"); }); + await new Promise((resolve) => { setTimeout(resolve, 100); }); + + expect(readCount).toBe(1); + view.unmount(); + }); + + it("uses the fallback interval while the channel socket is offline", async () => { + let readCount = 0; + function Probe() { + const status = useDashboardRealtimeStatus(channelId); + const query = useModuleQuery(channelId, "votekick", "panel", () => { + readCount += 1; + return Promise.resolve(readCount); + }, { refetchInterval: status === "connected" ? false : 20 }); + return

{String(query.data ?? "loading")}

; + } + + const view = renderWithQuery(, {}, { gcTime: 600_000, staleTime: 0 }); + await waitFor(() => expect(readCount).toBeGreaterThan(1)); + + expect(readCount).toBeGreaterThan(1); + view.unmount(); + }); + + it("does not show a previous overlay while another overlay is loading", async () => { + let releaseOverlayB: (() => void) | undefined; + let markOverlayBStarted: (() => void) | undefined; + const overlayBStarted = new Promise((resolve) => { markOverlayBStarted = resolve; }); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const overlayId = new URL(requestUrl, window.location.href).pathname.split("/").at(-1); + if (overlayId === "overlay-a") { + return new Response(JSON.stringify({ overlay: { id: overlayId, name: "Overlay A" } }), { + headers: { "Content-Type": "application/json" }, + }); + } + markOverlayBStarted?.(); + await new Promise((resolve) => { releaseOverlayB = resolve; }); + return new Response(JSON.stringify({ overlay: { id: overlayId, name: "Overlay B" } }), { + headers: { "Content-Type": "application/json" }, + }); + })); + + function OverlayProbe({ overlayId }: { overlayId: string }) { + const query = useOverlayQuery(channelId, overlayId); + return

{query.data?.overlay.name ?? "Loading"}

; + } + + const view = renderWithQuery(); + expect(await screen.findByText("Overlay A")).toBeInTheDocument(); + + view.rerender(); + await overlayBStarted; + + expect(screen.getByText("Loading")).toBeInTheDocument(); + expect(screen.queryByText("Overlay A")).not.toBeInTheDocument(); + releaseOverlayB?.(); + expect(await screen.findByText("Overlay B")).toBeInTheDocument(); + view.unmount(); + }); + + it("queues one trailing read when realtime hints arrive during an active refresh", async () => { + let readCount = 0; + let releaseRefresh: (() => void) | undefined; + let markRefreshStarted: (() => void) | undefined; + const refreshStarted = new Promise((resolve) => { markRefreshStarted = resolve; }); + function ModuleProbe() { + const query = useModuleQuery(channelId, "chat_voting", "panel", async () => { + readCount += 1; + if (readCount === 2) { + markRefreshStarted?.(); + await new Promise((resolve) => { releaseRefresh = resolve; }); + } + return readCount; + }); + return

{String(query.data ?? "Loading")}

; + } + + const view = renderWithQuery(, {}, { gcTime: 600_000, staleTime: 600_000 }); + expect(await screen.findByText("1")).toBeInTheDocument(); + const message = { + version: 1 as const, + id: "chat-vote-tally", + createdAt: "2026-10-09T08:00:00.000Z", + channelId, + type: "modul.chat_voting.tally" as const, + payload: {}, + }; + + invalidateDashboardRealtimeMessage(view.queryClient, message); + await refreshStarted; + invalidateDashboardRealtimeMessage(view.queryClient, { ...message, id: "chat-vote-tally-2" }); + invalidateDashboardRealtimeMessage(view.queryClient, { ...message, id: "chat-vote-tally-3" }); + expect(readCount).toBe(2); + + releaseRefresh?.(); + await waitFor(() => expect(readCount).toBe(3)); + await waitFor(() => expect(view.queryClient.getQueryData(moduleQueryKey(channelId, "chat_voting", "panel"))).toBe(3)); + view.unmount(); + }); + it("does not let a read started before a module write replace the refetched value", async () => { let resolveOldRead: ((value: string) => void) | undefined; let markReadStarted: (() => void) | undefined; diff --git a/tests/unit/dashboard-realtime.test.ts b/tests/unit/dashboard-realtime.test.ts new file mode 100644 index 00000000..96a4f5d6 --- /dev/null +++ b/tests/unit/dashboard-realtime.test.ts @@ -0,0 +1,176 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it } from "vitest"; + +import { parseRealtimeMessage } from "../../src/dashboard/realtime"; +import { dashboardDataKeys, queryKeys } from "../../src/dashboard/data/keys"; +import { + getDashboardRealtimeStatus, + invalidateDashboardChannelQueries, + invalidateDashboardRealtimeMessage, + setDashboardRealtimeStatus, +} from "../../src/dashboard/data/realtime"; +import { moduleQueryKey } from "../../src/dashboard/data/module-query"; +import type { RealtimeMessage } from "../../src/realtime-contract"; + +const channelId = "channel-a"; +const moduleHint = (type: string, payload: Readonly> = {}): RealtimeMessage => ({ + version: 1, + id: `hint-${type}`, + createdAt: "2026-10-09T08:00:00.000Z", + channelId, + type, + payload, +} as unknown as RealtimeMessage); + +const addQuery = (queryClient: QueryClient, key: readonly unknown[]): void => { + queryClient.setQueryData(key, { ready: true }); +}; + +afterEach(() => { + setDashboardRealtimeStatus(channelId, "offline"); +}); + +describe("dashboard realtime messages", () => { + it("accepts module state hints for panel query invalidation", () => { + const result = parseRealtimeMessage(JSON.stringify({ + version: 1, + id: "module-sample-1", + createdAt: "2026-10-09T08:00:00.000Z", + channelId: "channel-a", + type: "modul.belabox.sample", + payload: { at: "2026-10-09T08:00:00.000Z", connected: true }, + }), "channel-a"); + + expect(result.kind).toBe("message"); + if (result.kind === "message") expect(result.message.type).toBe("modul.belabox.sample"); + }); + + it("invalidates event and originating module queries for event hints", () => { + const queryClient = new QueryClient(); + const events = dashboardDataKeys.events(channelId, { origin: null, module: null, tone: null, person: null }); + const chatVoting = moduleQueryKey(channelId, "chat_voting", "panel"); + const belabox = moduleQueryKey(channelId, "belabox", "status"); + const unrelated = moduleQueryKey(channelId, "ads", "schedule"); + [events, chatVoting, belabox, unrelated].forEach((key) => { addQuery(queryClient, key); }); + + invalidateDashboardRealtimeMessage(queryClient, moduleHint("event_log.new", { + entries: [ + { eventId: "event-1", createdAt: "2026-10-09T08:00:00.000Z", moduleId: "chat_voting", code: "chat_voting.started", actorUserId: null }, + { eventId: "event-2", createdAt: "2026-10-09T08:00:00.000Z", moduleId: "belabox", code: "belabox.sample", actorUserId: null }, + ], + })); + + expect(queryClient.getQueryState(events)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(chatVoting)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(belabox)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(unrelated)?.isInvalidated).toBe(false); + }); + + it("maps variables, overlays, ad schedules, and module hints to their channel data keys", () => { + const queryClient = new QueryClient(); + const variables = dashboardDataKeys.variables(channelId); + const overlays = dashboardDataKeys.overlays(channelId); + const overlay = dashboardDataKeys.overlay(channelId, "overlay-a"); + const textCommands = moduleQueryKey(channelId, "text_commands", "template-variables"); + const ads = moduleQueryKey(channelId, "ads", "schedule"); + const belabox = moduleQueryKey(channelId, "belabox", "status"); + [variables, overlays, overlay, textCommands, ads, belabox].forEach((key) => { addQuery(queryClient, key); }); + + invalidateDashboardRealtimeMessage(queryClient, moduleHint("variables.changed", { set: [{ name: "score", value: 1 }], removed: [] })); + expect(queryClient.getQueryState(variables)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(overlays)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(overlay)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(textCommands)?.isInvalidated).toBe(true); + queryClient.setQueryData(belabox, { ready: true }); + + invalidateDashboardRealtimeMessage(queryClient, moduleHint("overlay.changed", { overlayId: "overlay-a", revision: 2 })); + expect(queryClient.getQueryState(overlays)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(overlay)?.isInvalidated).toBe(true); + + invalidateDashboardRealtimeMessage(queryClient, moduleHint("ads.schedule.updated", { asOf: "2026-10-09T08:00:00.000Z" })); + expect(queryClient.getQueryState(ads)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(belabox)?.isInvalidated).toBe(false); + + invalidateDashboardRealtimeMessage(queryClient, moduleHint("modul.belabox.sample", { at: "2026-10-09T08:00:00.000Z" })); + expect(queryClient.getQueryState(belabox)?.isInvalidated).toBe(true); + }); + + it("invalidates the right module cache for each panel live-state hint", () => { + const hints = [ + ["chat_voting", "modul.chat_voting.opened"], + ["chat_voting", "modul.chat_voting.tally"], + ["belabox", "modul.belabox.state_changed"], + ["belabox", "modul.belabox.sample"], + ["votekick", "modul.votekick.opened"], + ["votekick", "modul.votekick.tally"], + ] as const; + + for (const [moduleId, type] of hints) { + const queryClient = new QueryClient(); + const relevant = moduleQueryKey(channelId, moduleId, "panel"); + const unrelated = moduleQueryKey(channelId, "ads", "schedule"); + [relevant, unrelated].forEach((key) => { addQuery(queryClient, key); }); + + const parsed = parseRealtimeMessage(JSON.stringify({ + version: 1, + id: `hint-${type}`, + createdAt: "2026-10-09T08:00:00.000Z", + channelId, + type, + payload: {}, + }), channelId); + expect(parsed.kind).toBe("message"); + if (parsed.kind === "message") invalidateDashboardRealtimeMessage(queryClient, parsed.message); + + expect(queryClient.getQueryState(relevant)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(unrelated)?.isInvalidated).toBe(false); + } + }); + + it("refreshes chat voting when a shared votekick ballot opens or closes", () => { + const hints = [ + { type: "modul.votekick.opened", payload: { votekickId: "kick-a" }, refreshChatVoting: true }, + { type: "modul.votekick.tally", payload: { status: "running" }, refreshChatVoting: false }, + { type: "modul.votekick.tally", payload: { status: "cancelled" }, refreshChatVoting: true }, + ] as const; + + for (const hint of hints) { + const queryClient = new QueryClient(); + const chatVoting = moduleQueryKey(channelId, "chat_voting", "panel"); + const votekick = moduleQueryKey(channelId, "votekick", "panel"); + [chatVoting, votekick].forEach((key) => { addQuery(queryClient, key); }); + + invalidateDashboardRealtimeMessage(queryClient, moduleHint(hint.type, hint.payload)); + + expect(queryClient.getQueryState(votekick)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(chatVoting)?.isInvalidated).toBe(hint.refreshChatVoting); + } + }); + + it("invalidates channel queries on stream changes and reconnect", () => { + const queryClient = new QueryClient(); + const overview = queryKeys.channel(channelId, "overview"); + const system = queryKeys.channel(channelId, "system"); + const channels = queryKeys.channels(); + [overview, system, channels].forEach((key) => { addQuery(queryClient, key); }); + + invalidateDashboardRealtimeMessage(queryClient, moduleHint("stream.state.changed", { + state: "online", startedAt: "2026-10-09T08:00:00.000Z", changedAt: "2026-10-09T08:00:00.000Z", + })); + expect(queryClient.getQueryState(overview)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(system)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(channels)?.isInvalidated).toBe(true); + + [overview, system, channels].forEach((key) => queryClient.setQueryData(key, { ready: true })); + invalidateDashboardChannelQueries(queryClient, channelId); + expect(queryClient.getQueryState(overview)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(system)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(channels)?.isInvalidated).toBe(true); + }); + + it("tracks connection state per channel for conditional fallback queries", () => { + setDashboardRealtimeStatus(channelId, "connected"); + expect(getDashboardRealtimeStatus(channelId)).toBe("connected"); + expect(getDashboardRealtimeStatus("channel-b")).toBe("offline"); + }); +}); diff --git a/tests/unit/dispatch.test.ts b/tests/unit/dispatch.test.ts index aadffe4e..2de013f8 100644 --- a/tests/unit/dispatch.test.ts +++ b/tests/unit/dispatch.test.ts @@ -1394,6 +1394,7 @@ describe("dispatch and execution", () => { expect(publish).toHaveBeenCalledTimes(1); expect(publish.mock.calls[0]?.[0]).toMatchObject([ { type: "stream.state.changed", payload: { state: "online", startedAt: "2026-09-19T11:55:00.000Z" } }, + { type: "modul.belabox.state_changed", payload: { reason: "stream.state.changed" } }, { type: "variables.changed", payload: { set: [{ name: "score", value: 0 }], removed: [], overlayIdsByVariable: { score: ["overlay-a"] }, } }, diff --git a/tests/unit/module-overlay-realtime.test.ts b/tests/unit/module-overlay-realtime.test.ts index 3cd363c0..55b7c058 100644 --- a/tests/unit/module-overlay-realtime.test.ts +++ b/tests/unit/module-overlay-realtime.test.ts @@ -179,4 +179,28 @@ describe("module overlay realtime routing", () => { expect(result).toEqual({ outcome: "no_recipients" }); }); + + it("still prepares panel state hints when a poll module has no overlay recipient", async () => { + const chatVoting: BotModule = { + id: "chat_voting", + navigationCategory: "chat", + settingsSchema: { parse: (value: unknown) => value } as never, + defaultSettings: {}, + overlayElements: [declaration("chat_voting.poll")], + }; + + const result = await prepareModuleOverlayRealtimeMessage( + databaseFor([]).database, + "kanal-a", + "chat_voting", + { kind: "overlay", type: "tally", elementKind: "chat_voting.poll", payload: { pollId: "poll-1" } }, + false, + [chatVoting], + ); + + expect(result).toMatchObject({ + outcome: "ready", + message: { type: "modul.chat_voting.tally", overlayIds: [] }, + }); + }); }); diff --git a/tests/unit/stream-state-lookup.test.ts b/tests/unit/stream-state-lookup.test.ts index 7c0f7bcd..ef23684e 100644 --- a/tests/unit/stream-state-lookup.test.ts +++ b/tests/unit/stream-state-lookup.test.ts @@ -177,6 +177,7 @@ describe("lookupAndRefreshStreamState", () => { ]); expect(publish.mock.calls[1]?.[0]).toMatchObject([ { type: "stream.state.changed", payload: { state: "online", startedAt } }, + { type: "modul.belabox.state_changed", payload: { reason: "stream.state.changed" } }, ]); await expect(database.prepare("SELECT value FROM channel_variables WHERE name = 'score'").first()) .resolves.toEqual({ value: 0 }); @@ -266,6 +267,10 @@ describe("lookupAndRefreshStreamState", () => { type: "stream.state.changed", channelId: "kanal-a", payload: { state: "online", startedAt: "2026-09-23T11:58:00.000Z" }, + }, { + type: "modul.belabox.state_changed", + channelId: "kanal-a", + payload: { reason: "stream.state.changed" }, }]); } finally { database.close(); @@ -286,6 +291,9 @@ describe("lookupAndRefreshStreamState", () => { expect(publish.mock.calls[0]?.[0]).toMatchObject([{ type: "stream.state.changed", payload: { state: "offline", changedAt: NOW, checkedAt: NOW }, + }, { + type: "modul.belabox.state_changed", + payload: { reason: "stream.state.changed" }, }]); } finally { database.close(); @@ -409,6 +417,9 @@ describe("lookupAndRefreshStreamState", () => { pause: { active: true, mode: "until_stream_end" }, }, }, + }, { + type: "modul.belabox.state_changed", + payload: { reason: "stream.state.changed" }, }]); } finally { database.close(); diff --git a/tests/unit/votekick-routes.test.ts b/tests/unit/votekick-routes.test.ts index 1cfabcaf..e1ac2a1b 100644 --- a/tests/unit/votekick-routes.test.ts +++ b/tests/unit/votekick-routes.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createCsrfToken } from "../../src/worker/auth/csrf"; +import { encryptJson, parseKeyRing } from "../../src/worker/auth/crypto"; import { createSessionCookie } from "../../src/worker/auth/session"; +import { upsertBotIdentity } from "../../src/worker/db/bot-identity"; +import { upsertLoginIdentity } from "../../src/worker/db/login-identity"; +import { votekickTimeoutReason } from "../../src/modules/votekick/domain"; +import type { RealtimeMessage } from "../../src/realtime-contract"; import { panelRouter } from "../../src/worker/panel/routes"; import { insertChannel, insertLoginIdentityAndSession, insertMember, testKey } from "./fixtures"; import { TestD1Database } from "./test-d1"; @@ -10,6 +15,9 @@ const keys = { SESSION_COOKIE_KEYS: JSON.stringify({ active: { id: "cookie-v1", key: testKey(1) }, retired: [] }), SESSION_ENCRYPTION_KEYS: JSON.stringify({ active: { id: "encryption-v1", key: testKey(2) }, retired: [] }), }; +const tokenKeys = keys.SESSION_ENCRYPTION_KEYS; +const botUserId = "bot-a"; +const targetUserId = "viewer-a"; const databases: TestD1Database[] = []; const databaseFor = async (): Promise => { @@ -20,14 +28,28 @@ const databaseFor = async (): Promise => { return database; }; -const environmentFor = (database: TestD1Database, runModuleAlarm = vi.fn(() => Promise.resolve())): Env => ({ - DB: database as unknown as D1Database, - CHANNEL: { - idFromName: (channelId: string) => channelId, - get: () => ({ runModuleAlarm }), - }, - ...keys, -} as unknown as Env); +const environmentFor = ( + database: TestD1Database, + runModuleAlarm = vi.fn(() => Promise.resolve()), + publish = vi.fn<(messages: readonly RealtimeMessage[]) => Promise>(() => Promise.resolve()), +): Env => { + const channelObject = { + runModuleAlarm, + publish, + getTwitchRateLimitRetryAfter: vi.fn(() => Promise.resolve(null)), + setTwitchRateLimitRetryAfter: vi.fn(() => Promise.resolve()), + }; + return { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => channelObject, + }, + TWITCH_CLIENT_ID: "test-client", + TOKEN_ENCRYPTION_KEYS: tokenKeys, + ...keys, + } as unknown as Env; +}; const requestFor = async (userId: string, path: string, method = "GET"): Promise => { const sessionId = `session-${userId}`; @@ -53,9 +75,23 @@ const insertRunning = async (database: TestD1Database, channelId: string, id: st }; afterEach(() => { + vi.unstubAllGlobals(); for (const database of databases.splice(0)) database.close(); }); +const insertPassed = async (database: TestD1Database, id: string): Promise<{ startedAt: string; endedAt: string; expiresAt: string }> => { + const startedAt = new Date(Date.now() - 30_000).toISOString(); + const endedAt = new Date(Date.now() - 20_000).toISOString(); + const expiresAt = new Date(Date.parse(endedAt) + 90_000).toISOString(); + await database.prepare( + `INSERT INTO votekicks + (channel_id, votekick_id, target_user_id, target_login, initiator_user_id, status, threshold, + yes_votes, no_votes, ballot_revision, duration_seconds, started_at, expires_at, ended_at, lifted_at) + VALUES ('channel-a', ?, ?, 'viewer-a', 'starter-a', 'passed', 3, 3, 0, 2, 90, ?, ?, ?, NULL)`, + ).bind(id, targetUserId, startedAt, expiresAt, endedAt).run(); + return { startedAt, endedAt, expiresAt }; +}; + describe("Votekick routes", () => { it("scopes panel history to the authorized channel and rejects cross-tenant reads", async () => { const database = await databaseFor(); @@ -102,6 +138,70 @@ describe("Votekick routes", () => { expect(row).toEqual({ status: "cancelled" }); }); + it("publishes a votekick tally hint after an operator lifts its timeout", async () => { + const database = await databaseFor(); + await insertLoginIdentityAndSession(database, "operator-a"); + await insertMember(database, "channel-a", "operator-a", "manager"); + const id = "ballot-lift"; + const { startedAt, endedAt, expiresAt } = await insertPassed(database, id); + const ciphertext = await encryptJson({ token: "fictional-bot-token" }, parseKeyRing(tokenKeys)); + const broadcasterCiphertext = await encryptJson({ token: "fictional-broadcaster-token" }, parseKeyRing(tokenKeys)); + const operatorAt = new Date().toISOString(); + await upsertLoginIdentity(database as unknown as D1Database, { + userId: "channel-a", + login: "channel-a", + scopesJson: '["moderation:read"]', + tokenScopesJson: '["moderation:read"]', + accessTokenCiphertext: broadcasterCiphertext, + refreshTokenCiphertext: broadcasterCiphertext, + expiresAt: "2099-10-09T00:00:00.000Z", + status: "connected", + reason: null, + createdAt: operatorAt, + updatedAt: operatorAt, + }); + await upsertBotIdentity(database as unknown as D1Database, { + id: 1, + userId: botUserId, + login: "fictional-bot", + scopesJson: '["moderator:manage:banned_users"]', + accessTokenCiphertext: ciphertext, + refreshTokenCiphertext: ciphertext, + expiresAt: "2099-10-09T00:00:00.000Z", + createdAt: startedAt, + updatedAt: startedAt, + }); + await database.prepare( + "INSERT INTO bot_channel_status (channel_id, is_moderator, checked_at, reason) VALUES ('channel-a', 1, ?, NULL)", + ).bind(startedAt).run(); + const publish = vi.fn<(messages: readonly RealtimeMessage[]) => Promise>(() => Promise.resolve()); + const twitchFetch = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ data: [{ + user_id: targetUserId, + moderator_id: botUserId, + reason: votekickTimeoutReason(3, 0, id), + created_at: endedAt, + expires_at: expiresAt, + }] }), { status: 200, headers: { "content-type": "application/json" } })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", twitchFetch); + + const response = await panelRouter.fetch( + await requestFor("operator-a", `/api/channels/channel-a/modules/votekick/votekicks/${id}/lift`, "POST"), + environmentFor(database, undefined, publish), + ); + + expect(response.status).toBe(200); + expect(publish).toHaveBeenCalledOnce(); + const publishedMessage = publish.mock.calls[0]?.[0]?.[0]; + expect(publishedMessage).toMatchObject({ + type: "modul.votekick.tally", + channelId: "channel-a", + overlayIds: [], + payload: { votekickId: id, status: "passed" }, + }); + }); + it("reconciles a running votekick through its alarm handler on panel access", async () => { const database = await databaseFor(); await insertLoginIdentityAndSession(database, "operator-a"); diff --git a/tests/worker/channel-object.test.ts b/tests/worker/channel-object.test.ts index d38e4329..53ae5b46 100644 --- a/tests/worker/channel-object.test.ts +++ b/tests/worker/channel-object.test.ts @@ -32,6 +32,7 @@ vi.mock("../../src/worker/module-secrets", () => ({ import type { RealtimeEnvelope, + RealtimeMessage, RealtimeOverlayPrincipal, RealtimePanelPrincipal, RealtimePrincipal, @@ -1158,6 +1159,39 @@ describe("ChannelObject realtime path", () => { expect(panel.send.mock.calls).toHaveLength(0); }); + it("routes panel-enabled module messages to channel members and their overlays", async () => { + const overlay = overlaySocketFor({ + v: 1, kind: "overlay", channelId: "kanal-a", tokenId: "token-a", overlayId: "overlay-a", expiresAt: null, + }); + const panel = socketFor(validPrincipal()); + const object = objectFor([overlay, panel]); + const panelMessageTypes = [ + "modul.chat_voting.opened", + "modul.chat_voting.tally", + "modul.belabox.state_changed", + "modul.belabox.sample", + "modul.votekick.opened", + "modul.votekick.tally", + ]; + const messages = [...panelMessageTypes, "modul.text_library.blocks_updated"].map((type, index) => ({ + version: 1, + id: `module-message-${String(index)}`, + createdAt: "2026-10-09T08:00:00.000Z", + channelId: "kanal-a", + type, + payload: { messageType: type }, + overlayIds: ["overlay-a"], + } as unknown as RealtimeMessage)); + + await object.publish(messages); + + expect(panel.send.mock.calls.map(([serialized]) => typeOfSerializedMessage(serialized))).toEqual(panelMessageTypes); + expect(overlay.send.mock.calls.map(([serialized]) => typeOfSerializedMessage(serialized))).toEqual([ + ...panelMessageTypes, + "modul.text_library.blocks_updated", + ]); + }); + it("routes overlay changes by the durable overlay tag after a fresh object instance", async () => { const firstOverlay = overlaySocketFor({ v: 1, kind: "overlay", channelId: "kanal-a", tokenId: "token-a", overlayId: "overlay-a", expiresAt: null, From 30bad845992c6e42af9a2ae038de938fef558dd0 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 02:43:18 +0200 Subject: [PATCH 02/16] =?UTF-8?q?=F0=9F=90=9B=20fix(dashboard):=20throttle?= =?UTF-8?q?=20hints,=20reconcile=20on=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realtime query refresh - Throttle active query keys to one leading and one trailing read per second. - Reconcile channel queries on every socket connection and keep fallback polling active only offline. Regression coverage - Cover saved chat-voting defaults, cached first-connect reconciliation, and sustained fast hints. - Preserve event snapshots and editor drafts through refreshes; references #387. --- src/dashboard/ChannelVariablesPage.tsx | 42 +++- src/dashboard/OverlayEditorPage.tsx | 10 +- src/dashboard/OverlaysPage.tsx | 16 +- src/dashboard/data/index.ts | 1 + src/dashboard/data/lists.ts | 4 +- src/dashboard/data/query-error.ts | 13 + src/dashboard/data/realtime.ts | 89 ++++++- src/dashboard/locale.ts | 3 + src/dashboard/main.tsx | 59 +++-- src/dashboard/module-panels.tsx | 19 +- src/dashboard/realtime.ts | 5 +- src/dashboard/stream-manager.tsx | 24 +- src/modules/ads/index.ts | 1 + src/modules/ads/panel/index.tsx | 37 ++- src/modules/belabox/index.ts | 1 + src/modules/belabox/panel/index.tsx | 42 ++-- src/modules/chat_voting/index.ts | 1 + src/modules/chat_voting/panel/index.tsx | 22 +- src/modules/contract.ts | 4 +- src/modules/votekick/index.ts | 1 + src/modules/votekick/panel/index.tsx | 22 +- tests/components/ads-panel.test.tsx | 41 ++- tests/components/belabox-panel.test.tsx | 42 +++- .../channel-variables-page.test.tsx | 35 +++ tests/components/chat-voting-panel.test.tsx | 72 +++++- tests/components/dashboard.test.tsx | 233 +++++++++++++++--- tests/components/events-page.test.tsx | 51 +++- tests/components/module-panels.test.tsx | 37 ++- tests/components/overlay-editor-page.test.tsx | 137 ++++++++++ tests/components/overlays-page.test.tsx | 22 +- tests/components/votekick-panel.test.tsx | 32 ++- tests/unit/dashboard-query-data.test.tsx | 37 ++- tests/unit/dashboard-realtime.test.ts | 55 ++++- 33 files changed, 1035 insertions(+), 175 deletions(-) create mode 100644 src/dashboard/data/query-error.ts diff --git a/src/dashboard/ChannelVariablesPage.tsx b/src/dashboard/ChannelVariablesPage.tsx index a75332c8..b2458488 100644 --- a/src/dashboard/ChannelVariablesPage.tsx +++ b/src/dashboard/ChannelVariablesPage.tsx @@ -11,6 +11,8 @@ import { saveOverlay, updateChannelVariable, type PanelChannelVariable, + type PanelChannelVariableRecord, + type PanelChannelVariablesResponse, type PanelOverlayElement, } from "./api"; import { apiErrorText, channelVariablesTexts, dashboardCommonTexts, dashboardLanguage } from "./locale"; @@ -85,6 +87,32 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o const result = await refetchVariables({ throwOnError: true }); if (result.isError) throw result.error; }, [refetchVariables]); + const commitVariable = (previousName: string | null, record: PanelChannelVariableRecord, usages?: PanelChannelVariable["usages"]): void => { + queryClient.setQueryData(dashboardDataKeys.variables(channelId), (current) => { + if (current === undefined) return current; + const previousIndex = previousName === null ? -1 : current.variables.findIndex((variable) => variable.name === previousName); + const existing = previousIndex < 0 ? current.variables.find((variable) => variable.name === record.name) : current.variables[previousIndex]; + const nextVariable: PanelChannelVariable = { ...record, usages: usages ?? existing?.usages ?? [] }; + const next = current.variables.filter((variable) => variable.name !== previousName && variable.name !== record.name); + next.splice(previousIndex < 0 ? next.length : Math.min(previousIndex, next.length), 0, nextVariable); + next.sort((left, right) => left.name.localeCompare(right.name)); + return { + ...current, + variables: next, + count: previousName === null && existing === undefined ? current.count + 1 : current.count, + }; + }); + }; + const commitVariableRemoval = (name: string): void => { + queryClient.setQueryData(dashboardDataKeys.variables(channelId), (current) => current === undefined + ? current + : { + ...current, + variables: current.variables.filter((variable) => variable.name !== name), + count: Math.max(0, current.count - 1), + }); + }; + const refreshBestEffort = (): void => { void refresh().catch(() => undefined); }; useEffect(() => { if (!variablesQuery.isError) return; @@ -179,19 +207,21 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o description: draftDescription, resetOnStreamStart: draftReset, }); - await refresh(); + commitVariable(null, response.variable, response.usages); setCreating(false); setSelectedName(response.variable.name); + refreshBestEffort(); } else if (selected !== null) { const response = await updateChannelVariable(channelId, selected.name, { newName: name, description: draftDescription, resetOnStreamStart: draftReset, }); - await refresh(); + commitVariable(selected.name, response.variable, response.usages); setSelectedName(response.variable.name); setDraftName(response.variable.name); if (response.variable.name !== selected.name) setUseOverlayOpen(false); + refreshBestEffort(); } } catch (caught) { notify({ tone: "error", message: caught instanceof PanelApiError ? apiErrorText(caught.code, labels.saveError) : labels.saveError }); @@ -204,8 +234,9 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o setPending(true); try { const response = await changeChannelVariableValue(channelId, selected.name, operation, amount); - await refresh(); + commitVariable(selected.name, response.variable); setDraftSetValue(response.variable.value); + refreshBestEffort(); return true; } catch (caught) { notify({ tone: "error", message: caught instanceof PanelApiError ? apiErrorText(caught.code, labels.saveError) : labels.saveError }); @@ -269,7 +300,7 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o await saveOverlay(channelId, overlay.id, overlay.revision, { name: overlay.name, width: overlay.width, height: overlay.height, css: overlay.css, elements, }, { elementId: usage.elementId, missingVariableName: selected.name }); - await refresh(); + refreshBestEffort(); } catch (caught) { notify({ tone: "error", message: caught instanceof PanelApiError ? apiErrorText(caught.code, labels.saveError) : labels.saveError }); } finally { @@ -282,8 +313,9 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o try { await deleteChannelVariable(channelId, selected.name); setConfirmDelete(false); - await refresh(); + commitVariableRemoval(selected.name); closeInspector(); + refreshBestEffort(); } catch (caught) { notify({ tone: "error", message: caught instanceof PanelApiError ? apiErrorText(caught.code, labels.deleteError) : labels.deleteError }); } finally { diff --git a/src/dashboard/OverlayEditorPage.tsx b/src/dashboard/OverlayEditorPage.tsx index 7c7e0dea..188e4c1c 100644 --- a/src/dashboard/OverlayEditorPage.tsx +++ b/src/dashboard/OverlayEditorPage.tsx @@ -326,7 +326,7 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i ? apiErrorText(variablesQuery.error.code, labels.editorLoadError) : labels.editorLoadError : null; - const overlayLoadError = overlayId !== "new" && overlayQuery.isError && overlayQuery.data === undefined + const overlayLoadError = session === null && overlayId !== "new" && overlayQuery.isError ? overlayQuery.error instanceof PanelApiError ? apiErrorText(overlayQuery.error.code, labels.editorLoadError) : labels.editorLoadError @@ -342,21 +342,19 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i const message = variablesQuery.error instanceof PanelApiError ? apiErrorText(variablesQuery.error.code, labels.editorLoadError) : labels.editorLoadError; - initializedSessionKey.current = sessionKey; notify({ tone: "error", message }); return; } if (variablesQuery.data === undefined) return; const variableData = variablesQuery.data; - if (overlayId !== "new" && overlayQuery.isError && overlayQuery.data === undefined) { + if (overlayId !== "new" && overlayQuery.isError) { const message = overlayQuery.error instanceof PanelApiError ? apiErrorText(overlayQuery.error.code, labels.editorLoadError) : labels.editorLoadError; - initializedSessionKey.current = sessionKey; notify({ tone: "error", message }); return; } - if (overlayId !== "new" && overlayQuery.data === undefined) return; + if (overlayId !== "new" && (overlayQuery.data === undefined || overlayQuery.isFetching)) return; let disposed = false; const overlay = overlayId === "new" ? { @@ -389,7 +387,7 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i notify({ tone: "error", message }); }); return () => { disposed = true; }; - }, [channelId, initialOverlayName, labels.editorLoadError, loadGeneration, overlayId, overlayQuery.data, overlayQuery.error, overlayQuery.isError, variablesQuery.data, variablesQuery.error, variablesQuery.isError]); + }, [channelId, initialOverlayName, labels.editorLoadError, loadGeneration, overlayId, overlayQuery.data, overlayQuery.error, overlayQuery.isError, overlayQuery.isFetching, variablesQuery.data, variablesQuery.error, variablesQuery.isError]); const reload = useCallback((): void => { setSession(null); diff --git a/src/dashboard/OverlaysPage.tsx b/src/dashboard/OverlaysPage.tsx index 1b55d339..10b034d6 100644 --- a/src/dashboard/OverlaysPage.tsx +++ b/src/dashboard/OverlaysPage.tsx @@ -26,6 +26,8 @@ import { import { apiErrorText, dashboardCommonTexts, dashboardLanguage, formatTimestamp, overlaysTexts } from "./locale"; import { ActionMenu, Button, ConfirmDialog, EmptyCellValue, Field, FormDialog, InspectorActions, InspectorSection, Led, ListDetail, ListPaginationFooter, ListToolbar, LoadState, notify, NumberField, PageHeader, Select, Skeleton, SubInspector } from "./ui"; import { useOverlayQuery, useOverlaysQuery } from "./data/lists"; +import { dashboardDataKeys } from "./data/keys"; +import { useDashboardQueryError } from "./data"; interface OverlaysPageProperties { channelId: string; @@ -243,6 +245,7 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const [selectedId, setSelectedId] = useState(initialSelection ?? null); const selectedIdRef = useRef(initialSelection ?? null); const selectedOverlayQuery = useOverlayQuery(channelId, selectedId ?? "", selectedId !== null); + const selectedOverlayError = useDashboardQueryError(dashboardDataKeys.overlay(channelId, selectedId ?? "")); const [selectedOverlayData, setSelectedOverlayData] = useState<{ id: string; accesses: readonly PanelOverlayAccess[]; @@ -323,6 +326,7 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const selected = useMemo(() => overlays.find((overlay) => overlay.id === selectedId) ?? null, [overlays, selectedId]); const refetchOverlays = overlaysQuery.refetch; const selectedOverlay = selectedOverlayQuery.data?.overlay ?? null; + const selectedOverlayLoadFailed = selectedOverlayError !== null; const accesses = selectedOverlayData?.id === selectedId ? selectedOverlayData.accesses : []; const revokedAccesses = accesses.filter((access) => access.revokedAt !== null); const setupAccess = accesses.find((access) => access.tokenId === setupAccessId) ?? null; @@ -804,7 +808,16 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi
: selectedId === null ? null : - {selectedOverlay === null ?

{labels.loading}

:
+
} + empty={
} + error={

{labels.loadError}

} + onRetry={() => { void selectedOverlayQuery.refetch({ throwOnError: true }).catch(() => undefined); }} + refreshError={selectedOverlay !== null && selectedOverlayLoadFailed} + > + {selectedOverlay === null ? null :
{labels.size}
{selectedOverlay.width} × {selectedOverlay.height}
@@ -901,6 +914,7 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi { setConfirmDelete(true); }}>{labels.delete}} />
} + ; return <> diff --git a/src/dashboard/data/index.ts b/src/dashboard/data/index.ts index aa29d1e4..3c390c80 100644 --- a/src/dashboard/data/index.ts +++ b/src/dashboard/data/index.ts @@ -1,3 +1,4 @@ export { useQueryClient as useDashboardQueryClient } from "@tanstack/react-query"; export { moduleQueryKey, refetchModuleQueryData, runModuleQueryWrite, useModuleQuery } from "./module-query"; export type { ModuleQueryFunction, ModuleQueryOptions, ModuleQueryPart, ModuleQueryRevision, ModuleQueryWriteFunction, ModuleQueryWriteOptions } from "./module-query"; +export { useDashboardQueryError } from "./query-error"; diff --git a/src/dashboard/data/lists.ts b/src/dashboard/data/lists.ts index 318d77e0..a168791a 100644 --- a/src/dashboard/data/lists.ts +++ b/src/dashboard/data/lists.ts @@ -39,10 +39,11 @@ export const useEventsQuery = (channelId: string, filters: PanelEventFilters) => }); }; -export const useSystemQuery = (channelId: string, enabled: boolean) => useQuery({ +export const useSystemQuery = (channelId: string, enabled: boolean, refetchInterval: number | false = false) => useQuery({ queryKey: dashboardDataKeys.system(channelId), queryFn: ({ signal }) => fetchSystemOverview(channelId, signal), enabled, + refetchInterval, refetchOnWindowFocus: false, placeholderData: (previousData, previousQuery) => previousQuery?.queryKey[1] === channelId ? previousData : undefined, @@ -69,6 +70,7 @@ export const useOverlayQuery = (channelId: string, overlayId: string, enabled = queryKey: dashboardDataKeys.overlay(channelId, overlayId), queryFn: ({ signal }) => fetchOverlay(channelId, overlayId, signal), enabled, + refetchOnMount: "always", refetchOnWindowFocus: false, placeholderData: () => undefined, }); diff --git a/src/dashboard/data/query-error.ts b/src/dashboard/data/query-error.ts new file mode 100644 index 00000000..acee38ee --- /dev/null +++ b/src/dashboard/data/query-error.ts @@ -0,0 +1,13 @@ +import { hashKey, useQueryClient, type QueryKey } from "@tanstack/react-query"; +import { useCallback, useSyncExternalStore } from "react"; + +/** Observe one cached query error, including background failures with stale data. */ +export const useDashboardQueryError = (queryKey: QueryKey): unknown => { + const queryClient = useQueryClient(); + const queryHash = hashKey(queryKey); + const subscribe = useCallback((onStoreChange: () => void) => queryClient.getQueryCache().subscribe((event) => { + if (event.query.queryHash === queryHash) onStoreChange(); + }), [queryClient, queryHash]); + const getSnapshot = useCallback(() => queryClient.getQueryCache().get(queryHash)?.state.error ?? null, [queryClient, queryHash]); + return useSyncExternalStore(subscribe, getSnapshot, () => null); +}; diff --git a/src/dashboard/data/realtime.ts b/src/dashboard/data/realtime.ts index e66e0bf5..64321efd 100644 --- a/src/dashboard/data/realtime.ts +++ b/src/dashboard/data/realtime.ts @@ -1,10 +1,20 @@ import { useCallback, useSyncExternalStore } from "react"; -import type { QueryClient } from "@tanstack/react-query"; +import { hashKey, type QueryClient } from "@tanstack/react-query"; import type { RealtimeMessage } from "../../realtime-contract"; import { dashboardDataKeys, queryKeys } from "./keys"; import { refreshQuery } from "./refresh"; +const REALTIME_REFRESH_INTERVAL_MS = 1_000; + +interface ScheduledRealtimeRefresh { + lastStartedAt: number; + pending: boolean; + timer: ReturnType | null; +} + +const scheduledRefreshes = new WeakMap>(); + export type DashboardRealtimeStatus = "connecting" | "connected" | "reconnecting" | "offline" | "renew"; const statuses = new Map(); @@ -40,10 +50,51 @@ const invalidate = (queryClient: QueryClient, queryKey: readonly unknown[]): voi const matchingQueries = queryClient.getQueryCache().findAll({ queryKey }); void queryClient.invalidateQueries({ queryKey, refetchType: "none" }); for (const query of matchingQueries) { - if (query.isActive()) void refreshQuery(queryClient, query.queryKey); + if (query.isActive()) scheduleRealtimeRefresh(queryClient, query.queryKey); + } +}; + +const invalidateBelaboxLiveQueries = (queryClient: QueryClient, channelId: string): void => { + const moduleQueries = queryClient.getQueryCache().findAll({ queryKey: dashboardDataKeys.module(channelId, "belabox") }); + for (const query of moduleQueries) { + const part = query.queryKey[4]; + if (part === "status" || part === "streams" || part === "history-live" || (typeof part === "string" && part.startsWith("history-stream-"))) { + invalidate(queryClient, query.queryKey); + } } }; +/** Runs a leading refresh, then limits hints for this key to one refresh per interval. */ +const scheduleRealtimeRefresh = (queryClient: QueryClient, queryKey: readonly unknown[]): void => { + let refreshes = scheduledRefreshes.get(queryClient); + if (refreshes === undefined) { + refreshes = new Map(); + scheduledRefreshes.set(queryClient, refreshes); + } + + const id = hashKey(queryKey); + const now = Date.now(); + let state = refreshes.get(id); + if (state === undefined) { + state = { lastStartedAt: now, pending: false, timer: null }; + refreshes.set(id, state); + void refreshQuery(queryClient, queryKey); + return; + } + + state.pending = true; + if (state.timer !== null) return; + const wait = Math.max(0, REALTIME_REFRESH_INTERVAL_MS - (now - state.lastStartedAt)); + const scheduled = state; + scheduled.timer = setTimeout(() => { + scheduled.timer = null; + if (!scheduled.pending) return; + scheduled.pending = false; + scheduled.lastStartedAt = Date.now(); + void refreshQuery(queryClient, queryKey); + }, wait); +}; + /** Invalidates the channel caches affected by a realtime hint; API responses remain authoritative. */ export const invalidateDashboardRealtimeMessage = ( queryClient: QueryClient, @@ -68,22 +119,36 @@ export const invalidateDashboardRealtimeMessage = ( break; case "overlay.changed": invalidate(queryClient, dashboardDataKeys.overlays(channelId)); - invalidate(queryClient, dashboardDataKeys.overlay(channelId, message.payload.overlayId)); invalidate(queryClient, dashboardDataKeys.variables(channelId)); break; case "ads.schedule.updated": - invalidate(queryClient, dashboardDataKeys.module(channelId, "ads")); + invalidate(queryClient, queryKeys.module(channelId, "ads", "schedule")); break; default: { - if (message.type.startsWith("modul.")) { - const moduleId = message.type.split(".")[1]; - if (moduleId !== undefined) invalidate(queryClient, dashboardDataKeys.module(channelId, moduleId)); - const votekickOpened = message.type === "modul.votekick.opened"; - const votekickClosed = message.type === "modul.votekick.tally" && message.payload.status !== "running"; - if (votekickOpened || votekickClosed) { + switch (message.type) { + case "modul.chat_voting.opened": + case "modul.chat_voting.tally": + invalidate(queryClient, queryKeys.module(channelId, "chat_voting", "panel")); + break; + case "modul.belabox.state_changed": + case "modul.belabox.sample": + // Refresh only live data parts; stream history keys are selected dynamically by the panel. + invalidateBelaboxLiveQueries(queryClient, channelId); + break; + case "modul.votekick.opened": + invalidate(queryClient, queryKeys.module(channelId, "votekick", "panel")); // Chat voting also reads channel-wide ballot availability from the shared ballot. - invalidate(queryClient, dashboardDataKeys.module(channelId, "chat_voting")); - } + invalidate(queryClient, queryKeys.module(channelId, "chat_voting", "panel")); + break; + case "modul.votekick.tally": + invalidate(queryClient, queryKeys.module(channelId, "votekick", "panel")); + if (message.payload.status !== "running") { + // Chat voting also reads channel-wide ballot availability from the shared ballot. + invalidate(queryClient, queryKeys.module(channelId, "chat_voting", "panel")); + } + break; + default: + break; } } } diff --git a/src/dashboard/locale.ts b/src/dashboard/locale.ts index 88d01cd9..d4776213 100644 --- a/src/dashboard/locale.ts +++ b/src/dashboard/locale.ts @@ -1005,6 +1005,7 @@ export interface DashboardTexts { enable: string; cancel: string; failure: string; + refreshFailure: string; }; /** ⌘K/Ctrl+K (#164): jumps to an entity, explicitly not a navigation * replacement -- "raid" opens the module, "!clip" opens that text @@ -1339,6 +1340,7 @@ const dashboardTextsCatalog: LocaleCatalog = { enable: "Aktivieren", cancel: "Abbrechen", failure: "Die Kanalsteuerung konnte nicht geändert werden.", + refreshFailure: "Die Kanalsteuerung wurde geändert, aber ihr Status konnte nicht aktualisiert werden.", }, spotlight: { placeholder: "Suchen oder Aktion ausführen …", @@ -1656,6 +1658,7 @@ const dashboardTextsCatalog: LocaleCatalog = { enable: "Enable", cancel: "Cancel", failure: "The channel control could not be changed.", + refreshFailure: "The channel control changed, but its status could not be refreshed.", }, spotlight: { placeholder: "Search or run action …", diff --git a/src/dashboard/main.tsx b/src/dashboard/main.tsx index 37345c53..3bb410a5 100644 --- a/src/dashboard/main.tsx +++ b/src/dashboard/main.tsx @@ -37,6 +37,7 @@ import { Led, ModuleCount, ModuleHeading, ModuleIcon, ModulePage, ModuleTile, Mo import { BroadcasterConsentAction, ChannelNotices, ImmediateActions, ModeratorCheckAction, WarningsAndErrorsFeed } from "./stream-manager"; import { refreshAfterModuleToggle } from "./data/module-toggle"; import { useSystemQuery } from "./data/lists"; +import { useDashboardRealtimeStatus } from "./data/realtime"; import { ChannelSpotlight } from "./spotlight"; import { MembersPage } from "./members"; import { PlatformPage } from "./platform"; @@ -396,8 +397,18 @@ const ChannelControlExpiryWatcher = ({ controls, onRefresh }: { onRefresh: () => Promise; }): null => { const refreshedExpiryKey = useRef(""); + const onRefreshRef = useRef(onRefresh); + useEffect(() => { onRefreshRef.current = onRefresh; }, [onRefresh]); useEffect(() => { let timer: number | null = null; + let retryTimer: number | null = null; + const refreshExpiredControl = (expiryKey: string): void => { + void onRefreshRef.current().then(() => { + refreshedExpiryKey.current = expiryKey; + }).catch(() => { + retryTimer = window.setTimeout(() => { refreshExpiredControl(expiryKey); }, 30_000); + }); + }; const checkExpiry = (): void => { const now = Date.now(); const mute = controls?.mute ?? CONTROL_OFF; @@ -408,8 +419,7 @@ const ChannelControlExpiryWatcher = ({ controls, onRefresh }: { .join("|"); if (staleExpiryKey.length === 0) refreshedExpiryKey.current = ""; else if (refreshedExpiryKey.current !== staleExpiryKey) { - refreshedExpiryKey.current = staleExpiryKey; - void onRefresh(); + refreshExpiredControl(staleExpiryKey); } const nextExpiry = [mute, pause] .filter((control) => control.active && control.mode === "timed" && control.until !== null) @@ -419,8 +429,11 @@ const ChannelControlExpiryWatcher = ({ controls, onRefresh }: { if (nextExpiry !== undefined) timer = window.setTimeout(checkExpiry, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, nextExpiry - now))); }; checkExpiry(); - return () => { if (timer !== null) window.clearTimeout(timer); }; - }, [controls, onRefresh]); + return () => { + if (timer !== null) window.clearTimeout(timer); + if (retryTimer !== null) window.clearTimeout(retryTimer); + }; + }, [controls]); return null; }; @@ -469,7 +482,6 @@ const ChannelControlActions = ({ channelId, controls, onRefresh }: { try { await setChannelControl(channelId, kind, next); setDialogControl(null); - await onRefresh(); } catch (requestError: unknown) { notify({ tone: "error", @@ -477,6 +489,13 @@ const ChannelControlActions = ({ channelId, controls, onRefresh }: { ? apiErrorText(requestError.code, labels.failure) : labels.failure, }); + setBusy(null); + return; + } + try { + await onRefresh(); + } catch { + notify({ tone: "error", message: labels.refreshFailure }); } finally { setBusy(null); } @@ -1202,6 +1221,8 @@ export const DashboardApp = (): ReactElement => { }, []); const [route, navigate] = useDashboardRoute(); + const realtimeChannelId = route.kind === "channel" || route.kind === "module" ? route.channelId : null; + const realtimeStatus = useDashboardRealtimeStatus(realtimeChannelId ?? ""); const routeRef = useRef(route); useLayoutEffect(() => { routeRef.current = route; }, [route]); const queryClient = useQueryClient(); @@ -1215,6 +1236,7 @@ export const DashboardApp = (): ReactElement => { const channelsQuery = useQuery({ queryKey: queryKeys.channels(), refetchOnWindowFocus: false, + refetchInterval: realtimeStatus === "connected" ? false : 180_000, queryFn: async ({ signal }) => { const response = await fetchChannels(signal); return { @@ -1245,6 +1267,7 @@ export const DashboardApp = (): ReactElement => { const overviewQuery = useQuery({ queryKey: queryKeys.channel(overviewChannelId ?? "", "overview"), refetchOnWindowFocus: false, + refetchInterval: realtimeStatus === "connected" ? false : 180_000, queryFn: async ({ signal }) => { if (overviewChannelId === null) throw new Error("An overview query needs a channel ID."); const response = await fetchChannelOverview(overviewChannelId, signal); @@ -1256,10 +1279,13 @@ export const DashboardApp = (): ReactElement => { previousQuery?.queryKey[1] === overviewChannelId ? previousData : undefined, }); const overview = loadStateFromQuery(overviewQuery); - const realtimeChannelId = route.kind === "channel" || route.kind === "module" ? route.channelId : null; useRealtimePanelMessages(realtimeChannelId, realtimeChannelId !== null); const systemChannelId = route.kind === "channel" && route.section === "system" ? route.channelId : null; - const systemQuery = useSystemQuery(systemChannelId ?? "", !authenticationRequired && systemChannelId !== null); + const systemQuery = useSystemQuery( + systemChannelId ?? "", + !authenticationRequired && systemChannelId !== null, + realtimeStatus === "connected" ? false : 180_000, + ); const [, setFreshnessTick] = useState(0); const routeKey = dashboardRoutePath(route); const [loggingOut, setLoggingOut] = useState(false); @@ -1293,7 +1319,7 @@ export const DashboardApp = (): ReactElement => { const reloadChannels = useCallback(async (): Promise => { const filter = { queryKey: queryKeys.channels(), exact: true } as const; await queryClient.cancelQueries(filter); - await queryClient.invalidateQueries(filter); + await queryClient.invalidateQueries(filter, { throwOnError: true }); }, [queryClient]); const clearProtectedState = (): void => { @@ -1323,7 +1349,7 @@ export const DashboardApp = (): ReactElement => { } const filter = { queryKey: queryKeys.channel(route.channelId, "modules"), exact: true } as const; await queryClient.cancelQueries(filter); - await queryClient.invalidateQueries(filter); + await queryClient.invalidateQueries(filter, { throwOnError: true }); }, [queryClient, reloadChannels, route, selectedChannel]); const reloadAfterModuleToggle = async (channelId: string): Promise => refreshAfterModuleToggle(queryClient, channelId); @@ -1332,14 +1358,14 @@ export const DashboardApp = (): ReactElement => { if (route.kind !== "module" && !(route.kind === "channel" && route.section === "overview")) return; const filter = { queryKey: queryKeys.channel(route.channelId, "overview"), exact: true } as const; await queryClient.cancelQueries(filter); - await queryClient.invalidateQueries(filter); + await queryClient.invalidateQueries(filter, { throwOnError: true }); }, [queryClient, route]); const refreshChannelState = useCallback(async (): Promise => { const refreshFallbackModules = selectedChannel?.modules === undefined ? reloadModules() : Promise.resolve(); const refreshSystem = systemChannelId === null ? Promise.resolve() : (async () => { const filter = { queryKey: queryKeys.channel(systemChannelId, "system"), exact: true } as const; await queryClient.cancelQueries(filter); - await queryClient.invalidateQueries(filter); + await queryClient.invalidateQueries(filter, { throwOnError: true }); })(); await Promise.all([ reloadChannels(), @@ -1352,7 +1378,7 @@ export const DashboardApp = (): ReactElement => { useLayoutEffect(() => { refreshChannelStateRef.current = refreshChannelState; }, [refreshChannelState]); useEffect(() => { const refresh = (): void => { - if (document.visibilityState === "visible") void refreshChannelStateRef.current(); + if (document.visibilityState === "visible") void refreshChannelStateRef.current().catch(() => undefined); }; document.addEventListener("visibilitychange", refresh); return () => { @@ -1404,9 +1430,12 @@ export const DashboardApp = (): ReactElement => { return; } if (expiryRefreshRef.current === expiryRefreshKey) return; - expiryRefreshRef.current = expiryRefreshKey; - setFreshnessTick((current) => current + 1); - void refreshChannelStateRef.current(); + void refreshChannelStateRef.current().then(() => { + expiryRefreshRef.current = expiryRefreshKey; + setFreshnessTick((current) => current + 1); + }).catch(() => { + timer = window.setTimeout(refreshWhenExpired, 30_000); + }); }; timer = window.setTimeout(refreshWhenExpired, Math.min(Math.max(0, expiryAt - Date.now()), MAX_TIMER_DELAY_MS)); return () => { if (timer !== null) window.clearTimeout(timer); }; diff --git a/src/dashboard/module-panels.tsx b/src/dashboard/module-panels.tsx index 401e2462..9425a3c3 100644 --- a/src/dashboard/module-panels.tsx +++ b/src/dashboard/module-panels.tsx @@ -176,27 +176,24 @@ interface ModulePanelMountProperties { initialSelection?: string; } -const ModuleSettingsEditor = ({ module, channelId, canManageContent, language, onSaved }: { +const ModuleSettingsEditor = ({ module, channelId, canManageContent, language }: { module: (typeof MODULES)[number]; channelId: string; canManageContent: boolean; language: DashboardLanguage; - onSaved?: () => void; }): ReactElement | null => module.settingsEditor === undefined ? null : ; -const ModuleSettingsEditorQuery = ({ module, channelId, canManageContent, language, onSaved }: { +const ModuleSettingsEditorQuery = ({ module, channelId, canManageContent, language }: { module: (typeof MODULES)[number]; channelId: string; canManageContent: boolean; language: DashboardLanguage; - onSaved?: () => void; }): ReactElement => { const [generation, setGeneration] = useState(0); const queryClient = useDashboardQueryClient(); @@ -245,7 +242,6 @@ const ModuleSettingsEditorQuery = ({ module, channelId, canManageContent, langua channelVariables={loaded.variables} initial={loaded.settings} initialRevision={loaded.revision} - {...(onSaved === undefined ? {} : { onSaved })} onReload={async () => { try { await refetchModuleQueryData(queryClient, channelId, module.id, "settings"); @@ -259,7 +255,7 @@ const ModuleSettingsEditorQuery = ({ module, channelId, canManageContent, langua ; }; -const LoadedModuleSettingsEditor = ({ module, channelId, canManageContent, definition, copy, retryLabel, channelVariables, initial, initialRevision, onSaved, onReload }: { +const LoadedModuleSettingsEditor = ({ module, channelId, canManageContent, definition, copy, retryLabel, channelVariables, initial, initialRevision, onReload }: { module: (typeof MODULES)[number]; channelId: string; canManageContent: boolean; @@ -269,7 +265,6 @@ const LoadedModuleSettingsEditor = ({ module, channelId, canManageContent, defin channelVariables: readonly PanelChannelVariable[]; initial: Record; initialRevision: number; - onSaved?: () => void; onReload: () => Promise; }): ReactElement => { const queryClient = useDashboardQueryClient(); @@ -450,6 +445,7 @@ const LoadedModuleSettingsEditor = ({ module, channelId, canManageContent, defin updateCache: (current, result) => typeof current === "object" && current !== null ? { ...current, ...result } : current, + relatedParts: (module.settingsEditorRelatedParts ?? []).map((part) => ({ moduleId: module.id, part })), }, ); setValue(response.settings); @@ -458,7 +454,6 @@ const LoadedModuleSettingsEditor = ({ module, channelId, canManageContent, defin setSaved(true); setValidationAttempted(false); setServerWarnings(response.warnings); - onSaved?.(); return null; } catch (caught) { if (caught instanceof PanelApiError && caught.status === 409 && caught.code === "module_settings_changed_concurrently") { @@ -580,16 +575,11 @@ const MountedModuleView = ({ module, Panel, channelId, canManage, canOperate, bo botIsModerator: boolean | null; initialSelection?: string; }): ReactElement => { - const [settingsRefreshToken, setSettingsRefreshToken] = useState(0); - const onSettingsSaved = useCallback((): void => { - setSettingsRefreshToken((current) => current + 1); - }, []); const settingsEditor = ; return
{module.settingsEditorPlacement === "before-panel" ? settingsEditor : null} @@ -600,7 +590,6 @@ const MountedModuleView = ({ module, Panel, channelId, canManage, canOperate, bo canOperate={canOperate} botIsModerator={botIsModerator} textBlockConditions={MODULES.flatMap((candidate) => candidate.textBlockConditions ?? [])} - settingsRefreshToken={settingsRefreshToken} {...(initialSelection === undefined ? {} : { initialSelection })} />} {module.settingsEditorPlacement === "before-panel" ? null : settingsEditor} diff --git a/src/dashboard/realtime.ts b/src/dashboard/realtime.ts index a0f338b4..58789b50 100644 --- a/src/dashboard/realtime.ts +++ b/src/dashboard/realtime.ts @@ -157,7 +157,6 @@ interface PanelSocketConnection { reconnectTimer: ReturnType | null; cleanupTimer: ReturnType | null; reconnectAttempt: number; - hasConnected: boolean; disposed: boolean; references: number; seenMessageIds: Set; @@ -203,8 +202,7 @@ const connectPanelSocket = (channelId: string, connection: PanelSocketConnection connection.socket = socket; socket.addEventListener("open", () => { if (connection.disposed) return; - if (connection.hasConnected) invalidateDashboardChannelQueries(connection.queryClient, channelId); - connection.hasConnected = true; + invalidateDashboardChannelQueries(connection.queryClient, channelId); connection.reconnectAttempt = 0; setDashboardRealtimeStatus(channelId, "connected"); }); @@ -246,7 +244,6 @@ const acquirePanelSocket = (channelId: string, queryClient: QueryClient): (() => reconnectTimer: null, cleanupTimer: null, reconnectAttempt: 0, - hasConnected: false, disposed: false, references: 0, seenMessageIds: new Set(), diff --git a/src/dashboard/stream-manager.tsx b/src/dashboard/stream-manager.tsx index 0877a716..d8d1d108 100644 --- a/src/dashboard/stream-manager.tsx +++ b/src/dashboard/stream-manager.tsx @@ -10,6 +10,8 @@ import { channelPanelTexts } from "./labels"; import { eventCause, eventDetail, eventMetadata } from "./events/model"; import { emptyEventFilter } from "./events/model"; import { useEventsQuery } from "./data/lists"; +import { dashboardDataKeys } from "./data/keys"; +import { useDashboardQueryError } from "./data"; import { evaluateImmediateActionAvailability } from "./immediate-action-availability"; import { Button, LoadState, notify, Popover, Skeleton } from "./ui"; import { dashboardRoutePath, type DashboardRoute } from "./router"; @@ -250,6 +252,8 @@ export const WarningsAndErrorsFeed = ({ channelId, onNavigate }: { channelId: st const texts = dashboardTexts(); const filters = { ...emptyEventFilter, tones: ["warning", "error"] as const }; const query = useEventsQuery(channelId, filters); + const eventQueryError = useDashboardQueryError(dashboardDataKeys.events(channelId, filters)); + const loadErrorNotified = useRef(false); const entries = (query.data?.pages[0]?.entries ?? []) .filter((entry) => { const tone = eventMetadata(entry.code)?.tone; @@ -264,18 +268,30 @@ export const WarningsAndErrorsFeed = ({ channelId, onNavigate }: { channelId: st filters: { ...emptyEventFilter, tones: ["warning", "error"] }, }; useEffect(() => { - if (query.isError) notify({ tone: "error", message: texts.events.connectionLost }); - }, [query.error, query.isError, texts.events.connectionLost]); + if (eventQueryError === null) { + loadErrorNotified.current = false; + return; + } + if (loadErrorNotified.current) return; + loadErrorNotified.current = true; + notify({ tone: "error", message: texts.events.connectionLost }); + }, [eventQueryError, texts.events.connectionLost]); + const queryFailed = eventQueryError !== null; + const feedStatus = query.data === undefined + ? query.isPending ? "loading" : queryFailed ? "error" : "loading" + : entries.length === 0 ? "empty" : "success"; return (
} empty={

{texts.streamManager.feedEmpty}

} - error={} + error={

{texts.events.connectionLost}

} + onRetry={() => { void query.refetch({ throwOnError: true }).catch(() => undefined); }} + refreshError={query.data !== undefined && queryFailed} > {entries.length === 0 ? null :
    {entries.map((entry) => { diff --git a/src/modules/ads/index.ts b/src/modules/ads/index.ts index 7d98ebe1..5715294c 100644 --- a/src/modules/ads/index.ts +++ b/src/modules/ads/index.ts @@ -67,6 +67,7 @@ export const adsModule: BotModule = { routes: adsRoutes, panel: () => import("./panel"), settingsEditor: () => import("./panel/settings-editor"), + settingsEditorRelatedParts: ["schedule"], overlayElements: adsOverlayElements.map((element) => element.kind === "ads.countdown" ? { ...element, diff --git a/src/modules/ads/panel/index.tsx b/src/modules/ads/panel/index.tsx index 82da841a..25c0b569 100644 --- a/src/modules/ads/panel/index.tsx +++ b/src/modules/ads/panel/index.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState, type ReactElement } from "react"; +import { useRef, useState, type ReactElement } from "react"; import type { DashboardLanguage } from "../../../dashboard/locale"; +import type { AdsScheduleResponse } from "../contracts"; import { Button, EmptyCellValue, Icon, LoadState, Skeleton, notify } from "../../../dashboard/ui"; import { loadAdsSchedule, snoozeAds } from "./service"; import { adsPanelTexts } from "./locale"; -import { runModuleQueryWrite, useModuleQuery } from "../../../dashboard/data"; +import { moduleQueryKey, runModuleQueryWrite, useDashboardQueryError, useModuleQuery } from "../../../dashboard/data"; import { useDashboardQueryClient } from "../../../dashboard/data"; import { useDashboardRealtimeStatus } from "../../../dashboard/data/realtime"; @@ -23,17 +24,31 @@ export const AdsPanel = ({ channelId, language = "de" }: { channelId: string; la const loadError = labels.loadError; const realtimeStatus = useDashboardRealtimeStatus(channelId); const queryClient = useDashboardQueryClient(); - const scheduleQuery = useModuleQuery(channelId, "ads", "schedule", (signal) => loadAdsSchedule(channelId, signal), { + const scheduleError = useDashboardQueryError(moduleQueryKey(channelId, "ads", "schedule")); + const loadErrorNotified = useRef(false); + const readSchedule = async (signal: AbortSignal): Promise => { + try { + const response = await loadAdsSchedule(channelId, signal); + loadErrorNotified.current = false; + return response; + } catch (failure: unknown) { + if (!loadErrorNotified.current) { + loadErrorNotified.current = true; + notify({ tone: "error", message: loadError }); + } + throw failure; + } + }; + const scheduleQuery = useModuleQuery(channelId, "ads", "schedule", readSchedule, { refetchInterval: realtimeStatus === "connected" ? false : 60_000, }); const schedule = scheduleQuery.data ?? null; - const loadStatus = scheduleQuery.isPending ? "loading" : scheduleQuery.isError ? "error" : "success"; + const scheduleLoadFailed = scheduleError !== null; + const loadStatus = schedule === null + ? scheduleQuery.isPending ? "loading" : scheduleLoadFailed ? "error" : "loading" + : "success"; const [snoozeBusy, setSnoozeBusy] = useState(false); - useEffect(() => { - if (scheduleQuery.isError) notify({ tone: "error", message: loadError }); - }, [loadError, scheduleQuery.error, scheduleQuery.isError]); - const snoozeCount = schedule?.schedule.snoozeCount ?? null; const snoozeButtonDisabled = schedule === null || snoozeBusy || !schedule.snoozeScopeAvailable || snoozeCount === null || snoozeCount <= 0; const snoozeReason = schedule === null ? "" @@ -71,9 +86,9 @@ export const AdsPanel = ({ channelId, language = "de" }: { channelId: string; la
} empty={
} - error={
- -
}> + error={

{loadError}

} + onRetry={() => { void scheduleQuery.refetch({ throwOnError: true }).catch(() => undefined); }} + refreshError={schedule !== null && scheduleLoadFailed}> {schedule === null ? null : <>

{labels.scheduleSection}

diff --git a/src/modules/belabox/index.ts b/src/modules/belabox/index.ts index 27273f4c..d6c11ab5 100644 --- a/src/modules/belabox/index.ts +++ b/src/modules/belabox/index.ts @@ -205,6 +205,7 @@ export const belaboxModule: BotModule = { settingsEditorPlacement: "before-panel", panel: () => import("./panel/index"), settingsEditor: () => import("./panel/settings-editor"), + settingsEditorRelatedParts: ["status"], immediateActions: { requires: ["streamLive"], load: () => import("./panel/immediate-actions") }, handleEvent: async (event, context) => { if (context.streamStateTransitionAccepted !== true || diff --git a/src/modules/belabox/panel/index.tsx b/src/modules/belabox/panel/index.tsx index 9696e4f9..e7ecc335 100644 --- a/src/modules/belabox/panel/index.tsx +++ b/src/modules/belabox/panel/index.tsx @@ -18,11 +18,26 @@ const statusTimestamp = (value: string, locale: string): string => { return Number.isNaN(date.valueOf()) ? "" : new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(date); }; -export default function BelaboxPanel({ channelId, language = "de", canManage = false, settingsRefreshToken = 0 }: ModulePanelProperties): ReactElement { +export default function BelaboxPanel({ channelId, language = "de", canManage = false }: ModulePanelProperties): ReactElement { const labels = belaboxPanelTexts(language); const realtimeStatus = useDashboardRealtimeStatus(channelId); const queryClient = useDashboardQueryClient(); - const statusQuery = useModuleQuery(channelId, "belabox", "status", (signal) => loadBelaboxStatus(channelId, signal), { + const loadErrorNotified = useRef(false); + const suppressLoadErrorNotification = useRef(false); + const readStatus = useCallback(async (signal: AbortSignal): Promise => { + try { + const response = await loadBelaboxStatus(channelId, signal); + loadErrorNotified.current = false; + return response; + } catch (failure: unknown) { + if (!suppressLoadErrorNotification.current && !loadErrorNotified.current) { + loadErrorNotified.current = true; + notify({ tone: "error", message: labels.testFailed }); + } + throw failure; + } + }, [channelId, labels.testFailed]); + const statusQuery = useModuleQuery(channelId, "belabox", "status", readStatus, { refetchInterval: realtimeStatus === "connected" ? false : (query) => { const current = query.state.data; return current?.pollingDesired === true @@ -40,24 +55,19 @@ export default function BelaboxPanel({ channelId, language = "de", canManage = f const [historyRange, setHistoryRange] = useState<"live" | "stream">("live"); const [selectedStreamId, setSelectedStreamId] = useState(null); const pollingInactive = status?.pollingDesired === true && !status.polling; - const loadErrorNotified = useRef(false); const refresh = useCallback(async (): Promise => { - await refetchModuleQueryData(queryClient, channelId, "belabox", "status"); + suppressLoadErrorNotification.current = true; + try { + await refetchModuleQueryData(queryClient, channelId, "belabox", "status"); + } catch (failure: unknown) { + loadErrorNotified.current = true; + throw failure; + } finally { + suppressLoadErrorNotification.current = false; + } }, [channelId, queryClient]); - useEffect(() => { - if (settingsRefreshToken === 0) return; - void queryClient.invalidateQueries({ queryKey: moduleQueryKey(channelId, "belabox", "status"), exact: true }); - }, [channelId, queryClient, settingsRefreshToken]); - - useEffect(() => { - if (statusQuery.data !== undefined) loadErrorNotified.current = false; - if (!statusQuery.isError || loadErrorNotified.current) return; - loadErrorNotified.current = true; - notify({ tone: "error", message: labels.testFailed }); - }, [labels.testFailed, statusQuery.data, statusQuery.error, statusQuery.isError]); - useEffect(() => { if (pollingInactive) notify({ tone: "error", message: labels.pollingInactive }); }, [labels.pollingInactive, pollingInactive]); diff --git a/src/modules/chat_voting/index.ts b/src/modules/chat_voting/index.ts index 99dca1e4..38141568 100644 --- a/src/modules/chat_voting/index.ts +++ b/src/modules/chat_voting/index.ts @@ -109,6 +109,7 @@ export const chatVotingModule: BotModule = { routes: chatVotingRoutes, panel: () => import("./panel"), settingsEditor: () => import("./panel/settings-editor"), + settingsEditorRelatedParts: ["panel"], eventSubTypes: ["channel.chat.message"], alarms: [chatVotingAlarmDefinition, chatVotingStartAnnouncementAlarmDefinition], overlayElements: chatVotingOverlayElements.map((element) => ({ diff --git a/src/modules/chat_voting/panel/index.tsx b/src/modules/chat_voting/panel/index.tsx index cbd1c4d0..9a840392 100644 --- a/src/modules/chat_voting/panel/index.tsx +++ b/src/modules/chat_voting/panel/index.tsx @@ -61,7 +61,19 @@ export const ChatVotingPanel = ({ channelId, language = "de", canOperate = true const [draftState, setDraftState] = useState(null); const realtimeStatus = useDashboardRealtimeStatus(channelId); const queryClient = useDashboardQueryClient(); - const stateQuery = useModuleQuery(channelId, "chat_voting", "panel", (signal) => loadChatVotingState(channelId, signal), { + const stateQuery = useModuleQuery(channelId, "chat_voting", "panel", async (signal) => { + try { + const response = await loadChatVotingState(channelId, signal); + loadErrorNotified.current = false; + return response; + } catch (failure: unknown) { + if (!loadErrorNotified.current) { + loadErrorNotified.current = true; + notify({ tone: "error", message: labels.loadError }); + } + throw failure; + } + }, { refetchInterval: realtimeStatus === "connected" ? false : 2_000, }); @@ -77,14 +89,6 @@ export const ChatVotingPanel = ({ channelId, language = "de", canOperate = true if (stateQuery.data !== undefined) applyState(stateQuery.data); }, [applyState, stateQuery.data]); - useEffect(() => { - if (!stateQuery.isError) return; - if (!loadErrorNotified.current) { - loadErrorNotified.current = true; - notify({ tone: "error", message: labels.loadError }); - } - }, [labels.loadError, stateQuery.error, stateQuery.isError]); - const refresh = useCallback(async (): Promise => { try { applyState(await refetchModuleQueryData(queryClient, channelId, "chat_voting", "panel")); diff --git a/src/modules/contract.ts b/src/modules/contract.ts index 9e19fb45..4413db84 100644 --- a/src/modules/contract.ts +++ b/src/modules/contract.ts @@ -1041,8 +1041,6 @@ export interface ModulePanelProperties { * mount (e.g. text_commands selects the command by name); most modules * ignore it. */ initialSelection?: string; - /** Incremented after a sibling settings editor saves, so panels can reload dependent data. */ - settingsRefreshToken?: number; } /** Props for one lazily loaded card in the channel's immediate-action row. */ @@ -1360,6 +1358,8 @@ export type BotModule = { panel?: () => Promise<{ default: ComponentType }>; /** Lazily loaded editor declaration for this module's settings. */ settingsEditor?: () => Promise<{ default: SettingsEditorDefinition> }>; + /** Module query parts that depend on settings edited by this editor. */ + settingsEditorRelatedParts?: readonly string[]; /** Places the settings editor before a potentially long module panel. */ settingsEditorPlacement?: "before-panel" | "after-panel"; /** Reconciles one module-owned alarm immediately after settings are saved. */ diff --git a/src/modules/votekick/index.ts b/src/modules/votekick/index.ts index d2beffec..c7ac603f 100644 --- a/src/modules/votekick/index.ts +++ b/src/modules/votekick/index.ts @@ -72,5 +72,6 @@ export const votekickModule: BotModule = { }], panel: () => import("./panel"), settingsEditor: () => import("./panel/settings-editor"), + settingsEditorRelatedParts: ["panel"], handleEvent: (event, context) => processVotekickMessage(event, createVotekickRepository(context.DB), context), }; diff --git a/src/modules/votekick/panel/index.tsx b/src/modules/votekick/panel/index.tsx index 8cf47c58..29be2e03 100644 --- a/src/modules/votekick/panel/index.tsx +++ b/src/modules/votekick/panel/index.tsx @@ -57,14 +57,26 @@ export default function VotekickPanel({ channelId, language, canOperate = true } const labels = useMemo(() => votekickPanelTexts(resolvedLanguage), [resolvedLanguage]); const realtimeStatus = useDashboardRealtimeStatus(channelId); const queryClient = useDashboardQueryClient(); - const panelQuery = useModuleQuery(channelId, "votekick", "panel", (signal) => loadVotekickPanel(channelId, signal), { + const loadErrorNotified = useRef(false); + const panelQuery = useModuleQuery(channelId, "votekick", "panel", async (signal) => { + try { + const panel = await loadVotekickPanel(channelId, signal); + loadErrorNotified.current = false; + return panel; + } catch (error) { + if (!loadErrorNotified.current) { + loadErrorNotified.current = true; + notify({ tone: "error", message: labels.loadError }); + } + throw error; + } + }, { refetchInterval: realtimeStatus === "connected" ? false : 2_000, }); const data = panelQuery.data ?? null; const loading = panelQuery.isPending; const [busy, setBusy] = useState(false); const [dialogError, setDialogError] = useState(); - const loadErrorNotified = useRef(false); const [cancelTarget, setCancelTarget] = useState(null); const [liftTarget, setLiftTarget] = useState(null); @@ -79,12 +91,6 @@ export default function VotekickPanel({ channelId, language, canOperate = true } } } }, [channelId, labels.loadError, queryClient]); - useEffect(() => { - if (!panelQuery.isError || loadErrorNotified.current) return; - loadErrorNotified.current = true; - notify({ tone: "error", message: labels.loadError }); - }, [labels.loadError, panelQuery.error, panelQuery.isError]); - const cancel = async (): Promise => { if (cancelTarget === null) return; setBusy(true); diff --git a/tests/components/ads-panel.test.tsx b/tests/components/ads-panel.test.tsx index f862fc18..e537232f 100644 --- a/tests/components/ads-panel.test.tsx +++ b/tests/components/ads-panel.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ModulePage } from "../../src/dashboard/module-panels"; @@ -6,7 +6,7 @@ import { UiProvider } from "../../src/dashboard/ui"; import { toastsSnapshot } from "../../src/dashboard/ui/toast-store"; import type { AdsScheduleResponse } from "../../src/modules/ads/contracts"; import { moduleQueryKey } from "../../src/dashboard/data/module-query"; -import { invalidateDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; +import { invalidateDashboardRealtimeMessage, setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; import type { RealtimeMessage } from "../../src/realtime-contract"; import { renderWithQuery } from "../query-test-utils"; import { jsonResponse } from "../unit/fixtures"; @@ -24,6 +24,9 @@ const schedule: AdsScheduleResponse = { recentAdBreaks: [], }; const initialLanguage = Object.getOwnPropertyDescriptor(window.navigator, "language"); +const requestPath = (input: RequestInfo | URL): string => input instanceof Request + ? new URL(input.url).pathname + : input instanceof URL ? input.pathname : new URL(input, "https://brobot.example").pathname; const renderAds = (fetcher: typeof fetch, ownRole: "manager" | "operator" = "manager"): ReturnType => { vi.stubGlobal("fetch", fetcher); @@ -56,6 +59,7 @@ const adsFetch = ( describe("Ad settings editor declaration", () => { afterEach(() => { cleanup(); + setDashboardRealtimeStatus("kanal-a", "offline"); vi.unstubAllGlobals(); if (initialLanguage !== undefined) Object.defineProperty(window.navigator, "language", initialLanguage); }); @@ -131,6 +135,39 @@ describe("Ad settings editor declaration", () => { expect(screen.getByText(/channel:manage:ads fehlt/)).toBeInTheDocument(); }); + it("preserves the cached schedule and limits load notifications to one per outage", async () => { + setDashboardRealtimeStatus("kanal-a", "connected"); + let scheduleReadFails = false; + const baseFetcher = adsFetch(); + const fetcher = vi.fn((input, init) => { + const path = input instanceof Request ? new URL(input.url).pathname : new URL(String(input), "https://brobot.example").pathname; + if (path.endsWith("/schedule") && scheduleReadFails) return Promise.resolve(jsonResponse({ error: "schedule_unavailable" }, 503)); + return baseFetcher(input, init); + }); + const view = renderAds(fetcher); + expect(await screen.findByText(/Derzeit ist keine Werbung geplant\./u)).toBeInTheDocument(); + const key = { queryKey: moduleQueryKey("kanal-a", "ads", "schedule"), exact: true }; + const refresh = async (): Promise => { + await act(async () => { await view.queryClient.refetchQueries(key, { throwOnError: true }).catch(() => undefined); }); + }; + + scheduleReadFails = true; + await refresh(); + await refresh(); + expect(fetcher.mock.calls.filter(([input]) => requestPath(input).endsWith("/schedule"))).toHaveLength(3); + expect(view.queryClient.getQueryState(key.queryKey)?.status).toBe("error"); + expect(screen.getByText(/Derzeit ist keine Werbung geplant\./u)).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("Der Werbeplan konnte nicht geladen werden."); + expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument(); + expect(toastsSnapshot().filter((toast) => toast.message === "Der Werbeplan konnte nicht geladen werden.")).toHaveLength(1); + + scheduleReadFails = false; + await refresh(); + scheduleReadFails = true; + await refresh(); + expect(toastsSnapshot().filter((toast) => toast.message === "Der Werbeplan konnte nicht geladen werden.")).toHaveLength(2); + }); + it("shows a rejected snooze as an error toast without making the settings draft dirty", async () => { const fetcher = adsFetch(undefined, schedule, () => jsonResponse({ error: "ad_snooze_failed" }, 500)); renderAds(fetcher); diff --git a/tests/components/belabox-panel.test.tsx b/tests/components/belabox-panel.test.tsx index 175c73be..dafcb3fd 100644 --- a/tests/components/belabox-panel.test.tsx +++ b/tests/components/belabox-panel.test.tsx @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { UiProvider } from "../../src/dashboard/ui"; import { ToastHost } from "../../src/dashboard/ui/Toast"; import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store"; import { renderWithQuery as render } from "../query-test-utils"; import BelaboxPanel from "../../src/modules/belabox/panel"; +import { moduleQueryKey } from "../../src/dashboard/data/module-query"; +import { invalidateDashboardRealtimeMessage, setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; const mocks = vi.hoisted(() => ({ loadBelaboxStatus: vi.fn(), @@ -41,6 +43,7 @@ describe("BELABOX panel", () => { afterEach(() => { cleanup(); + setDashboardRealtimeStatus("kanal-a", "offline"); for (const toast of toastsSnapshot()) dismissToast(toast.id); vi.resetAllMocks(); }); @@ -135,7 +138,7 @@ describe("BELABOX panel", () => { expect(mocks.loadBelaboxStreams).not.toHaveBeenCalled(); }); - it("reloads status after settings save and starts history requests when interval mode is enabled", async () => { + it("refreshes status and history after a Belabox realtime hint", async () => { mocks.loadBelaboxStatus .mockResolvedValueOnce({ configured: true, updatedAt: null, mode: "on_demand", sample: null, errorCode: null, @@ -145,11 +148,18 @@ describe("BELABOX panel", () => { configured: true, updatedAt: null, mode: "interval", sample: null, errorCode: null, polling: true, pollingDesired: true, intervalSeconds: 15, streamId: "stream-42", belaboxStreamId: "stream-42", }); - const view = render(<>); + const view = render(<>); await waitFor(() => { expect(mocks.loadBelaboxStatus).toHaveBeenCalledOnce(); }); expect(mocks.loadBelaboxHistory).not.toHaveBeenCalled(); - view.rerender(<>); + act(() => invalidateDashboardRealtimeMessage(view.queryClient, { + version: 1, + id: "belabox-state-changed", + createdAt: "2026-10-09T08:00:00.000Z", + channelId: "channel-a", + type: "modul.belabox.state_changed", + payload: { reason: "stream.state.changed" }, + })); await waitFor(() => { expect(mocks.loadBelaboxStatus).toHaveBeenCalledTimes(2); @@ -160,6 +170,30 @@ describe("BELABOX panel", () => { expect(mocks.loadBelaboxStatus).toHaveBeenCalledTimes(2); }); + it("notifies once for each Belabox status outage, including after an identical recovery", async () => { + setDashboardRealtimeStatus("kanal-a", "connected"); + const view = render(<>); + await waitFor(() => { expect(mocks.loadBelaboxStatus).toHaveBeenCalledOnce(); }); + const key = { queryKey: moduleQueryKey("kanal-a", "belabox", "status"), exact: true }; + const refresh = async (): Promise => { + await act(async () => { await view.queryClient.refetchQueries(key, { throwOnError: true }).catch(() => undefined); }); + }; + + mocks.loadBelaboxStatus.mockRejectedValue(new Error("status unavailable")); + await refresh(); + await refresh(); + expect(toastsSnapshot().filter((toast) => toast.message === "The connection could not be tested.")).toHaveLength(1); + + mocks.loadBelaboxStatus.mockResolvedValue({ + configured: true, updatedAt: null, mode: "interval", sample: null, errorCode: null, + polling: false, pollingDesired: false, streamId: "stream-s2", belaboxStreamId: null, + }); + await refresh(); + mocks.loadBelaboxStatus.mockRejectedValue(new Error("status unavailable again")); + await refresh(); + expect(toastsSnapshot().filter((toast) => toast.message === "The connection could not be tested.")).toHaveLength(2); + }); + it("draws the history thresholds and disconnect gaps and can select a stream", async () => { mocks.loadBelaboxStatus.mockResolvedValue({ configured: true, diff --git a/tests/components/channel-variables-page.test.tsx b/tests/components/channel-variables-page.test.tsx index 14da2292..2736c2f0 100644 --- a/tests/components/channel-variables-page.test.tsx +++ b/tests/components/channel-variables-page.test.tsx @@ -126,6 +126,41 @@ describe("Channel variables page", () => { expect(requests.find(({ method, body }) => method === "POST" && body?.includes('"operation":"add"'))?.body).toBe(JSON.stringify({ operation: "add", amount: 1 })); }); + it("keeps a successful variable increment when the follow-up read fails", async () => { + setBrowserLanguage("en-US"); + let currentValue = variable.value; + let variableReads = 0; + const fetcher = vi.fn().mockImplementation((input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), window.location.href); + const method = init?.method ?? "GET"; + if (url.pathname === "/api/csrf") return Promise.resolve(jsonResponse({ token: "csrf-test" })); + if (url.pathname === "/api/channels/kanal-a/variables/score/value" && method === "POST") { + currentValue += 1; + return Promise.resolve(jsonResponse({ variable: { ...variable, value: currentValue } })); + } + if (url.pathname === "/api/channels/kanal-a/variables") { + variableReads += 1; + return variableReads === 1 + ? Promise.resolve(jsonResponse({ variables: [variable], count: 1, maximum: 25 })) + : Promise.reject(new Error("variables revalidation failed")); + } + if (url.pathname.endsWith("/overlay-tokens")) return Promise.resolve(jsonResponse({ tokens: [], nextOffset: null })); + return Promise.reject(new Error(`Unexpected request ${method} ${url.pathname}`)); + }); + vi.stubGlobal("fetch", fetcher); + + const view = render( {}} />); + fireEvent.click(await screen.findByRole("row", { name: /score/i })); + fireEvent.click(screen.getByRole("button", { name: "+1" })); + + await waitFor(() => expect(currentValue).toBe(1235)); + await waitFor(() => expect(view.queryClient.getQueryData<{ variables: Array<{ value: number }> }>(["channel", "kanal-a", "variables"])?.variables[0]?.value).toBe(1235)); + await waitFor(() => expect(variableReads).toBe(2)); + expect(document.querySelector(".channel-variable-value-controls__value")).toHaveTextContent("1,235"); + const newToasts = toastsSnapshot().filter((toast) => toast.message === "The channel variable could not be saved."); + expect(newToasts).toHaveLength(0); + }); + it("opens an existing overlay editor with the variable as an unsaved draft", async () => { const requests: Array<{ path: string; method: string }> = []; const fetcher = vi.fn().mockImplementation((input, init) => { diff --git a/tests/components/chat-voting-panel.test.tsx b/tests/components/chat-voting-panel.test.tsx index 06ec592c..1081b3dc 100644 --- a/tests/components/chat-voting-panel.test.tsx +++ b/tests/components/chat-voting-panel.test.tsx @@ -1,9 +1,12 @@ -import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { UiProvider } from "../../src/dashboard/ui"; import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store"; import { ChatVotingPanel } from "../../src/modules/chat_voting/panel"; +import { chatVotingModule } from "../../src/modules/chat_voting"; +import { moduleQueryKey, runModuleQueryWrite } from "../../src/dashboard/data/module-query"; +import { setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; import type { ChatVotePreset } from "../../src/modules/chat_voting/contracts"; import { jsonResponse } from "../unit/fixtures"; import { renderWithQuery as render } from "../query-test-utils"; @@ -45,14 +48,16 @@ const openVote = { voterCount: null, } as const; +const requestPath = (input: RequestInfo | URL): string => input instanceof Request + ? new URL(input.url).pathname + : input instanceof URL ? input.pathname : new URL(input, "https://brobot.example").pathname; + const fetchFor = ( readCurrent: () => unknown = () => currentState(), onStart: (body: unknown) => unknown = () => ({ vote: openVote }), onClose: () => void = () => {}, ) => vi.fn((input, init) => { - const path = input instanceof Request - ? new URL(input.url).pathname - : new URL(String(input), "https://brobot.example").pathname; + const path = requestPath(input); if (path === "/api/csrf") return Promise.resolve(jsonResponse({ token: "csrf-token" })); if (path.endsWith("/start")) { const body = typeof init?.body === "string" ? JSON.parse(init.body) as unknown : null; @@ -85,6 +90,8 @@ const selectOption = async (label: string, option: string): Promise => { describe("chat voting live panel", () => { afterEach(() => { cleanup(); + setDashboardRealtimeStatus("fictional-channel", "offline"); + for (const toast of toastsSnapshot()) dismissToast(toast.id); vi.unstubAllGlobals(); }); @@ -122,6 +129,35 @@ describe("chat voting live panel", () => { expect(screen.getByRole("spinbutton", { name: "Seconds" })).toBeInTheDocument(); }); + it("notifies once per state outage even when identical state recovers", async () => { + setDashboardRealtimeStatus("fictional-channel", "connected"); + let readState: "success" | "error" = "success"; + const fetcher = fetchFor(() => { + if (readState === "error") throw new Error("state unavailable"); + return currentState(); + }); + vi.stubGlobal("fetch", fetcher); + const view = render(); + expect(await screen.findByText("No vote yet. Start one in chat with !vote yesno [question].")).toBeInTheDocument(); + const key = { queryKey: moduleQueryKey("fictional-channel", "chat_voting", "panel"), exact: true }; + const refresh = async (): Promise => { + await act(async () => { await view.queryClient.refetchQueries(key, { throwOnError: true }).catch(() => undefined); }); + }; + + readState = "error"; + await refresh(); + await refresh(); + expect(fetcher.mock.calls.filter(([input]) => requestPath(input).endsWith("/current"))).toHaveLength(3); + expect(view.queryClient.getQueryState(key.queryKey)?.status).toBe("error"); + expect(toastsSnapshot().filter((toast) => toast.message === "The vote could not be loaded.")).toHaveLength(1); + + readState = "success"; + await refresh(); + readState = "error"; + await refresh(); + expect(toastsSnapshot().filter((toast) => toast.message === "The vote could not be loaded.")).toHaveLength(2); + }); + it("sends edited answer labels for this vote", async () => { let startPayload: unknown; let started = false; @@ -144,6 +180,34 @@ describe("chat voting live panel", () => { expect(screen.getByRole("textbox", { name: "Label for option 2" })).toHaveValue("Burger"); }); + it("uses saved default labels and duration for the next vote", async () => { + let serverState = currentState(); + let startPayload: unknown; + vi.stubGlobal("fetch", fetchFor( + () => serverState, + (body) => { startPayload = body; return { vote: openVote }; }, + )); + const view = render(); + + expect(await screen.findByRole("textbox", { name: "Label for option 1" })).toHaveAttribute("placeholder", "Yes"); + serverState = currentState({ + defaultDurationSeconds: 300, + defaultLabels: { ...defaultLabels, yes_no: ["Approve", "Reject"] }, + }); + + await act(async () => { + await runModuleQueryWrite(view.queryClient, "fictional-channel", "chat_voting", "settings", () => Promise.resolve({}), { + baselineRevision: 1, + updateCache: (_current, result) => result, + relatedParts: (chatVotingModule.settingsEditorRelatedParts ?? []).map((part) => ({ moduleId: chatVotingModule.id, part })), + }); + }); + + expect(await screen.findByRole("textbox", { name: "Label for option 1" })).toHaveAttribute("placeholder", "Approve"); + fireEvent.click(screen.getByRole("button", { name: "Start vote" })); + await waitFor(() => expect(startPayload).toEqual({ preset: "yes_no", durationSeconds: 300, labels: ["Approve", "Reject"] })); + }); + it("sends a trimmed question and enforces its 80-code-point limit", async () => { let startPayload: unknown; vi.stubGlobal("fetch", fetchFor(undefined, (body) => { diff --git a/tests/components/dashboard.test.tsx b/tests/components/dashboard.test.tsx index ff9fd2f4..7bb39b31 100644 --- a/tests/components/dashboard.test.tsx +++ b/tests/components/dashboard.test.tsx @@ -4,6 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DashboardApp } from "../../src/dashboard/main"; import { dashboardRoutePath, parseDashboardRoute } from "../../src/dashboard/router"; import { dashboardDataKeys } from "../../src/dashboard/data/keys"; +import { emptyEventFilter } from "../../src/dashboard/events/model"; +import { WarningsAndErrorsFeed } from "../../src/dashboard/stream-manager"; +import { UiProvider } from "../../src/dashboard/ui"; import { queryKeys } from "../../src/dashboard/data/keys"; import { renderWithQuery as render } from "../query-test-utils"; import { jsonResponse } from "../unit/fixtures"; @@ -606,7 +609,7 @@ describe("Dashboard skeleton", () => { vi.stubGlobal("WebSocket", TestWebSocket); const fetcher = stubEventFeedFetch(() => { eventRequests += 1; - return jsonResponse({ entries: eventRequests === 1 ? [raidAction, incomingRaid] : [newRaid, raidAction, incomingRaid], nextCursor: null }); + return jsonResponse({ entries: eventRequests <= 2 ? [raidAction, incomingRaid] : [newRaid, raidAction, incomingRaid], nextCursor: null }); }, channel); window.history.replaceState({}, "", "/channels/kanal-a/events"); @@ -618,6 +621,7 @@ describe("Dashboard skeleton", () => { const socket = TestWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime-Socket fehlt"); socket.open(); + await waitFor(() => expect(eventRequests).toBe(2)); socket.receive(JSON.stringify({ version: 1, id: "raid-hint", @@ -627,8 +631,8 @@ describe("Dashboard skeleton", () => { payload: { entries: [{ eventId: newRaid.eventId, createdAt: newRaid.createdAt, moduleId: "channel_events", code: newRaid.code, actorUserId: null }] }, })); - expect(await screen.findByText("Raid von freshraid mit 2 Zuschauern")).toBeInTheDocument(); - expect(eventRequests).toBe(2); + expect(await screen.findByText("Raid von freshraid mit 2 Zuschauern", {}, { timeout: 2_000 })).toBeInTheDocument(); + expect(eventRequests).toBe(3); }); it("refreshes while the reader is down without moving the displayed events", async () => { @@ -655,6 +659,7 @@ describe("Dashboard skeleton", () => { const socket = TestWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime-Socket fehlt"); socket.open(); + await waitFor(() => expect(eventRequests).toBe(2)); serverEntries = [neu, alt]; socket.receive(JSON.stringify({ version: 1, id: "message-lower", createdAt: "2026-09-18T04:00:01.000Z", channelId: "kanal-a", type: "event_log.new", payload: { entries: [{ eventId: neu.eventId, createdAt: neu.createdAt, moduleId: neu.moduleId, code: neu.code, actorUserId: null }] } })); @@ -662,7 +667,7 @@ describe("Dashboard skeleton", () => { // The query refreshes while the rows the reader is looking at stay frozen. await new Promise((resolve) => { setTimeout(resolve, 400); }); expect(screen.queryByText("Raid von unbekannt mit 8 Zuschauern")).not.toBeInTheDocument(); - expect(eventRequests).toBe(2); + expect(eventRequests).toBe(3); expect(screen.getAllByRole("row")).toHaveLength(2); Object.defineProperty(feed, "getBoundingClientRect", { configurable: true, value: () => ({ top: 0 }) }); @@ -783,7 +788,7 @@ describe("Dashboard skeleton", () => { vi.stubGlobal("WebSocket", TestWebSocket); stubEventFeedFetch(() => { eventRequests += 1; - return Promise.resolve(jsonResponse({ entries: eventRequests === 1 ? [alt] : [neu, alt], nextCursor: null })); + return Promise.resolve(jsonResponse({ entries: eventRequests <= 2 ? [alt] : [neu, alt], nextCursor: null })); }, channel); window.history.replaceState({}, "", "/channels/kanal-a/events"); @@ -793,6 +798,7 @@ describe("Dashboard skeleton", () => { const socket = TestWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime-Socket fehlt"); socket.open(); + await waitFor(() => expect(eventRequests).toBe(2)); socket.close(1006, "Abbruch"); await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 300); }); }); const rebuiltSocket = TestWebSocket.instances[1]; @@ -800,7 +806,7 @@ describe("Dashboard skeleton", () => { rebuiltSocket.open(); expect(await screen.findByText("Raid von unbekannt mit 11 Zuschauern")).toBeInTheDocument(); - expect(eventRequests).toBe(2); + expect(eventRequests).toBe(3); }); it("refreshes the infinite event query after loading older events", async () => { @@ -2313,6 +2319,43 @@ describe("Dashboard skeleton", () => { expect(screen.getByText("Pausiert")).toBeInTheDocument(); }); + it("retries an expired channel-control refresh after a transient request failure", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-23T09:00:00.000Z")); + const expiredChannel = { + ...healthyChannel("kanal-a", "Alpha"), + controls: { + mute: { active: true, until: "2026-09-23T08:59:59.000Z", mode: "timed" as const }, + pause: { active: false, until: null, mode: null }, + }, + }; + const refreshedChannel = { ...expiredChannel, controls: healthyChannel("kanal-a", "Alpha").controls }; + let channelCalls = 0; + vi.stubGlobal("WebSocket", TestWebSocket); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const path = requestUrl(input).pathname; + if (path === "/api/channels") { + channelCalls += 1; + if (channelCalls === 1) return Promise.resolve(jsonResponse({ channels: [expiredChannel], bot: expiredChannel.bot })); + if (channelCalls === 2) return Promise.resolve(jsonResponse({ error: "temporary_failure" }, 503)); + return Promise.resolve(jsonResponse({ channels: [refreshedChannel], bot: refreshedChannel.bot })); + } + if (path === "/api/channels/kanal-a/overview") return Promise.resolve(jsonResponse(overview(channelCalls >= 3 ? refreshedChannel : expiredChannel))); + if (path === "/api/channels/kanal-a/modules") return Promise.resolve(jsonResponse({ modules: [] })); + return Promise.resolve(jsonResponse({}, 404)); + })); + window.history.replaceState({}, "", "/channels/kanal-a"); + render(); + await flushQueryUpdates(); + + expect(channelCalls).toBe(2); + expect(screen.getByRole("button", { name: "Stummschaltung aufheben" })).toBeInTheDocument(); + await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); + await flushQueryUpdates(); + + expect(channelCalls).toBe(3); + }); + it("keeps a control edit's fresher overview state against an older, slower reconnect reload", async () => { const pausedChannel = { ...healthyChannel("kanal-a", "Alpha"), @@ -2335,11 +2378,12 @@ describe("Dashboard skeleton", () => { } if (url.pathname === "/api/channels/kanal-a/overview") { overviewRequestCount += 1; - // 1st: initial mount load. 2nd: reconnect reload, held open so it - // resolves after the control edit's own refresh below (3rd). + // 1st: initial mount load. 2nd: first-connect reconciliation. + // 3rd: reconnect reload, held open until the control edit's refresh. if (overviewRequestCount === 1) return Promise.resolve(jsonResponse(overview(pausedChannel))); - if (overviewRequestCount === 2) return reconnectReload; - if (overviewRequestCount === 3) return Promise.resolve(jsonResponse(overview({ ...pausedChannel, controls: resumedControls }))); + if (overviewRequestCount === 2) return Promise.resolve(jsonResponse(overview(pausedChannel))); + if (overviewRequestCount === 3) return reconnectReload; + if (overviewRequestCount === 4) return Promise.resolve(jsonResponse(overview({ ...pausedChannel, controls: resumedControls }))); return Promise.resolve(jsonResponse(overview({ ...pausedChannel, controls: resumedControls }))); } return Promise.resolve(jsonResponse({}, 404)); @@ -2352,15 +2396,16 @@ describe("Dashboard skeleton", () => { await screen.findByRole("button", { name: "Automatische Aktionen fortsetzen" }); await waitFor(() => { expect(TestWebSocket.instances).toHaveLength(1); }); TestWebSocket.instances[0]?.open(); - await waitFor(() => { expect(overviewRequestCount).toBe(1); }); + await waitFor(() => { expect(overviewRequestCount).toBe(2); }); + await waitFor(() => { expect(TestWebSocket.instances[0]?.readyState).toBe(1); }); TestWebSocket.instances[0]?.close(1006, "socket interruption"); await waitFor(() => { expect(TestWebSocket.instances).toHaveLength(2); }, { timeout: 2_000 }); TestWebSocket.instances[1]?.open(); - await waitFor(() => { expect(overviewRequestCount).toBe(2); }); + await waitFor(() => { expect(overviewRequestCount).toBe(3); }); fireEvent.click(screen.getByRole("button", { name: "Automatische Aktionen fortsetzen" })); await screen.findByRole("button", { name: "Automatische Aktionen pausieren" }); - await waitFor(() => { expect(overviewRequestCount).toBe(3); }); + await waitFor(() => { expect(overviewRequestCount).toBe(4); }); await act(async () => { resolveReconnectReload?.(jsonResponse(overview(pausedChannel))); @@ -2434,13 +2479,13 @@ describe("Dashboard skeleton", () => { expect(screen.getByRole("button", { name: "Stummschaltung aufheben" })).toBeInTheDocument(); }); - it("refreshes stale channel queries on visibility change without a timer poll", async () => { + it("polls shell queries while realtime is offline and stops polling after connect", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-23T09:00:00.000Z")); - const channel = healthyChannel("kanal-a", "Alpha"); + const channel = { ...healthyChannel("kanal-a", "Alpha"), modules: [] }; let channelCalls = 0; let overviewCalls = 0; - let modulesCalls = 0; + let systemCalls = 0; vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { const path = requestUrl(input).pathname; if (path === "/api/channels") { @@ -2451,28 +2496,39 @@ describe("Dashboard skeleton", () => { overviewCalls += 1; return Promise.resolve(jsonResponse(overview(channel))); } - if (path === "/api/channels/kanal-a/modules") { - modulesCalls += 1; - return Promise.resolve(jsonResponse({ modules: [] })); + if (path === "/api/channels/kanal-a/system") { + systemCalls += 1; + return Promise.resolve(jsonResponse(system)); } return Promise.resolve(jsonResponse({}, 404)); })); + vi.stubGlobal("WebSocket", TestWebSocket); window.history.replaceState({}, "", "/channels/kanal-a"); render(); await flushQueryUpdates(); - expect([channelCalls, overviewCalls, modulesCalls]).toEqual([1, 1, 1]); + expect([channelCalls, overviewCalls, systemCalls]).toEqual([1, 1, 0]); - await act(async () => { await vi.advanceTimersByTimeAsync(30_001); }); - expect([channelCalls, overviewCalls, modulesCalls]).toEqual([1, 1, 1]); + await act(async () => { await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 1); }); + await flushQueryUpdates(); + expect([channelCalls, overviewCalls, systemCalls]).toEqual([2, 2, 0]); + + navigateToPath("/channels/kanal-a/system"); + await flushQueryUpdates(); + expect([channelCalls, overviewCalls, systemCalls]).toEqual([2, 2, 1]); + + await act(async () => { await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 1); }); + await flushQueryUpdates(); + expect([channelCalls, overviewCalls, systemCalls]).toEqual([3, 2, 2]); - fireEvent(document, new Event("visibilitychange", { bubbles: true })); + expect(TestWebSocket.instances).toHaveLength(1); + TestWebSocket.instances[0]?.open(); await flushQueryUpdates(); - expect([channelCalls, overviewCalls, modulesCalls]).toEqual([2, 2, 2]); + const connectedCounts = [channelCalls, overviewCalls, systemCalls]; - await act(async () => { await vi.advanceTimersByTimeAsync(3 * 60 * 1000); }); + await act(async () => { await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 1); }); await flushQueryUpdates(); - expect([channelCalls, overviewCalls, modulesCalls]).toEqual([2, 2, 2]); + expect([channelCalls, overviewCalls, systemCalls]).toEqual(connectedCounts); }); it("shows stale token expiry as neutral, reloads, then marks server-confirmed expiry red", async () => { @@ -2967,6 +3023,51 @@ describe("Dashboard skeleton", () => { expect(screen.getByRole("switch", { name: "Shoutout" })).toBeChecked(); }); + it("retries a token-expiry reconciliation after a transient shell refresh failure", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-23T09:00:00.000Z")); + const expiry = "2026-09-23T09:00:01.000Z"; + const freshChannel = { + ...healthyChannel("kanal-a", "Alpha"), + tokens: { ...healthyChannel("kanal-a", "Alpha").tokens, botExpiresAt: expiry, loginExpiresAt: expiry }, + }; + const expiredChannel = { + ...freshChannel, + tokens: { ...freshChannel.tokens, botExpiresAt: "2026-09-23T09:00:00.000Z", loginExpiresAt: "2026-09-23T09:00:00.000Z" }, + }; + let channelCalls = 0; + let overviewCalls = 0; + vi.stubGlobal("WebSocket", TestWebSocket); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const path = requestUrl(input).pathname; + if (path === "/api/channels") { + channelCalls += 1; + if (channelCalls === 1) return Promise.resolve(jsonResponse({ channels: [freshChannel], bot: freshChannel.bot })); + if (channelCalls === 2) return Promise.resolve(jsonResponse({ error: "temporary_failure" }, 503)); + return Promise.resolve(jsonResponse({ channels: [expiredChannel], bot: expiredChannel.bot })); + } + if (path === "/api/channels/kanal-a/overview") { + overviewCalls += 1; + if (overviewCalls === 2) return Promise.resolve(jsonResponse({ error: "temporary_failure" }, 503)); + return Promise.resolve(jsonResponse(overview(channelCalls >= 3 ? expiredChannel : freshChannel))); + } + if (path === "/api/channels/kanal-a/modules") return Promise.resolve(jsonResponse({ modules: [] })); + return Promise.resolve(jsonResponse({}, 404)); + })); + window.history.replaceState({}, "", "/channels/kanal-a"); + render(); + await flushQueryUpdates(); + + expect(channelCalls).toBe(1); + await act(async () => { await vi.advanceTimersByTimeAsync(1_000); }); + await flushQueryUpdates(); + expect(channelCalls).toBe(2); + await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); + await flushQueryUpdates(); + + expect(channelCalls).toBe(3); + }); + it("is the Stream Manager after sign-in: immediate actions, and the warnings feed together, no page change", async () => { const channel = { ...healthyChannel("kanal-a", "Alpha"), role: "manager" as const }; let modulesCalls = 0; @@ -3018,6 +3119,33 @@ describe("Dashboard skeleton", () => { // 5 s findBy waits it contains (the 5 s default did not). }, 20_000); + it("keeps cached Stream Manager warnings visible after a failed background refresh", async () => { + const entry = { + eventId: "cached-warning", createdAt: relativeIso(0), moduleId: "ads", triggerId: "t1", + code: "ads.commercial.failed", detail: "{\"reason\":\"rate_limited\"}", + actorUserId: null, actorLogin: null, actorDisplayName: null, + }; + let failEvents = false; + const fetcher = vi.fn((input: RequestInfo | URL) => { + if (requestUrl(input).pathname === "/api/channels/kanal-a/events") { + return Promise.resolve(failEvents ? jsonResponse({ error: "events_unavailable" }, 503) : jsonResponse({ entries: [entry], nextCursor: null })); + } + return Promise.resolve(jsonResponse({}, 404)); + }); + vi.stubGlobal("fetch", fetcher); + const view = render(); + expect(await screen.findByText("Werbeeinblendung nicht gestartet: Twitch-Abklingzeit aktiv")).toBeInTheDocument(); + const eventKey = dashboardDataKeys.events("kanal-a", { ...emptyEventFilter, tones: ["warning", "error"] }); + failEvents = true; + await act(async () => { await view.queryClient.refetchQueries({ queryKey: eventKey, exact: true }, { throwOnError: true }).catch(() => undefined); }); + + expect(fetcher.mock.calls.filter(([input]) => requestUrl(input).pathname.endsWith("/events"))).toHaveLength(2); + expect(view.queryClient.getQueryState(eventKey)?.status).toBe("error"); + expect(screen.getByText("Werbeeinblendung nicht gestartet: Twitch-Abklingzeit aktiv")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("Verbindung unterbrochen. Die Ereignisse konnten nicht geladen werden."); + expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument(); + }); + it("uses the overview stream state for Spotlight actions", async () => { const channel = { ...healthyChannel("kanal-a", "Alpha"), streamState: "offline" as const }; const freshOverview = { ...overview(channel), streamState: "online" as const, streamStartedAt: "2026-09-23T11:30:00.000Z" }; @@ -3086,6 +3214,7 @@ describe("Dashboard skeleton", () => { ? jsonResponse({ error: "internal_error" }, 500) : jsonResponse(systemFor("recovered-system")); }, [channel]); + vi.stubGlobal("WebSocket", TestWebSocket); window.history.replaceState({}, "", "/channels/kanal-a/system"); const rendered = render(, undefined, { @@ -3136,7 +3265,7 @@ describe("Dashboard skeleton", () => { render(); expect(await screen.findByRole("heading", { name: "System", level: 1 })).toBeInTheDocument(); - await waitFor(() => expect(TestWebSocket.instances).toHaveLength(1)); + expect(TestWebSocket.instances).toHaveLength(1); const socket = TestWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime-Socket fehlt"); socket.open(); @@ -3220,7 +3349,6 @@ describe("Dashboard skeleton", () => { const socket = TestWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime-Socket fehlt"); socket.open(); - fireEvent(document, new Event("visibilitychange")); await waitFor(() => expect(channelsCalls).toBe(2)); const changedAt = relativeIso(1_000); @@ -3252,13 +3380,12 @@ describe("Dashboard skeleton", () => { }, }, })); - await waitFor(() => expect(channelsCalls).toBe(3)); - expect(await screen.findByText(/^Live ·/)).toBeInTheDocument(); - await act(async () => { resolveDelayedChannels?.(jsonResponse({ channels: [channel], bot: channel.bot })); await delayedChannels; }); + await waitFor(() => expect(channelsCalls).toBe(3)); + expect(await screen.findByText(/^Live ·/)).toBeInTheDocument(); expect(document.querySelector(".dashboard-header__stream")).toHaveAttribute("data-state", "live"); expect(screen.getByText(/^Pausiert ·/)).toBeInTheDocument(); @@ -3359,6 +3486,50 @@ describe("Dashboard skeleton", () => { }); }); + it("reconciles cached channel data when the socket first connects", async () => { + const initialChannel = { + ...healthyChannel("kanal-a", "Alpha"), + streamStateChangedAt: relativeIso(-10_000), + streamStateCheckedAt: relativeIso(-10_000), + }; + let serverChannel = initialChannel; + let overviewCalls = 0; + vi.stubGlobal("WebSocket", TestWebSocket); + const fetcher = stubDashboardFetch((url) => { + if (url.pathname.endsWith("/overview")) { + overviewCalls += 1; + return jsonResponse(overview(serverChannel)); + } + if (url.pathname.endsWith("/modules")) return jsonResponse({ modules: [] }); + if (url.pathname.endsWith("/events")) return jsonResponse({ entries: [], nextCursor: null }); + }, [initialChannel]); + window.history.replaceState({}, "", "/channels/kanal-a"); + + render(); + + await screen.findByRole("heading", { name: "Alpha", level: 1 }); + await waitFor(() => expect(overviewCalls).toBe(1)); + await waitFor(() => expect(TestWebSocket.instances).toHaveLength(1)); + + const changedAt = relativeIso(1_000); + serverChannel = { + ...initialChannel, + streamState: "online", + streamStartedAt: relativeIso(-60 * 60 * 1_000), + streamStateChangedAt: changedAt, + streamStateCheckedAt: changedAt, + controls: { + mute: { active: false, until: null, mode: null }, + pause: { active: true, until: null, mode: "until_stream_end" }, + }, + }; + TestWebSocket.instances[0]?.open(); + + await waitFor(() => expect(overviewCalls).toBe(2)); + await waitFor(() => expect(document.querySelector(".dashboard-header__stream")).toHaveAttribute("data-state", "live")); + expect(fetcher.mock.calls.filter(([input]) => requestUrl(input).pathname.endsWith("/overview"))).toHaveLength(2); + }); + it("closes an open Spotlight when the route changes channels, then loads the new channel's results", async () => { const alpha = healthyChannel("kanal-a", "Alpha"); const beta = healthyChannel("kanal-b", "Beta"); diff --git a/tests/components/events-page.test.tsx b/tests/components/events-page.test.tsx index 917fe38a..faaccf5c 100644 --- a/tests/components/events-page.test.tsx +++ b/tests/components/events-page.test.tsx @@ -191,6 +191,45 @@ describe("EventsPage failure cause icon", () => { expect(FakeWebSocket.instances[0]?.close).toHaveBeenCalledTimes(1); }); + it("reconciles the cached event query when the first channel socket connects", async () => { + vi.stubGlobal("WebSocket", TestEventsWebSocket); + const cachedEntry = entry({ eventId: "cached-before-connect" }); + const currentEntry = entry({ eventId: "changed-before-connect", code: "host.chat.sent" }); + let serverEntries = [cachedEntry]; + const fetcher = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ entries: serverEntries, nextCursor: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }))); + vi.stubGlobal("fetch", fetcher); + const filters = emptyEventFilter; + const view = renderWithQuery( + undefined} />, + undefined, + { + gcTime: 600_000, + staleTime: 0, + initialData: [{ + queryKey: dashboardDataKeys.events("kanal-a", filters), + data: { pages: [{ entries: [cachedEntry], nextCursor: null }], pageParams: [null] }, + }], + }, + ); + + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + serverEntries = [currentEntry]; + const socket = TestEventsWebSocket.instances[0]; + if (socket === undefined) throw new Error("Realtime socket was not created."); + socket.open(); + + await waitFor(() => { + expect(view.queryClient.getQueryData(dashboardDataKeys.events("kanal-a", filters))).toMatchObject({ + pages: [{ entries: [expect.objectContaining({ eventId: "changed-before-connect" })] }], + }); + }); + expect(fetcher).toHaveBeenCalledTimes(2); + view.unmount(); + }); + it("does not show another filter's rows while the selected query loads", async () => { const initialEntry = entry({ eventId: "all-events" }); const filteredEntry = entry({ eventId: "channel-events", code: "channel_events.chat.sub", detail: "{\"person\":\"new-subscriber\",\"tier\":\"1000\"}" }); @@ -481,8 +520,8 @@ describe("EventsPage failure cause icon", () => { let requestCount = 0; const fetcher = vi.fn((): Promise => { requestCount += 1; - if (requestCount <= 1) { - // The mount responds with cached rows before realtime refreshes begin. + if (requestCount <= 2) { + // The mount and first-connect reconciliation return the cached rows. return Promise.resolve(new Response(JSON.stringify({ entries: [initialEntry], nextCursor: null }), { status: 200, headers: { "Content-Type": "application/json" } })); } return new Promise((resolve) => { refreshResponses.push(resolve); }); @@ -508,6 +547,7 @@ describe("EventsPage failure cause icon", () => { const socket = TestEventsWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime socket was not created."); socket.open(); + await waitFor(() => expect(requestCount).toBe(2)); await waitFor(() => expect(rendered.queryClient.isFetching()).toBe(0)); fetcher.mockClear(); const sendHint = (id: string, event: PanelEventEntry): void => { @@ -663,7 +703,7 @@ describe("EventsPage failure cause icon", () => { fetcher.mockClear(); serverEntries = [newEntry, additionalEntry, oldEntry]; emitEventHint(socket, `top-hint-${action}`, newEntry); - await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1), { timeout: 2_000 }); await waitFor(() => expect(rendered.queryClient.isFetching()).toBe(0)); const refreshedEvents = rendered.queryClient.getQueryData<{ pages: { entries: PanelEventEntry[] }[] }>(queryKey); expect(refreshedEvents?.pages.flatMap((page) => page.entries).map((event) => event.eventId)).toEqual([ @@ -993,7 +1033,7 @@ describe("EventsPage failure cause icon", () => { fetcher.mockClear(); serverHasLive = true; emitEventHint(socket, "storm-message", live); - await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + await act(async () => { await vi.advanceTimersByTimeAsync(1_500); }); const cached = rendered.queryClient.getQueryData<{ pages: Array<{ entries: PanelEventEntry[] }> }>(dashboardDataKeys.events("kanal-a", emptyEventFilter)); expect(cached?.pages[0]?.entries[0]?.eventId).toBe("live"); expect(screen.queryByText(/Raid von stormraid/u)).not.toBeInTheDocument(); @@ -1083,7 +1123,7 @@ describe("EventsPage failure cause icon", () => { return new Promise((resolve) => { releaseNextPage = resolve; }); } firstPageRequests += 1; - if (firstPageRequests <= 1) { + if (firstPageRequests <= 2) { return Promise.resolve(new Response(JSON.stringify({ entries: [firstEntry], nextCursor: "cursor-1" }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -1103,6 +1143,7 @@ describe("EventsPage failure cause icon", () => { const socket = TestEventsWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime socket was not created."); socket.open(); + await waitFor(() => expect(firstPageRequests).toBe(2)); await waitFor(() => expect(view.queryClient.isFetching()).toBe(0)); fetcher.mockClear(); socket.receive(JSON.stringify({ diff --git a/tests/components/module-panels.test.tsx b/tests/components/module-panels.test.tsx index f21d1dbb..f05906c0 100644 --- a/tests/components/module-panels.test.tsx +++ b/tests/components/module-panels.test.tsx @@ -58,7 +58,8 @@ const editorFixture = vi.hoisted(() => { return { definition, loader: vi.fn(() => Promise.resolve({ default: definition })) }; }); -const activeLoader = vi.hoisted(() => vi.fn(() => Promise.resolve({ default: ({ settingsRefreshToken }: { settingsRefreshToken?: number }) => <>

Panel geladen

{String(settingsRefreshToken ?? 0)} }))); +const activeLoader = vi.hoisted(() => vi.fn(() => Promise.resolve({ default: () =>

Panel geladen

}))); +const relatedPanelLoader = vi.hoisted(() => vi.fn()); vi.mock("../../src/modules/registry", () => ({ MODULES: [ @@ -104,9 +105,10 @@ vi.mock("../../src/modules/registry", () => ({ settingsSchema: { shape: { amount: {}, handle: {}, labels: {}, message: {}, mode: {}, enabled: {}, threshold: {} } }, defaultSettings: { amount: 2, handle: "", labels: "", message: "Hello {viewer}", mode: "automatic", enabled: true, threshold: 4 }, templateFields: { message: [{ name: "viewer", sample: "Ada", maxLength: 40 }] }, - panel: activeLoader, + panel: relatedPanelLoader, settingsEditor: editorFixture.loader, settingsEditorPlacement: "before-panel", + settingsEditorRelatedParts: ["panel"], }, { id: "channel_events", mandatory: true, settingsSchema: {}, defaultSettings: {} }, ], @@ -115,9 +117,25 @@ vi.mock("../../src/modules/registry", () => ({ import { UiProvider } from "../../src/dashboard/ui"; import { ModuleNavigation, ModulePanelMount, ModulePage, ModuleWorkspace } from "../../src/dashboard/module-panels"; import { moduleQueryKey } from "../../src/dashboard/data"; +import { useModuleQuery } from "../../src/dashboard/data"; import { useDashboardRoute } from "../../src/dashboard/router"; import { renderWithQuery } from "../query-test-utils"; +const ActivePanelProbe = ({ channelId }: { channelId: string }): ReactElement => { + const query = useModuleQuery(channelId, "editor-before-fixture", "panel", async (signal) => { + const response = await fetch(`/api/channels/${encodeURIComponent(channelId)}/modules/editor-before-fixture/panel-state`, { signal }); + const result: unknown = await response.json(); + if (typeof result !== "object" || result === null) throw new Error("The panel response is invalid."); + if (!("revision" in result)) throw new Error("The panel response is missing its revision."); + const revision: unknown = result.revision; + if (typeof revision !== "number") throw new Error("The panel response is missing its revision."); + return { revision }; + }); + return <>

Panel geladen

{String(query.data?.revision ?? (query.isError ? `Error: ${String(query.error)}` : "Loading"))}; +}; + +relatedPanelLoader.mockImplementation(() => Promise.resolve({ default: ActivePanelProbe })); + const renderWithMantine = (element: ReactElement, queryOptions: { gcTime?: number; staleTime?: number } = {}): ReturnType => renderWithQuery({element}, undefined, queryOptions); const editorFixtureSettings = { amount: 2, handle: "ada", labels: "", message: "Hello {viewer}", mode: "automatic", enabled: true, threshold: 4 }; @@ -796,14 +814,21 @@ describe("Module panel loader", () => { expect(settingsReads).toBe(2); }); - it("notifies a mounted module panel after its sibling settings save succeeds", async () => { + it("refreshes related module data after its settings editor saves", async () => { + let panelRevision = 1; + let panelReads = 0; const fetcher = vi.fn((input, init) => { const path = input instanceof Request ? new URL(input.url).pathname : new URL(String(input), "https://brobot.example").pathname; if (path === "/api/csrf") return Promise.resolve(Response.json({ token: "csrf-token" })); if (path.endsWith("/modules/editor-before-fixture/settings") && init?.method === "PATCH") { + panelRevision = 2; return Promise.resolve(Response.json({ settings: editorFixtureSettings, revision: 2, warnings: [] })); } if (path.endsWith("/modules/editor-before-fixture/settings")) return Promise.resolve(Response.json({ settings: editorFixtureSettings, revision: 1, variables: [] })); + if (path.endsWith("/modules/editor-before-fixture/panel-state")) { + panelReads += 1; + return Promise.resolve(Response.json({ revision: panelRevision })); + } return Promise.resolve(Response.json({})); }); vi.stubGlobal("fetch", fetcher); @@ -817,11 +842,13 @@ describe("Module panel loader", () => { onToggle={vi.fn()} />); - expect(await screen.findByTestId("panel-settings-refresh")).toHaveTextContent("0"); + await waitFor(() => expect(screen.getByTestId("panel-settings-revision")).toHaveTextContent("1")); + expect(panelReads).toBe(1); fireEvent.change(await screen.findByRole("textbox", { name: "Konto" }), { target: { value: "saved-handle" } }); fireEvent.click(screen.getByRole("button", { name: "Fixture speichern" })); - await waitFor(() => { expect(screen.getByTestId("panel-settings-refresh")).toHaveTextContent("1"); }); + await waitFor(() => { expect(screen.getByTestId("panel-settings-revision")).toHaveTextContent("2"); }); + expect(panelReads).toBe(2); }); it("disables switch cards while a settings save is pending", async () => { diff --git a/tests/components/overlay-editor-page.test.tsx b/tests/components/overlay-editor-page.test.tsx index fbbb3050..7ba51695 100644 --- a/tests/components/overlay-editor-page.test.tsx +++ b/tests/components/overlay-editor-page.test.tsx @@ -4,6 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { DashboardApp } from "../../src/dashboard/main"; import { generateOverlayStyleBlock, OVERLAY_STYLE_BEGIN_MARKER, OVERLAY_STYLE_END_MARKER } from "../../src/dashboard/overlay-style-model"; import { OverlayCanvas } from "../../src/overlay/canvas"; +import { OverlayEditorPage } from "../../src/dashboard/OverlayEditorPage"; +import { dashboardDataKeys } from "../../src/dashboard/data/keys"; +import { createDashboardQueryClient } from "../../src/dashboard/data/client"; +import { UiProvider } from "../../src/dashboard/ui"; import { jsonResponse } from "../unit/fixtures"; import { renderWithQuery as render } from "../query-test-utils"; @@ -64,6 +68,139 @@ describe("Overlay composition editor", () => { Object.defineProperty(window.navigator, "language", { value: "de-DE", configurable: true }); }); + it("waits for overlay revalidation before creating the editor session and preserves the draft", async () => { + Object.defineProperty(window.navigator, "language", { value: "en-US", configurable: true }); + const client = createDashboardQueryClient(); + const overlayKey = dashboardDataKeys.overlay("kanal-a", "overlay-a"); + const baseElement = initialOverlay.elements[0]; + if (baseElement === undefined) throw new Error("Overlay test element is missing."); + client.setQueryData(overlayKey, { overlay: { ...initialOverlay, revision: 1, elements: [{ ...baseElement, x: 11 }] } }); + let resolveFreshOverlay: ((response: Response) => void) | undefined; + let overlayReadCount = 0; + let savedBody: { baseRevision: number; elements: Array<{ x: number }> } | undefined; + const freshOverlay = { ...initialOverlay, revision: 2, elements: [{ ...baseElement, x: 42 }] }; + const fetcher = vi.fn().mockImplementation((input, init) => { + const url = requestUrl(input); + if (url.pathname === "/api/channels/kanal-a/overlays/overlay-a" && (init?.method ?? "GET") === "GET") { + overlayReadCount += 1; + if (overlayReadCount > 1) return Promise.reject(new Error("background revalidation failed")); + return new Promise((resolve) => { resolveFreshOverlay = resolve; }); + } + if (url.pathname === "/api/channels/kanal-a/modules") return Promise.resolve(jsonResponse({ modules: [] })); + if (url.pathname === "/api/channels/kanal-a/variables") return Promise.resolve(jsonResponse({ variables: [], count: 0, maximum: 25 })); + if (url.pathname === "/api/csrf") return Promise.resolve(jsonResponse({ token: "csrf-test" })); + if (url.pathname === "/api/channels/kanal-a/overlays/overlay-a" && init?.method === "PUT") { + if (typeof init.body !== "string") throw new Error("Overlay save body must be JSON text."); + savedBody = JSON.parse(init.body) as typeof savedBody; + return Promise.resolve(jsonResponse({ overlay: { ...freshOverlay, revision: 3 } })); + } + return Promise.reject(new Error(`Unexpected request ${init?.method ?? "GET"} ${url.pathname}`)); + }); + vi.stubGlobal("fetch", fetcher); + const view = render(, undefined, { queryClient: client, gcTime: 600_000 }); + + await waitFor(() => expect(resolveFreshOverlay).toBeTypeOf("function")); + expect(screen.queryByRole("heading", { name: "Gameplay", level: 1 })).not.toBeInTheDocument(); + await act(async () => { + resolveFreshOverlay?.(jsonResponse({ overlay: freshOverlay })); + await Promise.resolve(); + }); + + expect(await screen.findByRole("heading", { name: "Gameplay", level: 1 })).toBeInTheDocument(); + const position = await screen.findByRole("spinbutton", { name: "X (px)" }); + expect(position).toHaveValue("42"); + fireEvent.change(position, { target: { value: "50" } }); + await act(async () => { + await Promise.resolve(); + client.setQueryData(overlayKey, { overlay: { ...freshOverlay, revision: 3, elements: [{ ...baseElement, x: 99 }] } }); + }); + expect(screen.getByRole("spinbutton", { name: "X (px)" })).toHaveValue("50"); + + await act(async () => { + await client.refetchQueries({ queryKey: overlayKey, exact: true }); + }); + expect(screen.getByRole("spinbutton", { name: "X (px)" })).toHaveValue("50"); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(savedBody).toMatchObject({ baseRevision: 2, elements: [{ x: 50 }] })); + view.unmount(); + }); + + it("creates the editor session after an initial overlay read recovers", async () => { + Object.defineProperty(window.navigator, "language", { value: "en-US", configurable: true }); + let overlayAvailable = false; + const fetcher = vi.fn().mockImplementation((input) => { + const url = requestUrl(input); + if (url.pathname === "/api/channels/kanal-a/overlays/overlay-a") { + return overlayAvailable + ? Promise.resolve(jsonResponse({ overlay: initialOverlay })) + : Promise.reject(new Error("overlay read unavailable")); + } + if (url.pathname === "/api/channels/kanal-a/modules") return Promise.resolve(jsonResponse({ modules: [] })); + if (url.pathname === "/api/channels/kanal-a/variables") return Promise.resolve(jsonResponse({ variables: [], count: 0, maximum: 25 })); + return Promise.reject(new Error(`Unexpected request ${url.pathname}`)); + }); + vi.stubGlobal("fetch", fetcher); + const view = render(); + + expect(await screen.findByRole("button", { name: "Reload" })).toBeInTheDocument(); + overlayAvailable = true; + await act(async () => { + await view.queryClient.refetchQueries({ queryKey: dashboardDataKeys.overlay("kanal-a", "overlay-a"), exact: true }); + }); + + expect(await screen.findByRole("heading", { name: "Gameplay", level: 1 })).toBeInTheDocument(); + expect(screen.getByRole("spinbutton", { name: "X (px)" })).toHaveValue("24"); + }); + + it("creates the editor session after an initial variables read recovers", async () => { + Object.defineProperty(window.navigator, "language", { value: "en-US", configurable: true }); + let variablesAvailable = false; + const fetcher = vi.fn().mockImplementation((input) => { + const url = requestUrl(input); + if (url.pathname === "/api/channels/kanal-a/overlays/overlay-a") return Promise.resolve(jsonResponse({ overlay: initialOverlay })); + if (url.pathname === "/api/channels/kanal-a/modules") return Promise.resolve(jsonResponse({ modules: [] })); + if (url.pathname === "/api/channels/kanal-a/variables") { + return variablesAvailable + ? Promise.resolve(jsonResponse({ variables: [], count: 0, maximum: 25 })) + : Promise.reject(new Error("variables read unavailable")); + } + return Promise.reject(new Error(`Unexpected request ${url.pathname}`)); + }); + vi.stubGlobal("fetch", fetcher); + const view = render(); + + expect(await screen.findByRole("button", { name: "Reload" })).toBeInTheDocument(); + variablesAvailable = true; + await act(async () => { + await view.queryClient.refetchQueries({ queryKey: dashboardDataKeys.variables("kanal-a"), exact: true }); + }); + + expect(await screen.findByRole("heading", { name: "Gameplay", level: 1 })).toBeInTheDocument(); + expect(screen.getByRole("spinbutton", { name: "X (px)" })).toHaveValue("24"); + }); + it("adds enabled module elements with their declared default size", async () => { Object.defineProperty(window.navigator, "language", { value: "en-US", configurable: true }); let savedOverlay = { ...initialOverlay, elements: [] as typeof initialOverlay.elements }; diff --git a/tests/components/overlays-page.test.tsx b/tests/components/overlays-page.test.tsx index 4af1910f..f5ebbc9a 100644 --- a/tests/components/overlays-page.test.tsx +++ b/tests/components/overlays-page.test.tsx @@ -107,6 +107,7 @@ describe("Overlays page", () => { const routeFetcher = (options: { conflictDelete?: boolean; emptyOverlays?: boolean; + overlayDetailFailsOnce?: boolean; secondOverlay?: boolean; accessLastUsedAt?: string | null; accessExpiresAt?: string | null; @@ -126,6 +127,7 @@ describe("Overlays page", () => { let legacyTokens = [...(options.legacyTokens ?? [])]; let importedOverlay: typeof overlay | null = null; let currentOverlay = overlay; + let shouldFailOverlayDetail = options.overlayDetailFailsOnce === true; const fetcher = vi.fn().mockImplementation(async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.href); const method = init?.method ?? "GET"; @@ -160,7 +162,13 @@ describe("Overlays page", () => { legacyTokens = []; return jsonResponse({ overlay: importedOverlay }, 201); } - if (url.pathname === "/api/channels/channel-a/overlays/overlay-a" && method === "GET") return jsonResponse({ overlay: currentOverlay }); + if (url.pathname === "/api/channels/channel-a/overlays/overlay-a" && method === "GET") { + if (shouldFailOverlayDetail) { + shouldFailOverlayDetail = false; + return jsonResponse({ error: "overlay_unavailable" }, 503); + } + return jsonResponse({ overlay: currentOverlay }); + } if (url.pathname === "/api/channels/channel-a/overlays/overlay-b" && method === "GET") return jsonResponse({ overlay: overlayB }); if (url.pathname === "/api/channels/channel-a/overlays/overlay-imported" && method === "GET" && importedOverlay !== null) return jsonResponse({ overlay: importedOverlay }); if (url.pathname === "/api/channels/channel-a/overlays/overlay-a/accesses" && method === "GET") { @@ -256,6 +264,18 @@ describe("Overlays page", () => { expect(reason).toHaveAttribute("role", "note"); }); + it("retries an initial overlay detail failure instead of leaving the inspector loading", async () => { + vi.stubGlobal("fetch", routeFetcher({ overlayDetailFailsOnce: true })); + render(); + fireEvent.click(await screen.findByText("Gameplay")); + + expect(await screen.findByRole("alert")).toHaveTextContent("Overlays konnten nicht geladen werden."); + fireEvent.click(screen.getByRole("button", { name: "Erneut versuchen" })); + + expect(await screen.findByRole("region", { name: "Zugänge" })).toBeInTheDocument(); + expect(screen.queryByText("Overlays werden geladen …")).not.toBeInTheDocument(); + }); + it("shows operators the disabled remove action and its reason for revoked access", async () => { vi.stubGlobal("fetch", routeFetcher({ accessRevokedAt: "2026-09-24T12:00:00.000Z" })); render(); diff --git a/tests/components/votekick-panel.test.tsx b/tests/components/votekick-panel.test.tsx index cb8f948a..d4d74f97 100644 --- a/tests/components/votekick-panel.test.tsx +++ b/tests/components/votekick-panel.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, screen } from "@testing-library/react"; +import { act, cleanup, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { UiProvider } from "../../src/dashboard/ui"; @@ -6,6 +6,7 @@ import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store import VotekickPanel from "../../src/modules/votekick/panel"; import type { Votekick } from "../../src/modules/votekick/contracts"; import { setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; +import { moduleQueryKey } from "../../src/dashboard/data/module-query"; import { jsonResponse } from "../unit/fixtures"; import { renderWithQuery as render } from "../query-test-utils"; @@ -109,4 +110,33 @@ describe("Votekick panel", () => { expect(newToasts.filter((toast) => toast.message === "Votekicks could not be loaded.")).toHaveLength(1); for (const toast of newToasts) dismissToast(toast.id); }); + + it("notifies again when a later refresh fails after a successful recovery", async () => { + const previousToastIds = new Set(toastsSnapshot().map((toast) => toast.id)); + let shouldFail = true; + const fetcher = vi.fn(() => shouldFail + ? Promise.reject(new Error("network unavailable")) + : Promise.resolve(jsonResponse({ running: null, votekicks: [], now: "2026-10-04T11:00:00.000Z" }))); + vi.stubGlobal("fetch", fetcher); + setDashboardRealtimeStatus("channel-a", "connected"); + const view = render(); + + await waitFor(() => expect(toastsSnapshot().filter((toast) => !previousToastIds.has(toast.id))).toHaveLength(1)); + shouldFail = false; + await act(async () => { + await view.queryClient.refetchQueries({ queryKey: moduleQueryKey("channel-a", "votekick", "panel"), exact: true }); + }); + expect(view.queryClient.getQueryState(moduleQueryKey("channel-a", "votekick", "panel"))?.status).toBe("success"); + expect(await screen.findByText("No votekick yet. Start one in chat with !votekick @user.")).toBeInTheDocument(); + shouldFail = true; + await act(async () => { + await view.queryClient.refetchQueries({ queryKey: moduleQueryKey("channel-a", "votekick", "panel"), exact: true }); + }); + + expect(fetcher).toHaveBeenCalledTimes(3); + expect(view.queryClient.getQueryState(moduleQueryKey("channel-a", "votekick", "panel"))?.status).toBe("error"); + const newToasts = toastsSnapshot().filter((toast) => !previousToastIds.has(toast.id)); + expect(newToasts.filter((toast) => toast.message === "Votekicks could not be loaded.")).toHaveLength(2); + for (const toast of newToasts) dismissToast(toast.id); + }); }); diff --git a/tests/unit/dashboard-query-data.test.tsx b/tests/unit/dashboard-query-data.test.tsx index 1dff1b93..fe38ece5 100644 --- a/tests/unit/dashboard-query-data.test.tsx +++ b/tests/unit/dashboard-query-data.test.tsx @@ -168,8 +168,41 @@ describe("dashboard query data layer", () => { expect(readCount).toBe(2); releaseRefresh?.(); - await waitFor(() => expect(readCount).toBe(3)); - await waitFor(() => expect(view.queryClient.getQueryData(moduleQueryKey(channelId, "chat_voting", "panel"))).toBe(3)); + await waitFor(() => expect(readCount).toBe(3), { timeout: 2_000 }); + await waitFor(() => expect(view.queryClient.getQueryData(moduleQueryKey(channelId, "chat_voting", "panel"))).toBe(3), { timeout: 2_000 }); + view.unmount(); + }); + + it("throttles sustained realtime hints when each read finishes quickly", async () => { + let readCount = 0; + function ModuleProbe() { + const query = useModuleQuery(channelId, "chat_voting", "panel", () => { + readCount += 1; + return Promise.resolve(readCount); + }); + return

{String(query.data ?? "Loading")}

; + } + + const view = renderWithQuery(, {}, { gcTime: 600_000, staleTime: 600_000 }); + expect(await screen.findByText("1")).toBeInTheDocument(); + const message = { + version: 1 as const, + id: "chat-vote-tally-0", + createdAt: "2026-10-09T08:00:00.000Z", + channelId, + type: "modul.chat_voting.tally" as const, + payload: {}, + }; + + for (let index = 0; index < 20; index += 1) { + await new Promise((resolve) => { setTimeout(resolve, 50); }); + invalidateDashboardRealtimeMessage(view.queryClient, { ...message, id: `chat-vote-tally-${String(index + 1)}` }); + } + await new Promise((resolve) => { setTimeout(resolve, 300); }); + + // One immediate refresh plus one trailing refresh for the sustained hint burst. + expect(readCount).toBe(3); + expect(view.queryClient.getQueryData(moduleQueryKey(channelId, "chat_voting", "panel"))).toBe(readCount); view.unmount(); }); diff --git a/tests/unit/dashboard-realtime.test.ts b/tests/unit/dashboard-realtime.test.ts index 96a4f5d6..cc7b4008 100644 --- a/tests/unit/dashboard-realtime.test.ts +++ b/tests/unit/dashboard-realtime.test.ts @@ -1,4 +1,4 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { afterEach, describe, expect, it } from "vitest"; import { parseRealtimeMessage } from "../../src/dashboard/realtime"; @@ -95,21 +95,53 @@ describe("dashboard realtime messages", () => { expect(queryClient.getQueryState(belabox)?.isInvalidated).toBe(true); }); + it("refreshes an overlay detail only once when its overlay list also changes", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const overlays = dashboardDataKeys.overlays(channelId); + const overlay = dashboardDataKeys.overlay(channelId, "overlay-a"); + let listReads = 0; + let detailReads = 0; + queryClient.setQueryData(overlays, { overlays: [] }); + queryClient.setQueryData(overlay, { overlay: { revision: 1 } }); + const listObserver = new QueryObserver(queryClient, { + queryKey: overlays, + queryFn: () => { listReads += 1; return Promise.resolve({ overlays: [] }); }, + staleTime: Infinity, + }); + const detailObserver = new QueryObserver(queryClient, { + queryKey: overlay, + queryFn: () => { detailReads += 1; return Promise.resolve({ overlay: { revision: 2 } }); }, + staleTime: Infinity, + }); + const unsubscribeList = listObserver.subscribe(() => undefined); + const unsubscribeDetail = detailObserver.subscribe(() => undefined); + + invalidateDashboardRealtimeMessage(queryClient, moduleHint("overlay.changed", { overlayId: "overlay-a", revision: 2 })); + await new Promise((resolve) => { setTimeout(resolve, 320); }); + + expect(listReads).toBe(1); + expect(detailReads).toBe(1); + unsubscribeList(); + unsubscribeDetail(); + queryClient.clear(); + }); + it("invalidates the right module cache for each panel live-state hint", () => { const hints = [ - ["chat_voting", "modul.chat_voting.opened"], - ["chat_voting", "modul.chat_voting.tally"], - ["belabox", "modul.belabox.state_changed"], - ["belabox", "modul.belabox.sample"], - ["votekick", "modul.votekick.opened"], - ["votekick", "modul.votekick.tally"], + { moduleId: "chat_voting", type: "modul.chat_voting.opened", parts: ["panel"] }, + { moduleId: "chat_voting", type: "modul.chat_voting.tally", parts: ["panel"] }, + { moduleId: "belabox", type: "modul.belabox.state_changed", parts: ["status", "streams", "history-live", "history-stream-42"] }, + { moduleId: "belabox", type: "modul.belabox.sample", parts: ["status", "streams", "history-live", "history-stream-42"] }, + { moduleId: "votekick", type: "modul.votekick.opened", parts: ["panel"] }, + { moduleId: "votekick", type: "modul.votekick.tally", parts: ["panel"] }, ] as const; - for (const [moduleId, type] of hints) { + for (const { moduleId, type, parts } of hints) { const queryClient = new QueryClient(); - const relevant = moduleQueryKey(channelId, moduleId, "panel"); + const relevant = parts.map((part) => moduleQueryKey(channelId, moduleId, part)); + const settings = moduleQueryKey(channelId, moduleId, "settings"); const unrelated = moduleQueryKey(channelId, "ads", "schedule"); - [relevant, unrelated].forEach((key) => { addQuery(queryClient, key); }); + [...relevant, settings, unrelated].forEach((key) => { addQuery(queryClient, key); }); const parsed = parseRealtimeMessage(JSON.stringify({ version: 1, @@ -122,7 +154,8 @@ describe("dashboard realtime messages", () => { expect(parsed.kind).toBe("message"); if (parsed.kind === "message") invalidateDashboardRealtimeMessage(queryClient, parsed.message); - expect(queryClient.getQueryState(relevant)?.isInvalidated).toBe(true); + for (const key of relevant) expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(settings)?.isInvalidated).toBe(false); expect(queryClient.getQueryState(unrelated)?.isInvalidated).toBe(false); } }); From 35b80018020c2014597483d0ca0e465a1164c043 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 03:39:30 +0200 Subject: [PATCH 03/16] =?UTF-8?q?=F0=9F=90=9B=20fix(dashboard):=20throttle?= =?UTF-8?q?=20hints,=20reconcile=20on=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realtime refresh behavior - Avoid settings refreshes for module event hints while bounding repeated query refreshes. - Keep chat-voting defaults current after settings writes on a connected socket. Regression coverage - Cover sustained realtime hints and one-socket navigation across dashboard views for #387. --- src/dashboard/data/realtime.ts | 10 +- tests/components/chat-voting-panel.test.tsx | 1 + tests/e2e/realtime-navigation.spec.ts | 127 ++++++++++++++++++++ tests/unit/dashboard-realtime.test.ts | 48 +++++++- 4 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/realtime-navigation.spec.ts diff --git a/src/dashboard/data/realtime.ts b/src/dashboard/data/realtime.ts index 64321efd..c76e8634 100644 --- a/src/dashboard/data/realtime.ts +++ b/src/dashboard/data/realtime.ts @@ -64,6 +64,14 @@ const invalidateBelaboxLiveQueries = (queryClient: QueryClient, channelId: strin } }; +const invalidateModuleDataQueries = (queryClient: QueryClient, channelId: string, moduleId: string): void => { + const moduleQueries = queryClient.getQueryCache().findAll({ queryKey: dashboardDataKeys.module(channelId, moduleId) }); + for (const query of moduleQueries) { + const part = query.queryKey[4]; + if (typeof part === "string" && part !== "settings") invalidate(queryClient, query.queryKey); + } +}; + /** Runs a leading refresh, then limits hints for this key to one refresh per interval. */ const scheduleRealtimeRefresh = (queryClient: QueryClient, queryKey: readonly unknown[]): void => { let refreshes = scheduledRefreshes.get(queryClient); @@ -109,7 +117,7 @@ export const invalidateDashboardRealtimeMessage = ( case "event_log.new": { invalidate(queryClient, queryKeys.channel(channelId, "events")); const moduleIds = new Set(message.payload.entries.map((entry) => entry.moduleId)); - for (const moduleId of moduleIds) invalidate(queryClient, dashboardDataKeys.module(channelId, moduleId)); + for (const moduleId of moduleIds) invalidateModuleDataQueries(queryClient, channelId, moduleId); break; } case "variables.changed": diff --git a/tests/components/chat-voting-panel.test.tsx b/tests/components/chat-voting-panel.test.tsx index 1081b3dc..ccf71c4f 100644 --- a/tests/components/chat-voting-panel.test.tsx +++ b/tests/components/chat-voting-panel.test.tsx @@ -181,6 +181,7 @@ describe("chat voting live panel", () => { }); it("uses saved default labels and duration for the next vote", async () => { + setDashboardRealtimeStatus("fictional-channel", "connected"); let serverState = currentState(); let startPayload: unknown; vi.stubGlobal("fetch", fetchFor( diff --git a/tests/e2e/realtime-navigation.spec.ts b/tests/e2e/realtime-navigation.spec.ts new file mode 100644 index 00000000..9b86618a --- /dev/null +++ b/tests/e2e/realtime-navigation.spec.ts @@ -0,0 +1,127 @@ +import { expect, test, type Page } from "@playwright/test"; + +const channelId = "realtime-navigation"; +const channel = { + channelId, + login: "realtime-navigation", + displayName: "Realtime Navigation", + language: "en", + role: "manager", + broadcasterConnection: "connected", + channelBotConsent: "granted", + bot: { status: "connected", reason: null, updatedAt: "2026-10-09T08:00:00.000Z" }, + moderator: { isModerator: true, checkedAt: "2026-10-09T08:00:00.000Z", reason: null }, + chatSubscription: { status: "enabled", subscriptionId: "realtime-subscription", reason: null, updatedAt: "2026-10-09T08:00:00.000Z" }, + tokens: { + botExpiresAt: "2099-10-09T08:00:00.000Z", + loginStatus: "connected", + loginReason: null, + loginExpiresAt: "2099-10-09T08:00:00.000Z", + }, + streamState: "offline", + streamStartedAt: null, + controls: { + mute: { active: false, until: null, mode: null }, + pause: { active: false, until: null, mode: null }, + }, + lastError: null, +}; + +const installSocketCounter = async (page: Page): Promise => { + await page.addInitScript(() => { + type TrackedSocket = EventTarget & { url: string; readyState: number }; + const sockets: TrackedSocket[] = []; + class MockWebSocket extends EventTarget { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + readonly url: string; + readyState = MockWebSocket.CONNECTING; + + constructor(url: string | URL) { + super(); + this.url = String(url); + sockets.push(this); + } + + close(): void { + this.readyState = MockWebSocket.CLOSED; + this.dispatchEvent(new CloseEvent("close", { code: 1000 })); + } + + send(): void {} + } + Object.defineProperty(window, "__realtimePanelSockets", { value: sockets, configurable: true }); + Object.defineProperty(window, "WebSocket", { value: MockWebSocket, writable: true, configurable: true }); + }); +}; + +const installApiMocks = async (page: Page): Promise => { + await page.route("**/api/**", async (route) => { + const pathname = new URL(route.request().url()).pathname; + if (pathname === "/api/channels") { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ channels: [channel], bot: channel.bot }) }); + return; + } + if (pathname === `/api/channels/${channelId}/overview`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...channel, activeModules: [{ moduleId: "text_commands", settings: "{}" }] }) }); + return; + } + if (pathname === `/api/channels/${channelId}/settings`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ timeZone: "UTC", revision: 1, location: null, locationRevision: 0 }) }); + return; + } + if (pathname === `/api/channels/${channelId}/modules`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ modules: [{ id: "text_commands", enabled: true, settings: "{}" }] }) }); + return; + } + if (pathname === `/api/channels/${channelId}/modules/text_commands/commands`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ commands: [], variables: [] }) }); + return; + } + if (pathname === `/api/channels/${channelId}/template-variables`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ variables: [] }) }); + return; + } + if (pathname === `/api/channels/${channelId}/events`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ entries: [], nextCursor: null }) }); + return; + } + if (pathname === `/api/channels/${channelId}/variables`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ variables: [], count: 0, maximum: 25 }) }); + return; + } + await route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + }); +}; + +const panelSocketPaths = async (page: Page): Promise => page.evaluate(() => { + const sockets = (window as Window & { __realtimePanelSockets?: Array<{ url: string }> }).__realtimePanelSockets ?? []; + return sockets + .map(({ url }) => new URL(url, window.location.href).pathname) + .filter((pathname) => pathname.startsWith("/ws/channels/")); +}); + +test("keeps one channel socket while navigating between dashboard views", async ({ page }) => { + await installSocketCounter(page); + await installApiMocks(page); + await page.goto(`/channels/${channelId}`); + await expect(page.getByRole("heading", { name: channel.displayName, level: 1 })).toBeVisible(); + await expect.poll(() => panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); + + await page.locator('a[data-nav-page-id="events"]').click(); + await expect(page).toHaveURL(`/channels/${channelId}/events`); + await expect(page.getByRole("heading", { name: "Events", level: 1 })).toBeVisible(); + expect(await panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); + + await page.locator('a[data-nav-page-id="variables"]').click(); + await expect(page).toHaveURL(`/channels/${channelId}/variables`); + await expect(page.getByRole("heading", { name: "Channel variables", level: 1 })).toBeVisible(); + expect(await panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); + + await page.locator(`a[href="/channels/${channelId}/modules/text_commands"]`).click(); + await expect(page).toHaveURL(`/channels/${channelId}/modules/text_commands`); + await expect(page.locator("h1")).toBeVisible(); + expect(await panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); +}); diff --git a/tests/unit/dashboard-realtime.test.ts b/tests/unit/dashboard-realtime.test.ts index cc7b4008..db8c2eb2 100644 --- a/tests/unit/dashboard-realtime.test.ts +++ b/tests/unit/dashboard-realtime.test.ts @@ -1,5 +1,5 @@ import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { parseRealtimeMessage } from "../../src/dashboard/realtime"; import { dashboardDataKeys, queryKeys } from "../../src/dashboard/data/keys"; @@ -28,6 +28,7 @@ const addQuery = (queryClient: QueryClient, key: readonly unknown[]): void => { afterEach(() => { setDashboardRealtimeStatus(channelId, "offline"); + vi.useRealTimers(); }); describe("dashboard realtime messages", () => { @@ -66,6 +67,51 @@ describe("dashboard realtime messages", () => { expect(queryClient.getQueryState(unrelated)?.isInvalidated).toBe(false); }); + it("bounds sustained module hints and does not refresh module settings", async () => { + vi.useFakeTimers(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const reads = { events: 0, panel: 0, settings: 0 }; + const queryKeysByPart = { + events: dashboardDataKeys.events(channelId, { origin: null, module: null, tone: null, person: null }), + panel: moduleQueryKey(channelId, "chat_voting", "panel"), + settings: moduleQueryKey(channelId, "chat_voting", "settings"), + } as const; + const subscriptions = Object.entries(queryKeysByPart).map(([part, queryKey]) => { + queryClient.setQueryData(queryKey, { ready: true }); + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: () => { + reads[part as keyof typeof reads] += 1; + return Promise.resolve({ ready: true }); + }, + staleTime: Infinity, + }); + return observer.subscribe(() => undefined); + }); + + for (let index = 0; index < 20; index += 1) { + invalidateDashboardRealtimeMessage(queryClient, moduleHint("modul.chat_voting.tally", { voteId: "vote-a" })); + invalidateDashboardRealtimeMessage(queryClient, moduleHint("event_log.new", { + entries: [{ + eventId: `event-${String(index)}`, + createdAt: "2026-10-09T08:00:00.000Z", + moduleId: "chat_voting", + code: "chat_voting.vote_recorded", + actorUserId: null, + }], + })); + await vi.advanceTimersByTimeAsync(50); + } + await vi.advanceTimersByTimeAsync(250); + + expect(reads.panel).toBeLessThanOrEqual(2); + expect(reads.events).toBeLessThanOrEqual(2); + expect(reads.settings).toBe(0); + + subscriptions.forEach((unsubscribe) => { unsubscribe(); }); + queryClient.clear(); + }); + it("maps variables, overlays, ad schedules, and module hints to their channel data keys", () => { const queryClient = new QueryClient(); const variables = dashboardDataKeys.variables(channelId); From b454a2068fc4a1e35931d0c26f44e48a1fce072d Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 04:23:07 +0200 Subject: [PATCH 04/16] =?UTF-8?q?=F0=9F=90=9B=20fix(dashboard):=20close=20?= =?UTF-8?q?realtime=20socket=20on=20sign-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Realtime lifecycle - Gate the channel socket on authentication, channel selection, and sign-out state. - Dispose disabled sockets immediately and cancel pending reconnect timers. - Overlay editor - Initialize from fresh overlay and variable data while module state loads independently. - Keep the fixed loading frame until module state resolves. - Regression coverage - Verify 401 reconnect cancellation and guarded logout with a dirty editor draft. - Cover continuous variable updates during a pending module read. (#387) --- src/dashboard/OverlayEditorPage.tsx | 46 ++++++--------- src/dashboard/main.tsx | 6 +- src/dashboard/realtime.ts | 14 ++++- tests/components/dashboard.test.tsx | 59 +++++++++++++++++++ tests/components/overlay-editor-page.test.tsx | 48 +++++++++++++++ 5 files changed, 142 insertions(+), 31 deletions(-) diff --git a/src/dashboard/OverlayEditorPage.tsx b/src/dashboard/OverlayEditorPage.tsx index 188e4c1c..a4d18ab0 100644 --- a/src/dashboard/OverlayEditorPage.tsx +++ b/src/dashboard/OverlayEditorPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactElement, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; import { createPortal } from "react-dom"; import { OVERLAY_ELEMENT_MAXIMUM_COUNT } from "../contracts/values"; @@ -35,6 +36,7 @@ import { apiErrorText, channelVariablesTexts, dashboardCommonTexts, dashboardLan import { moduleName } from "./module-labels"; import { OverlayElementPalette, type OverlayElementPaletteOption } from "./OverlayElementPalette"; import { useChannelVariablesQuery, useOverlayQuery } from "./data/lists"; +import { queryKeys } from "./data/keys"; import { Button, CodeField, ColorField, ConfirmDialog, Field, Icon, LoadState, NumberField, PageHeader, SaveBar, Select, Switch, notify, type IconName, registerDashboardNavigationGuard, useDraftGuard } from "./ui"; import "./overlay-editor.css"; @@ -314,10 +316,15 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i const labels = useMemo(() => overlaysTexts(dashboardLocale), [dashboardLocale]); const variablesQuery = useChannelVariablesQuery(channelId); const overlayQuery = useOverlayQuery(channelId, overlayId, overlayId !== "new"); + const modulesQuery = useQuery({ + queryKey: queryKeys.channel(channelId, "modules"), + queryFn: ({ signal }) => fetchModules(channelId, signal), + refetchOnWindowFocus: false, + }); const refetchVariables = variablesQuery.refetch; const refetchOverlay = overlayQuery.refetch; const variables = variablesQuery.data?.variables ?? []; - const [moduleStates, setModuleStates] = useState([]); + const moduleStates: readonly PanelModuleState[] = modulesQuery.data?.modules ?? []; const [session, setSession] = useState(null); const [loadError, setLoadError] = useState(null); const [loadGeneration, setLoadGeneration] = useState(0); @@ -326,6 +333,7 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i ? apiErrorText(variablesQuery.error.code, labels.editorLoadError) : labels.editorLoadError : null; + const moduleStatesReady = modulesQuery.data !== undefined || modulesQuery.isError; const overlayLoadError = session === null && overlayId !== "new" && overlayQuery.isError ? overlayQuery.error instanceof PanelApiError ? apiErrorText(overlayQuery.error.code, labels.editorLoadError) @@ -355,7 +363,6 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i return; } if (overlayId !== "new" && (overlayQuery.data === undefined || overlayQuery.isFetching)) return; - let disposed = false; const overlay = overlayId === "new" ? { id: "new", channelId, name: initialOverlayName ?? "", width: 1920, height: 1080, css: "", revision: 1, @@ -363,30 +370,15 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i } satisfies PanelOverlay : overlayQuery.data?.overlay; if (overlay === undefined) return; - const moduleRequest = fetchModules(channelId).catch(() => ({ modules: [] })); - void moduleRequest - .then((moduleResult) => { - if (disposed) return; - setModuleStates(moduleResult.modules); - const requestedVariable = initialVariableRef.current; - initialVariableRef.current = undefined; - if (requestedVariable !== undefined) { - const currentUrl = new URL(window.location.href); - currentUrl.searchParams.delete("variable"); - window.history.replaceState(window.history.state, "", `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`); - } - setSession(initialSession(overlay, variableData.variables, requestedVariable)); - initializedSessionKey.current = sessionKey; - }) - .catch((caught: unknown) => { - if (disposed) return; - const message = caught instanceof PanelApiError - ? apiErrorText(caught.code, labels.editorLoadError) - : labels.editorLoadError; - setLoadError(message); - notify({ tone: "error", message }); - }); - return () => { disposed = true; }; + const requestedVariable = initialVariableRef.current; + initialVariableRef.current = undefined; + if (requestedVariable !== undefined) { + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete("variable"); + window.history.replaceState(window.history.state, "", `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`); + } + setSession(initialSession(overlay, variableData.variables, requestedVariable)); + initializedSessionKey.current = sessionKey; }, [channelId, initialOverlayName, labels.editorLoadError, loadGeneration, overlayId, overlayQuery.data, overlayQuery.error, overlayQuery.isError, overlayQuery.isFetching, variablesQuery.data, variablesQuery.error, variablesQuery.isError]); const reload = useCallback((): void => { @@ -406,7 +398,7 @@ export function OverlayEditorPage({ channelId, overlayId, canManage, language, i }); }, [labels.editorLoadError, overlayId, refetchOverlay, refetchVariables]); - if (visibleLoadError !== null || session === null) return
+ if (visibleLoadError !== null || session === null || !moduleStatesReady) return
{labels.editorLoading}

} diff --git a/src/dashboard/main.tsx b/src/dashboard/main.tsx index 3bb410a5..6eb8777f 100644 --- a/src/dashboard/main.tsx +++ b/src/dashboard/main.tsx @@ -1227,6 +1227,7 @@ export const DashboardApp = (): ReactElement => { useLayoutEffect(() => { routeRef.current = route; }, [route]); const queryClient = useQueryClient(); const [authenticationRequired, setAuthenticationRequired] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); const requestLogin = useCallback((): void => { setAuthenticationRequired(true); }, []); useEffect(() => { window.addEventListener(dashboardAuthenticationRequiredEvent, requestLogin); @@ -1256,6 +1257,8 @@ export const DashboardApp = (): ReactElement => { const selectedChannel = (route.kind !== "channel" && route.kind !== "module") || channels.data === null ? null : channels.data.find((channel) => channel.channelId === route.channelId) ?? null; + const realtimeEnabled = realtimeChannelId !== null && selectedChannel !== null && + !authenticationRequired && !loggingOut; const viewerUserId = channelsResponse?.viewerUserId ?? null; const isPlatform = channelsResponse?.platformAdmin ?? false; const viewerIsBot = channelsResponse?.viewerIsBot ?? false; @@ -1279,7 +1282,7 @@ export const DashboardApp = (): ReactElement => { previousQuery?.queryKey[1] === overviewChannelId ? previousData : undefined, }); const overview = loadStateFromQuery(overviewQuery); - useRealtimePanelMessages(realtimeChannelId, realtimeChannelId !== null); + useRealtimePanelMessages(realtimeChannelId, realtimeEnabled); const systemChannelId = route.kind === "channel" && route.section === "system" ? route.channelId : null; const systemQuery = useSystemQuery( systemChannelId ?? "", @@ -1288,7 +1291,6 @@ export const DashboardApp = (): ReactElement => { ); const [, setFreshnessTick] = useState(0); const routeKey = dashboardRoutePath(route); - const [loggingOut, setLoggingOut] = useState(false); const [headerModuleBusyKeys, setHeaderModuleBusyKeys] = useState>(() => new Set()); const headerModuleBusy = route.kind === "module" && headerModuleBusyKeys.has(`${route.channelId}:${route.moduleId}`); // Spotlight (#164): set right before navigating to a module so its panel diff --git a/src/dashboard/realtime.ts b/src/dashboard/realtime.ts index 58789b50..21907bee 100644 --- a/src/dashboard/realtime.ts +++ b/src/dashboard/realtime.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import type { PanelChannelControl, PanelChannelControls } from "../panel-contract"; @@ -274,9 +274,19 @@ const acquirePanelSocket = (channelId: string, queryClient: QueryClient): (() => /** The dashboard shell owns the channel socket, independent of the active page. */ export const useRealtimePanelMessages = (channelId: string | null, enabled: boolean): void => { const queryClient = useQueryClient(); + const activeChannelId = useRef(null); useEffect(() => { - if (!enabled || channelId === null) return; + if (!enabled || channelId === null) { + const channelToDispose = channelId ?? activeChannelId.current; + if (channelToDispose !== null) { + const connection = panelSocketConnections.get(channelToDispose); + if (connection !== undefined && connection.references === 0) disposePanelSocket(channelToDispose, connection); + } + activeChannelId.current = null; + return; + } + activeChannelId.current = channelId; return acquirePanelSocket(channelId, queryClient); }, [channelId, enabled, queryClient]); }; diff --git a/tests/components/dashboard.test.tsx b/tests/components/dashboard.test.tsx index 7bb39b31..93428426 100644 --- a/tests/components/dashboard.test.tsx +++ b/tests/components/dashboard.test.tsx @@ -4446,6 +4446,65 @@ describe("Dashboard skeleton", () => { expect(screen.queryByRole("heading", { name: "Alpha", level: 1 })).not.toBeInTheDocument(); }); + it("cancels a pending socket reconnect after any query requires sign-in", async () => { + const channel = healthyChannel("kanal-a", "Alpha"); + let resolveEvents: ((response: Response) => void) | undefined; + const fetcher = stubEventFeedFetch(() => new Promise((resolve) => { resolveEvents = resolve; }), channel); + vi.stubGlobal("WebSocket", TestWebSocket); + window.history.replaceState({}, "", "/channels/kanal-a/events"); + + render(); + await screen.findByRole("heading", { name: "Ereignisse", level: 1 }); + await waitFor(() => expect(resolveEvents).toBeTypeOf("function")); + const socket = TestWebSocket.instances[0]; + if (socket === undefined) throw new Error("Realtime socket was not created."); + socket.close(1006, "network interruption"); + + await act(async () => { + resolveEvents?.(jsonResponse({ error: "session_required" }, 401)); + await Promise.resolve(); + }); + expect(fetcher.mock.calls.some(([input]) => requestUrl(input).pathname.endsWith("/events"))).toBe(true); + await screen.findByRole("heading", { name: "Anmeldung erforderlich", level: 1 }); + await new Promise((resolve) => { window.setTimeout(resolve, 350); }); + + expect(TestWebSocket.instances).toHaveLength(1); + }); + + it("closes the channel socket when guarded logout leaves a dirty editor route mounted", async () => { + const channel = healthyChannel("kanal-a", "Alpha"); + const overlay = { + id: "overlay-a", channelId: "kanal-a", name: "Gameplay", width: 1280, height: 720, css: "", revision: 1, + createdAt: "2026-09-24T10:00:00.000Z", updatedAt: "2026-09-24T10:00:00.000Z", + elements: [{ id: "element-a", kind: "variable", label: "Score", variableName: "score", text: "Score: {value}", config: {}, x: 24, y: 32, scalePercent: 100, z: 0, inComposition: true }], + }; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input); + if (url.pathname === "/api/channels") return jsonResponse({ channels: [channel], bot: channel.bot }); + if (url.pathname === "/api/channels/kanal-a/overlays/overlay-a") return jsonResponse({ overlay }); + if (url.pathname === "/api/channels/kanal-a/variables") return jsonResponse({ variables: [], count: 0, maximum: 25 }); + if (url.pathname === "/api/channels/kanal-a/modules") return jsonResponse({ modules: [] }); + if (url.pathname === "/api/csrf") return jsonResponse({ token: "csrf-token" }); + if (url.pathname === "/auth/logout" && init?.method === "POST") return new Response(null, { status: 204 }); + return jsonResponse({}, 404); + })); + vi.stubGlobal("WebSocket", TestWebSocket); + window.history.replaceState({}, "", "/channels/kanal-a/overlays/overlay-a"); + + render(); + await screen.findByRole("heading", { name: "Gameplay", level: 1 }); + const socket = TestWebSocket.instances[0]; + if (socket === undefined) throw new Error("Realtime socket was not created."); + socket.open(); + fireEvent.change(screen.getByRole("spinbutton", { name: "X (px)" }), { target: { value: "25" } }); + fireEvent.click(screen.getByRole("button", { name: "Abmelden" })); + + await screen.findByRole("heading", { name: "Anmeldung erforderlich", level: 1 }); + + expect(window.location.pathname).toBe("/channels/kanal-a/overlays/overlay-a"); + expect(socket.readyState).toBe(3); + }); + it("stays signed in and reports the error when logout is rejected with 403", async () => { // 403 means the CSRF token didn't match — the worker did not revoke the // session. Redirecting to sign-in here would report a logout that never diff --git a/tests/components/overlay-editor-page.test.tsx b/tests/components/overlay-editor-page.test.tsx index 7ba51695..123616ae 100644 --- a/tests/components/overlay-editor-page.test.tsx +++ b/tests/components/overlay-editor-page.test.tsx @@ -201,6 +201,54 @@ describe("Overlay composition editor", () => { expect(screen.getByRole("spinbutton", { name: "X (px)" })).toHaveValue("24"); }); + it("initializes once when variables update continuously during a slow module read", async () => { + Object.defineProperty(window.navigator, "language", { value: "en-US", configurable: true }); + let moduleReadCount = 0; + let resolveModules: ((response: Response) => void) | undefined; + const fetcher = vi.fn().mockImplementation((input) => { + const url = requestUrl(input); + if (url.pathname === "/api/channels/kanal-a/overlays/overlay-a") { + return Promise.resolve(jsonResponse({ overlay: initialOverlay })); + } + if (url.pathname === "/api/channels/kanal-a/modules") { + moduleReadCount += 1; + return new Promise((resolve) => { resolveModules = resolve; }); + } + if (url.pathname === "/api/channels/kanal-a/variables") { + return Promise.resolve(jsonResponse({ variables: [variable], count: 1, maximum: 25 })); + } + return Promise.reject(new Error(`Unexpected request ${url.pathname}`)); + }); + vi.stubGlobal("fetch", fetcher); + const view = render(); + + for (let index = 0; index < 6; index += 1) { + act(() => { + view.queryClient.setQueryData(dashboardDataKeys.variables("kanal-a"), { + variables: [{ ...variable, value: index }], count: 1, maximum: 25, + }); + }); + if (index < 5) await new Promise((resolve) => { window.setTimeout(resolve, 1_000); }); + } + + expect(screen.queryByRole("heading", { name: "Gameplay", level: 1 })).not.toBeInTheDocument(); + expect(moduleReadCount).toBe(1); + act(() => { + resolveModules?.(jsonResponse({ modules: [] })); + }); + + await screen.findByRole("heading", { name: "Gameplay", level: 1 }); + expect(moduleReadCount).toBe(1); + view.unmount(); + }, 15_000); + it("adds enabled module elements with their declared default size", async () => { Object.defineProperty(window.navigator, "language", { value: "en-US", configurable: true }); let savedOverlay = { ...initialOverlay, elements: [] as typeof initialOverlay.elements }; From 679a63aab26e024ff644f9b74922cb9e7a05f500 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 04:56:38 +0200 Subject: [PATCH 05/16] =?UTF-8?q?=F0=9F=90=9B=20fix(modules):=20bounded=20?= =?UTF-8?q?panel=20hints=20for=20every=20state=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Realtime protocol (#387) - Add panel-only bounded `{ part }` hints with strict payload validation and throttled query invalidation. - Keep module overlay payloads on overlay sockets. - Module state transitions - Relay chat voting, BELABOX, and votekick changes after commits, including BELABOX failures and terminal ballot transitions. - Publish hints independently of overlay payload size limits. - Regression coverage - Cover relay routing, secret-free hints, failure transitions, and oversized chat voting closes. - Verify single-socket navigation across 10 repeated e2e runs. --- src/dashboard/data/realtime.ts | 15 ++---- src/dashboard/realtime.ts | 4 +- src/modules/belabox/adapters/d1.ts | 10 ++-- src/modules/belabox/service.ts | 26 +++++++-- src/modules/chat_voting/routes.ts | 11 ++++ src/modules/chat_voting/service.ts | 10 ++++ src/modules/contract.ts | 7 +++ src/modules/votekick/routes.ts | 4 ++ src/modules/votekick/service.ts | 19 ++++++- src/realtime-contract.ts | 63 +++++++++++++++------- src/worker/dispatch.ts | 7 +++ src/worker/durable/ChannelObject.ts | 10 ++++ src/worker/module-panel-realtime.ts | 56 +++++++++++++++++++ src/worker/panel/module-routes.ts | 4 ++ tests/components/belabox-panel.test.tsx | 6 +-- tests/unit/belabox-polling.test.ts | 15 +++++- tests/unit/chat-voting-close.test.ts | 52 ++++++++++++++++-- tests/unit/dashboard-query-data.test.tsx | 12 ++--- tests/unit/dashboard-realtime.test.ts | 39 ++++++++------ tests/unit/dispatch.test.ts | 1 - tests/unit/module-overlay-realtime.test.ts | 7 +-- tests/unit/module-panel-realtime.test.ts | 28 ++++++++++ tests/unit/stream-state-lookup.test.ts | 11 ---- tests/unit/votekick-routes.test.ts | 5 +- tests/worker/channel-object.test.ts | 24 ++++++--- 25 files changed, 346 insertions(+), 100 deletions(-) create mode 100644 src/worker/module-panel-realtime.ts create mode 100644 tests/unit/module-panel-realtime.test.ts diff --git a/src/dashboard/data/realtime.ts b/src/dashboard/data/realtime.ts index c76e8634..660370c9 100644 --- a/src/dashboard/data/realtime.ts +++ b/src/dashboard/data/realtime.ts @@ -134,23 +134,16 @@ export const invalidateDashboardRealtimeMessage = ( break; default: { switch (message.type) { - case "modul.chat_voting.opened": - case "modul.chat_voting.tally": + case "modul.chat_voting.changed": invalidate(queryClient, queryKeys.module(channelId, "chat_voting", "panel")); break; - case "modul.belabox.state_changed": - case "modul.belabox.sample": + case "modul.belabox.changed": // Refresh only live data parts; stream history keys are selected dynamically by the panel. invalidateBelaboxLiveQueries(queryClient, channelId); break; - case "modul.votekick.opened": + case "modul.votekick.changed": invalidate(queryClient, queryKeys.module(channelId, "votekick", "panel")); - // Chat voting also reads channel-wide ballot availability from the shared ballot. - invalidate(queryClient, queryKeys.module(channelId, "chat_voting", "panel")); - break; - case "modul.votekick.tally": - invalidate(queryClient, queryKeys.module(channelId, "votekick", "panel")); - if (message.payload.status !== "running") { + if (message.payload.part === "availability") { // Chat voting also reads channel-wide ballot availability from the shared ballot. invalidate(queryClient, queryKeys.module(channelId, "chat_voting", "panel")); } diff --git a/src/dashboard/realtime.ts b/src/dashboard/realtime.ts index 21907bee..f087eea9 100644 --- a/src/dashboard/realtime.ts +++ b/src/dashboard/realtime.ts @@ -4,7 +4,7 @@ import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import type { PanelChannelControl, PanelChannelControls } from "../panel-contract"; import type { RealtimeEnvelope, RealtimeEventLogHint, RealtimeMessage } from "../realtime-contract"; import type { AdsSchedule } from "../modules/ads/contracts"; -import { REALTIME_PROTOCOL, isPanelModuleRealtimeMessageType } from "../realtime-contract"; +import { REALTIME_PROTOCOL, isPanelModuleRealtimeMessageType, isPanelModuleRealtimePayload } from "../realtime-contract"; import { invalidateDashboardChannelQueries, invalidateDashboardRealtimeMessage, @@ -93,7 +93,7 @@ export const parseRealtimeMessage = (raw: string, channelId: string): RealtimePa } if (!isKnownEnvelope(parsed)) return { kind: "ignored" }; if (isPanelModuleRealtimeMessageType(parsed.type)) { - return isRecord(parsed.payload) + return isPanelModuleRealtimePayload(parsed.type, parsed.payload) ? { kind: "message", message: parsed as RealtimeMessage } : { kind: "ignored" }; } diff --git a/src/modules/belabox/adapters/d1.ts b/src/modules/belabox/adapters/d1.ts index a36bc563..f70aade4 100644 --- a/src/modules/belabox/adapters/d1.ts +++ b/src/modules/belabox/adapters/d1.ts @@ -689,8 +689,8 @@ export const reopenCurrentBelaboxStream = async ( db: D1Database, channelId: string, expectedModuleRevision: number, -): Promise => { - await db.prepare( +): Promise => { + const result = await db.prepare( `UPDATE belabox_streams SET ended_at = NULL, bitrate_p10 = NULL WHERE channel_id = ? AND ended_at IS NOT NULL @@ -706,6 +706,7 @@ export const reopenCurrentBelaboxStream = async ( AND revision = ? AND json_extract(settings, '$.mode') = 'interval' )`, ).bind(channelId, channelId, expectedModuleRevision).run(); + return result.meta.changes > 0; }; export const belaboxStreamExists = async ( @@ -778,8 +779,8 @@ export const clearBelaboxHistoryBaseline = async ( export const markBelaboxPollingStarted = async ( db: D1Database, channelId: string, -): Promise => { - await db.prepare( +): Promise => { + const result = await db.prepare( `INSERT INTO belabox_status (channel_id, polling, stream_id, belabox_stream_id, fetch_phase_json, recent_json, revision) VALUES (?, 1, NULL, NULL, ?, '[]', 1) @@ -788,6 +789,7 @@ export const markBelaboxPollingStarted = async ( revision = belabox_status.revision + 1 WHERE belabox_status.polling != 1`, ).bind(channelId, encodedPhase(EMPTY_FETCH_PHASE)).run(); + return result.meta.changes > 0; }; export const writeBelaboxFetch = async ( diff --git a/src/modules/belabox/service.ts b/src/modules/belabox/service.ts index 924aac30..223aeb17 100644 --- a/src/modules/belabox/service.ts +++ b/src/modules/belabox/service.ts @@ -64,6 +64,10 @@ const LIVE_BUFFER_MAX_POINTS = 120; const FETCH_FAILURE_THRESHOLD = 3; const INFRASTRUCTURE_RETRY_MAX_MS = 60_000; +const publishBelaboxPanelHint = async (context: Pick): Promise => { + try { await context.publishModulePanelHint?.("live"); } catch { /* Panel refresh hints are best-effort. */ } +}; + /** Sets fresh chat templates to the channel language whenever BELABOX is enabled. */ export const belaboxAlertDefaultsOnEnable = async ( context: ModuleEnableContext, @@ -292,6 +296,7 @@ const stopPolling = async ( ...(prerequisites.secretUnavailable ? { errorCode: BELABOX_SECRET_UNAVAILABLE_STATUS_CODE } : {}), }, ); + if (current) await publishBelaboxPanelHint(context); if (current && hadVisibleState && prerequisites.streamState === "offline") { try { await context.publishModuleOverlayMessage("state_changed", "belabox.status", { @@ -634,7 +639,10 @@ const sendPendingAlert = async ( if (latest?.alertState.pendingChat?.idempotencyKind !== output.idempotencyKind || latest.alertState.pendingChat.episodeStartedAt !== output.episodeStartedAt) return; const next = settleAlertChat(latest.alertState, { sent: result.sent, retryable: result.retryable }, Date.now()); - if (await writeBelaboxAlertState(context.DB, context.channelId, next, latest.revision) !== null) return; + if (await writeBelaboxAlertState(context.DB, context.channelId, next, latest.revision) !== null) { + await publishBelaboxPanelHint(context); + return; + } } } }; @@ -721,6 +729,7 @@ const runBelaboxPoll = async ( currentStatus.revision, prerequisites.streamSnapshot, ); + if (cleared) await publishBelaboxPanelHint(context); if (!cleared) await context.schedule(BELABOX_POLL_ALARM_KEY, Date.now()); } return; @@ -728,7 +737,7 @@ const runBelaboxPoll = async ( const prerequisites = await alarmPrerequisites(context, requiredMode); delay = retryDelay(prerequisites.settings); if (prerequisites.secretReadFailed) { - await finalizeAndResetBelaboxHistory( + const current = await finalizeAndResetBelaboxHistory( context.DB, context.channelId, new Date().toISOString(), @@ -740,6 +749,7 @@ const runBelaboxPoll = async ( maxBaselineAgeMs: (prerequisites.historyIntervalSeconds ?? 0) * 2_000, }, ); + if (current) await publishBelaboxPanelHint(context); if (reason !== "on_demand") await reschedulePollAfterFailure(context, deadline, delay); return reason === "alarm" ? undefined : { ok: false, reason: "network" }; } @@ -750,10 +760,12 @@ const runBelaboxPoll = async ( } pollMode = prerequisites.settings.mode; if (pollMode === "interval") { + let panelStateChanged = false; if (prerequisites.moduleRevision !== null) { - await reopenCurrentBelaboxStream(context.DB, context.channelId, prerequisites.moduleRevision); + panelStateChanged = await reopenCurrentBelaboxStream(context.DB, context.channelId, prerequisites.moduleRevision); } - await markBelaboxPollingStarted(context.DB, context.channelId); + panelStateChanged = await markBelaboxPollingStarted(context.DB, context.channelId) || panelStateChanged; + if (panelStateChanged) await publishBelaboxPanelHint(context); } const originSession = streamSessionFromSnapshot(prerequisites.streamSnapshot); const result = await fetchRelaySample( @@ -770,10 +782,11 @@ const runBelaboxPoll = async ( originSession, pollMode, ); + if (stored.kind === "stored") await publishBelaboxPanelHint(context); const latest = await alarmPrerequisites(context, requiredMode); if (latest.secretReadFailed) { - await finalizeAndResetBelaboxHistory( + const current = await finalizeAndResetBelaboxHistory( context.DB, context.channelId, new Date().toISOString(), @@ -785,6 +798,7 @@ const runBelaboxPoll = async ( maxBaselineAgeMs: (latest.historyIntervalSeconds ?? 0) * 2_000, }, ); + if (current) await publishBelaboxPanelHint(context); if (pollMode === "interval") await reschedulePollAfterFailure(context, deadline, retryDelay(latest.settings)); return reason === "alarm" ? undefined : { ok: false, reason: "network" }; } @@ -806,6 +820,7 @@ const runBelaboxPoll = async ( maxBaselineAgeMs: (latest.historyIntervalSeconds ?? 0) * 2_000, }, ); + if (current) await publishBelaboxPanelHint(context); await ensureLifecycleRevision(context, current); if (current && pollMode === "interval") await context.schedule(BELABOX_POLL_ALARM_KEY, Date.now()); return reason === "alarm" ? undefined : { ok: false, reason: "stream_changed" }; @@ -827,6 +842,7 @@ const runBelaboxPoll = async ( maxBaselineAgeMs: (latest.historyIntervalSeconds ?? 0) * 2_000, }, ); + if (current) await publishBelaboxPanelHint(context); await ensureLifecycleRevision(context, current); if (current && pollMode === "interval") await context.schedule(BELABOX_POLL_ALARM_KEY, Date.now()); return reason === "alarm" ? undefined : { ok: false, reason: "not_configured" }; diff --git a/src/modules/chat_voting/routes.ts b/src/modules/chat_voting/routes.ts index 957ab69e..412ed392 100644 --- a/src/modules/chat_voting/routes.ts +++ b/src/modules/chat_voting/routes.ts @@ -31,6 +31,14 @@ const channelLanguage = async (db: D1Database, channelId: string): Promise<"de" export const chatVotingRoutes = new Hono(); +const publishChatVotingPanelHint = async ( + context: { get: (key: "publishModulePanelHint") => ModuleRouteEnvironment["Variables"]["publishModulePanelHint"] }, + channelId: string, +): Promise => { + try { await context.get("publishModulePanelHint")?.(channelId, CHAT_VOTING_MODULE_ID, "panel"); } + catch { /* Panel refresh hints are best-effort. */ } +}; + chatVotingRoutes.get("/current", async (context) => { const channelId = context.req.param("channelId") ?? ""; const repository = createChatVotingRepository(context.env.DB); @@ -164,6 +172,7 @@ chatVotingRoutes.post("/start", async (context) => { } if (result.status === "busy") return context.json({ error: "chat_voting_busy" }, 409); + await publishChatVotingPanelHint(context, channelId); await context.get("writeModuleAudit")({ channelId, moduleId: CHAT_VOTING_MODULE_ID, @@ -221,6 +230,7 @@ chatVotingRoutes.post("/approve-term", async (context) => { }; const publishTally = async (snapshot: BallotSnapshot): Promise => { + await publishChatVotingPanelHint(context, channelId); await context.get("publishModuleOverlayMessage")( channelId, CHAT_VOTING_MODULE_ID, @@ -280,6 +290,7 @@ chatVotingRoutes.post("/approve-term", async (context) => { if (!persisted.authorized) return context.json({ error: "chat_voting_not_authorized" }, 403); if (!persisted.changed) return context.json({ error: "chat_voting_not_running" }, 409); + await publishChatVotingPanelHint(context, channelId); const filtered = await ballots.setBlockedTerms(vote.id, blockedTerms); if (filtered === null) return context.json({ error: "chat_voting_not_running" }, 409); // A refresh can remove previously counted terms. Publish that durable state diff --git a/src/modules/chat_voting/service.ts b/src/modules/chat_voting/service.ts index 78c5051d..7ccfa9b3 100644 --- a/src/modules/chat_voting/service.ts +++ b/src/modules/chat_voting/service.ts @@ -27,6 +27,12 @@ export type VoteStartResult = | { status: "started"; vote: ChatVoteDraft } | { status: "busy" }; +const publishChatVotingPanelHint = async ( + context: Pick, +): Promise => { + try { await context.publishModulePanelHint?.("panel"); } catch { /* Panel refresh hints are best-effort. */ } +}; + export const chatVotingTemplateValues = ( vote: Pick, language: ModuleLanguage, @@ -283,6 +289,7 @@ export const processChatVotingMessage = async ( if (result.status === "busy") { return { actions: [directChat(chatVotingChatText(language, "busy"))], diagnostics: [] }; } + await publishChatVotingPanelHint(context); return { actions: [ { kind: "overlay", type: "opened", elementKind: CHAT_VOTING_ELEMENT_KIND, payload: { pollId: result.vote.id } }, @@ -306,6 +313,7 @@ export const processChatVotingMessage = async ( if (result.status !== "counted" && result.status !== "changed" && result.status !== "overflow") { return { actions: [], diagnostics: [] }; } + await publishChatVotingPanelHint(context); return { actions: [{ kind: "overlay", @@ -338,6 +346,7 @@ export const processChatVotingMessage = async ( const result = await context.ballots.cast(vote.id, userId, choice.choice, choice.source === "word" ? { onlyIfNew: true } : undefined); if (result.status !== "counted" && result.status !== "changed") return { actions: [], diagnostics: [] }; + await publishChatVotingPanelHint(context); return { actions: [{ kind: "overlay", @@ -519,6 +528,7 @@ export const closeChatVoteFromAlarm = async ( const closedVote = await repository.byId(context.channelId, pollId); if (closedVote === null || closedVote.status !== "closed") throw new Error("The chat vote could not be closed."); + await publishChatVotingPanelHint(context); await context.publishModuleOverlayMessage("tally", CHAT_VOTING_ELEMENT_KIND, { pollId, openedAt: closedVote.openedAt, diff --git a/src/modules/contract.ts b/src/modules/contract.ts index 4413db84..2763439e 100644 --- a/src/modules/contract.ts +++ b/src/modules/contract.ts @@ -5,6 +5,7 @@ import type { AuditWriteAction, ChannelRole, ChannelStreamState, ChannelVariable import type { TemplateContext, TemplateFields, TemplateVariable } from "../template"; import type { SettingsEditorDefinition } from "../dashboard/ui"; import type { ModerationResult, ModerationTimeoutExpectation } from "./contracts/moderation"; +import type { PanelModuleHintPart } from "../realtime-contract"; export type { PanelTemplateWarning, PanelTemplateWarningResponse } from "../panel-contract"; export { CHAT_OUTPUT_TARGETS } from "../contracts/values"; export type { ChatOutputTarget } from "../contracts/values"; @@ -565,6 +566,8 @@ export interface ModuleExecutionContext { authorizeMutation: AuthorizeModuleMutation; /** Ephemeral ballot access bound to the event channel and executing module. */ ballots: ModuleBallotAccess; + /** Publishes a bounded panel invalidation after a module commits visible state. */ + publishModulePanelHint?: (part: PanelModuleHintPart) => Promise; /** Lazily reads the current channel's public blocked terms with the bot moderator token. */ readChannelBlockedTerms?: () => Promise; /** Lazily resolves the connected bot identity so modules can ignore its own chat messages. */ @@ -659,6 +662,8 @@ export interface ModuleAlarmContext { elementKind: string, payload: Readonly>, ) => Promise; + /** Publishes a bounded panel invalidation independently of overlay payloads. */ + publishModulePanelHint?: (part: PanelModuleHintPart) => Promise; /** Sends scheduled automated output through the shared channel limit with an occurrence claim. */ sendChat: ( text: string, @@ -1174,6 +1179,8 @@ export interface ModuleRouteVariables { payload: Readonly>, recipientConfig?: { field: string; value: string }, ) => Promise; + /** Publishes a bounded panel invalidation independently of overlay payloads. */ + publishModulePanelHint?: (channelId: string, moduleId: string, part: PanelModuleHintPart) => Promise; publishOverlayHostEvent: (channelId: string, event: ModuleOverlayHostEvent) => Promise; writeModuleDiagnostics: ( db: D1Database, diff --git a/src/modules/votekick/routes.ts b/src/modules/votekick/routes.ts index 3c3df77c..d9d83bd0 100644 --- a/src/modules/votekick/routes.ts +++ b/src/modules/votekick/routes.ts @@ -51,6 +51,8 @@ votekickRoutes.post("/votekicks/:id/cancel", async (context) => { }, now); const results = await context.env.DB.batch([mutation, audit]); if (results.at(0)?.meta.changes !== 1) return context.json({ error: "votekick_not_running" }, 409); + try { await context.get("publishModulePanelHint")?.(channelId, VOTEKICK_MODULE_ID, "availability"); } + catch { /* Panel refresh hints are best-effort. */ } try { const ballots = context.get("ballots")(channelId); const snapshot = await ballots.close(id); @@ -111,6 +113,8 @@ votekickRoutes.post("/votekicks/:id/lift", async (context) => { AND target_user_id IS NOT NULL ${authorization.sql}`, ).bind(now, channelId, id, ...authorization.values).run(); if (update.meta.changes === 0) return context.json({ error: "votekick_timeout_unavailable" }, 409); + try { await context.get("publishModulePanelHint")?.(channelId, VOTEKICK_MODULE_ID, "panel"); } + catch { /* Panel refresh hints are best-effort. */ } try { const lifted = await repository.byId(channelId, id); if (lifted !== null) { diff --git a/src/modules/votekick/service.ts b/src/modules/votekick/service.ts index f2d33818..1e9d91d9 100644 --- a/src/modules/votekick/service.ts +++ b/src/modules/votekick/service.ts @@ -44,15 +44,23 @@ const votekickOverlayAction = (type: "opened" | "tally", payload: Readonly, + context: Pick, votekick: Votekick, ): Promise => { + await publishVotekickPanelHint(context, "availability"); if (typeof context.publishModuleOverlayMessage !== "function") return; try { await context.publishModuleOverlayMessage("tally", VOTEKICK_ELEMENT_KIND, votekickOverlayPayload(votekick)); } catch { /* Overlay presentation must never hold up moderation or alarm cleanup. */ } }; +const publishVotekickPanelHint = async ( + context: { publishModulePanelHint?: ModuleExecutionContext["publishModulePanelHint"] }, + part: "panel" | "availability", +): Promise => { + try { await context.publishModulePanelHint?.(part); } catch { /* Panel refresh hints are best-effort. */ } +}; + const chatAction = (text: string, target: VotekickSettings["chatTarget"], automated = false): ModuleAction | null => text.length === 0 ? null : { kind: "chat", text, target, automated }; @@ -132,6 +140,7 @@ const preparePass = async ( type VotekickFinalizeAccess = { ballots: ModuleExecutionContext["ballots"]; + publishModulePanelHint?: ModuleExecutionContext["publishModulePanelHint"]; channelLanguage: ModuleExecutionContext["channelLanguage"]; secureRandomInteger: ModuleExecutionContext["secureRandomInteger"]; renderTemplate: ( @@ -156,6 +165,7 @@ const finalizeVotekick = async ( const [yesVotes, noVotes] = snapshotCounts(finalized); if (finalized.outcome === "open") { await repository.updateCounts(channelId, running.id, yesVotes, noVotes, finalized.revision); + await publishVotekickPanelHint(access, "panel"); return { outcome: "open", claimed: false, counts: [yesVotes, noVotes], revision: finalized.revision }; } if (finalized.outcome === "not_open") { @@ -371,6 +381,7 @@ const commandResult = async ( } ballotOpened = false; alarmScheduled = false; + await publishVotekickPanelHint(context, "availability"); return { actions: [ votekickOverlayAction("opened", { votekickId: id }), @@ -405,6 +416,7 @@ const commandResult = async ( liftedAt: null, } : null; + if (failed !== null) await publishVotekickPanelHint(context, "availability"); return { actions: failed === null ? [] : [votekickOverlayAction("tally", votekickOverlayPayload(failed))], diagnostics: rejection("lookup_failure").diagnostics, @@ -430,10 +442,14 @@ export const processVotekickMessage = async ( if (!attempt.claimed) { let finalized: Votekick | null = null; try { finalized = await repository.byId(event.channelId, running.id); } catch { /* The other claimant owns moderation. */ } + if (finalized !== null && finalized.status !== "running") { + await publishVotekickPanelHint(context, "availability"); + } return { actions: finalized === null ? [] : [votekickOverlayAction("tally", votekickOverlayPayload(finalized))], diagnostics: [] }; } await bestEffortClose(context, running.id); await bestEffortClear(context, `close:${running.id}`); + await publishVotekickPanelHint(context, "availability"); return { actions: [attempt.prepared.action, votekickOverlayAction("tally", votekickOverlayPayload(attempt.votekick))], diagnostics: [...attempt.prepared.diagnostics], @@ -445,6 +461,7 @@ export const processVotekickMessage = async ( else await context.scheduleAlarm("close", `close:${running.id}`, Date.now()); let expired: Votekick | null = null; try { expired = await repository.byId(event.channelId, running.id); } catch { /* Overlay presentation is best-effort. */ } + if (expired !== null && expired.status !== "running") await publishVotekickPanelHint(context, "availability"); return { actions: expired === null ? [] : [votekickOverlayAction("tally", votekickOverlayPayload(expired))], diagnostics: [] }; } return null; diff --git a/src/realtime-contract.ts b/src/realtime-contract.ts index cc5b3c43..d8240ee9 100644 --- a/src/realtime-contract.ts +++ b/src/realtime-contract.ts @@ -21,6 +21,16 @@ export const REALTIME_MESSAGE_TYPES = [ export type FixedRealtimeMessageType = (typeof REALTIME_MESSAGE_TYPES)[number]; export type ModuleOverlayRealtimeMessageType = `modul.${string}.${string}`; +export type PanelModuleHintPart = "panel" | "live" | "availability"; + +export interface PanelModuleRealtimePayloads { + "modul.chat_voting.changed": { part: "panel" }; + "modul.belabox.changed": { part: "live" }; + "modul.votekick.changed": { part: "panel" | "availability" }; +} + +export type PanelModuleRealtimeMessageType = keyof PanelModuleRealtimePayloads; + export interface ModuleOverlayRealtimePayloads { "modul.text_library.blocks_updated": { blockName: string }; "modul.text_library.state_changed": { @@ -39,13 +49,6 @@ export type KnownModuleOverlayRealtimeMessageType = keyof ModuleOverlayRealtimeP | `modul.votekick.${string}` | `modul.text_commands.${string}`; export type RealtimeMessageType = FixedRealtimeMessageType | KnownModuleOverlayRealtimeMessageType; -export type PanelModuleRealtimeMessageType = - | "modul.chat_voting.opened" - | "modul.chat_voting.tally" - | "modul.belabox.state_changed" - | "modul.belabox.sample" - | "modul.votekick.opened" - | "modul.votekick.tally"; type ModuleOverlayPayload = Type extends `modul.text_library.${string}` ? Type extends keyof ModuleOverlayRealtimePayloads ? ModuleOverlayRealtimePayloads[Type] : never @@ -106,9 +109,21 @@ export type ModuleOverlayRealtimeEnvelope = { + [EnvelopeType in Type]: { + version: RealtimeProtocolVersion; + id: string; + createdAt: string; + channelId: string; + type: EnvelopeType; + payload: PanelModuleRealtimePayloads[EnvelopeType]; + } +}[Type]; + export type RealtimeEnvelope = Type extends FixedRealtimeMessageType ? FixedRealtimeEnvelope - : Type extends ModuleOverlayRealtimeMessageType ? ModuleOverlayRealtimeEnvelope + : Type extends PanelModuleRealtimeMessageType ? PanelModuleRealtimeEnvelope + : Type extends ModuleOverlayRealtimeMessageType ? ModuleOverlayRealtimeEnvelope : never; /** @@ -118,32 +133,42 @@ export type RealtimeEnvelope> + | ModuleOverlayRealtimeEnvelope> | ModuleOverlayRealtimeEnvelope; export type RealtimeMessage = { [Type in FixedRealtimeMessageType]: FixedRealtimeEnvelope }[FixedRealtimeMessageType] - | AnyModuleOverlayRealtimeEnvelope; + | AnyModuleOverlayRealtimeEnvelope + | PanelModuleRealtimeEnvelope; export const isModuleOverlayRealtimeMessageType = (type: string): type is ModuleOverlayRealtimeMessageType => /^modul\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_-]*$/u.test(type); -/** Only module actions that update panel-owned live state are relayed to panel sockets. */ +/** Bounded panel-only invalidation hints; module overlay payloads stay overlay-only. */ const PANEL_MODULE_REALTIME_MESSAGE_TYPES = new Set([ - "modul.chat_voting.opened", - "modul.chat_voting.tally", - "modul.belabox.state_changed", - "modul.belabox.sample", - "modul.votekick.opened", - "modul.votekick.tally", + "modul.chat_voting.changed", + "modul.belabox.changed", + "modul.votekick.changed", ]); export const isPanelModuleRealtimeMessageType = ( type: string, ): type is PanelModuleRealtimeMessageType => PANEL_MODULE_REALTIME_MESSAGE_TYPES.has(type as PanelModuleRealtimeMessageType); +export const isPanelModuleRealtimePayload = (type: string, payload: unknown): boolean => { + if (!isPanelModuleRealtimeMessageType(type) || typeof payload !== "object" || payload === null || Array.isArray(payload)) { + return false; + } + const values = payload as Readonly>; + if (Object.keys(values).length !== 1 || !Object.hasOwn(values, "part")) return false; + if (type === "modul.chat_voting.changed") return values.part === "panel"; + if (type === "modul.belabox.changed") return values.part === "live"; + return values.part === "panel" || values.part === "availability"; +}; + export const isModuleOverlayRealtimeEnvelope = ( message: RealtimeMessage, -): message is AnyModuleOverlayRealtimeEnvelope => isModuleOverlayRealtimeMessageType(message.type); +): message is AnyModuleOverlayRealtimeEnvelope => + isModuleOverlayRealtimeMessageType(message.type) && !isPanelModuleRealtimeMessageType(message.type); export type RealtimeRecipientKind = "panel" | "overlay"; @@ -159,7 +184,7 @@ export const REALTIME_RECIPIENTS = { export const realtimeRecipients = (type: RealtimeMessageType | ModuleOverlayRealtimeMessageType): readonly RealtimeRecipientKind[] => isModuleOverlayRealtimeMessageType(type) - ? isPanelModuleRealtimeMessageType(type) ? ["panel", "overlay"] : ["overlay"] + ? isPanelModuleRealtimeMessageType(type) ? ["panel"] : ["overlay"] : REALTIME_RECIPIENTS[type]; export type RealtimePanelPrincipal = { diff --git a/src/worker/dispatch.ts b/src/worker/dispatch.ts index 829c3ccb..45630b03 100644 --- a/src/worker/dispatch.ts +++ b/src/worker/dispatch.ts @@ -11,6 +11,7 @@ import { fetchTwitchUserByLogin, sendShoutout } from "./shoutout"; import { isTwitchChannelModerator, sendModerationBan } from "./moderation"; import { publishRealtimeMessages, publishVariablesChanged } from "./realtime"; import { prepareModuleOverlayHostEventMessages, prepareModuleOverlayRealtimeMessage } from "./module-overlay-realtime"; +import { publishModulePanelHint } from "./module-panel-realtime"; import { writeModuleDiagnostics, type WrittenModuleDiagnostic } from "./event-log"; import { authorizeModuleMutation } from "./module-authorization"; import { getAppAccessToken } from "./app-token"; @@ -959,6 +960,12 @@ export const dispatchEventSubNotification = async ( DB: environment.DB, authorizeMutation: authorizeModuleMutation, ballots: moduleBallots(environment.CHANNEL, event.channelId, module.id), + publishModulePanelHint: (part) => publishModulePanelHint( + environment.CHANNEL, + event.channelId, + module.id, + part, + ), readChannelBlockedTerms: channelBlockedTerms, botUserId, lookupUserByLogin: async (login) => { diff --git a/src/worker/durable/ChannelObject.ts b/src/worker/durable/ChannelObject.ts index 5484f6bb..a9989fcf 100644 --- a/src/worker/durable/ChannelObject.ts +++ b/src/worker/durable/ChannelObject.ts @@ -46,6 +46,7 @@ import { broadcasterHasScope } from "../broadcaster-scope"; import { helixRequest } from "../twitch/helix"; import { TWITCH_RATE_LIMIT_COOLDOWN_MS } from "../twitch/rate-limit"; import { prepareModuleOverlayRealtimeMessage } from "../module-overlay-realtime"; +import { modulePanelHintMessage } from "../module-panel-realtime"; import { createModuleSecretAccess } from "../module-secrets"; import { sendChatMessage } from "../chat"; import { sendModerationBan } from "../moderation"; @@ -1331,6 +1332,15 @@ export class ChannelObject extends DurableObject { }); if (prepared.outcome === "ready") await this.publish([prepared.message]); }, + publishModulePanelHint: async (part) => { + const message = modulePanelHintMessage(channelId, moduleId, part); + if (message === null) return; + try { + await this.publish([message]); + } catch { + console.warn("Module panel update could not be sent."); + } + }, sendChat: async (text, idempotencyKey, attributions = [], stillValid, target = "source_only") => { const suppression = { reason: null as string | null }; const validateOutput = async (): Promise => { diff --git a/src/worker/module-panel-realtime.ts b/src/worker/module-panel-realtime.ts new file mode 100644 index 00000000..39e8741a --- /dev/null +++ b/src/worker/module-panel-realtime.ts @@ -0,0 +1,56 @@ +import type { PanelModuleRealtimeEnvelope, PanelModuleRealtimeMessageType, PanelModuleHintPart } from "../realtime-contract"; + +type PanelModuleHint = + | { moduleId: "chat_voting"; part: "panel" } + | { moduleId: "belabox"; part: "live" } + | { moduleId: "votekick"; part: "panel" | "availability" }; + +const panelHintType = (moduleId: string, part: PanelModuleHintPart): PanelModuleRealtimeMessageType | null => { + if (moduleId === "chat_voting" && part === "panel" || + moduleId === "belabox" && part === "live" || + moduleId === "votekick" && (part === "panel" || part === "availability")) { + return `modul.${moduleId}.changed` as PanelModuleRealtimeMessageType; + } + return null; +}; + +/** Creates an allowlisted panel hint whose payload is exactly one bounded `part` field. */ +export const modulePanelHintMessage = ( + channelId: string, + moduleId: string, + part: PanelModuleHintPart, +): PanelModuleRealtimeEnvelope | null => { + const type = panelHintType(moduleId, part); + if (type === null) return null; + const hint: PanelModuleHint = moduleId === "chat_voting" + ? { moduleId, part: "panel" } + : moduleId === "belabox" + ? { moduleId, part: "live" } + : { moduleId: "votekick", part: part as "panel" | "availability" }; + return { + version: 1, + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + channelId, + type, + payload: { part: hint.part }, + } as PanelModuleRealtimeEnvelope; +}; + +/** Sends a panel hint without consulting overlay recipients or carrying module state. */ +export const publishModulePanelHint = async ( + namespace: Env["CHANNEL"] | undefined, + channelId: string, + moduleId: string, + part: PanelModuleHintPart, +): Promise => { + if (namespace === undefined) return; + const message = modulePanelHintMessage(channelId, moduleId, part); + if (message === null) return; + try { + const object = namespace.get(namespace.idFromName(channelId)); + await object.publish([message]); + } catch { + console.warn("Module panel update could not be sent."); + } +}; diff --git a/src/worker/panel/module-routes.ts b/src/worker/panel/module-routes.ts index 88694854..373785fa 100644 --- a/src/worker/panel/module-routes.ts +++ b/src/worker/panel/module-routes.ts @@ -30,6 +30,7 @@ import { findChannelVariable } from "../db/channel-variables"; import { measureServerTiming, recordServerTiming, scheduleBackgroundWork } from "../server-timing"; import { publishOverlayChanged, publishOverlayHostEvent as publishOverlayHostEventHint, publishRealtimeMessages } from "../realtime"; import { prepareModuleOverlayRealtimeMessage } from "../module-overlay-realtime"; +import { publishModulePanelHint } from "../module-panel-realtime"; import { DEFAULT_CHANNEL_TIME_ZONE } from "../../modules/contract"; import { readChannelVariables } from "../db/channel-variables"; import { readChannelLocation } from "../db/channel-settings"; @@ -57,6 +58,7 @@ interface ModuleRouteEnvironment { | "notifyScheduleInputsChanged" | "validateTemplateContentMutation" | "resolveTextBlockConditions" | "publishModuleOverlayMessage" + | "publishModulePanelHint" | "publishOverlayHostEvent" | "templateUsageSources" | "listRegisteredTemplateVariables" | "liftModerationBan" @@ -309,6 +311,8 @@ moduleRouter.use("/api/channels/:channelId/*", (context, next) => { console.warn("Module overlay update could not be sent.", error); } }); + context.set("publishModulePanelHint", (channelId, moduleId, part) => + publishModulePanelHint(context.env.CHANNEL, channelId, moduleId, part)); context.set("publishOverlayHostEvent", async (channelId: string, event: ModuleOverlayHostEvent) => { await publishOverlayHostEventHint(context.env.CHANNEL, context.env.DB, channelId, event); }); diff --git a/tests/components/belabox-panel.test.tsx b/tests/components/belabox-panel.test.tsx index dafcb3fd..cd92e6c0 100644 --- a/tests/components/belabox-panel.test.tsx +++ b/tests/components/belabox-panel.test.tsx @@ -154,11 +154,11 @@ describe("BELABOX panel", () => { expect(mocks.loadBelaboxHistory).not.toHaveBeenCalled(); act(() => invalidateDashboardRealtimeMessage(view.queryClient, { version: 1, - id: "belabox-state-changed", + id: "belabox-live-changed", createdAt: "2026-10-09T08:00:00.000Z", channelId: "channel-a", - type: "modul.belabox.state_changed", - payload: { reason: "stream.state.changed" }, + type: "modul.belabox.changed", + payload: { part: "live" }, })); await waitFor(() => { diff --git a/tests/unit/belabox-polling.test.ts b/tests/unit/belabox-polling.test.ts index 7a1bbd42..a2197bd7 100644 --- a/tests/unit/belabox-polling.test.ts +++ b/tests/unit/belabox-polling.test.ts @@ -133,6 +133,7 @@ const alarmContext = ( const cleared: string[] = []; const diagnostics: Array<{ code: string; detail?: Readonly> }> = []; const overlayMessages: Array<{ type: string; elementKind: string; payload: Readonly> }> = []; + const panelHints: string[] = []; let currentDeadline: number | null = null; const sendChat = vi.fn(async (_text, _idempotencyKey, _attributions, stillValid) => { if (stillValid !== undefined && !await stillValid()) return { sent: false, reason: "stale_before_send", retryable: false }; @@ -167,8 +168,12 @@ const alarmContext = ( overlayMessages.push({ type, elementKind, payload }); return Promise.resolve(); }, + publishModulePanelHint: (part: string) => { + panelHints.push(part); + return Promise.resolve(); + }, } as unknown as ModuleAlarmContext; - return { context, scheduled, cleared, diagnostics, overlayMessages, sendChat }; + return { context, scheduled, cleared, diagnostics, overlayMessages, panelHints, sendChat }; }; const pollOnDemand = async (context: ModuleAlarmContext, fetcher: typeof fetch = fetch): Promise => { @@ -1260,7 +1265,7 @@ describe("BELABOX polling", () => { it("keeps relay fetch failures out of chat and raises the notice phase on failure three", async () => { await insertModule(database, { ...BELABOX_DEFAULT_SETTINGS, mode: "interval", intervalSeconds: 5, chatEnabled: true }); - const { context, sendChat } = alarmContext(database); + const { context, sendChat, panelHints } = alarmContext(database); await handleBelaboxPollAlarm( context, "poll", @@ -1279,6 +1284,12 @@ describe("BELABOX polling", () => { } expect(sendChat).not.toHaveBeenCalled(); + expect(panelHints).toEqual(["live", "live", "live", "live", "live"]); + expect(await getBelaboxStatus(context.DB, CHANNEL_ID)).toMatchObject({ + sample: { connected: true }, + errorCode: "network", + fetchPhase: { consecutiveFailures: 3, failing: true }, + }); }); it("uses max(now, deadline) plus the active interval for the next deadline", async () => { diff --git a/tests/unit/chat-voting-close.test.ts b/tests/unit/chat-voting-close.test.ts index 9bc70503..6ee2fec0 100644 --- a/tests/unit/chat-voting-close.test.ts +++ b/tests/unit/chat-voting-close.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ModuleAlarmContext, ModuleEvent } from "../../src/modules/contract"; +import type { BallotSnapshot, ModuleAlarmContext, ModuleEvent } from "../../src/modules/contract"; import { chatVotingModule } from "../../src/modules/chat_voting"; import { createTemplateRenderer } from "../../src/worker/template-resolver"; +import { prepareModuleOverlayRealtimeMessage } from "../../src/worker/module-overlay-realtime"; import type { ChatVoteDraft } from "../../src/modules/chat_voting/contracts"; import { createChatVotingRepository } from "../../src/modules/chat_voting/repository"; import { announceChatVoteStartFromAlarm, closeChatVoteFromAlarm, requestChatVoteClose } from "../../src/modules/chat_voting/service"; @@ -54,8 +55,13 @@ describe("chat voting close service", () => { values.set(key, value); return Promise.resolve(); }); - const read = vi.fn(() => { order.push("read"); return Promise.resolve(snapshot); }); - const close = vi.fn(() => { order.push("close"); return Promise.resolve(snapshot); }); + const read = vi.fn<() => Promise>(() => { order.push("read"); return Promise.resolve(snapshot); }); + const close = vi.fn<() => Promise>(() => { order.push("close"); return Promise.resolve(snapshot); }); + const publishModulePanelHint = vi.fn((part: string) => { + order.push("panel-hint"); + void part; + return Promise.resolve(); + }); const publishModuleOverlayMessage = vi.fn( () => { order.push("overlay"); return Promise.resolve(); }, ); @@ -82,6 +88,7 @@ describe("chat voting close service", () => { clear: vi.fn(), renderTemplate: vi.fn(() => Promise.resolve({ text: "Yes: 7 (70%) · No: 3 (30%)" })), publishModuleOverlayMessage, + publishModulePanelHint, sendChat: vi.fn((...args: Parameters) => { void args; order.push("chat"); @@ -93,7 +100,7 @@ describe("chat voting close service", () => { streamStartedAt: vi.fn(() => Promise.resolve({ streamId: null, startedAt: null })), channelLanguage: vi.fn(() => Promise.resolve("en" as const)), } as unknown as ModuleAlarmContext; - return { context, values, close, read, publishModuleOverlayMessage, sendChat, storagePut }; + return { context, values, close, read, publishModuleOverlayMessage, publishModulePanelHint, sendChat, storagePut }; }; it("coalesces concurrent manual-close requests onto the same alarm key", async () => { @@ -221,6 +228,43 @@ describe("chat voting close service", () => { .resolves.toMatchObject({ status: "closed", counts: [7, 3] }); }); + it("publishes a bounded panel hint when a large closed tally exceeds the overlay limit", async () => { + const repository = await seedOpenVote(false); + await database.prepare( + `UPDATE chat_votes + SET preset = 'free_text', option_count = 0, labels_json = '[]', text_mode = 'whole_message', + term_filter_ready = 1 + WHERE channel_id = 'fictional-channel' AND poll_id = 'fictional-poll'`, + ).run(); + const terms = Array.from({ length: 200 }, (_, index) => ({ + term: `answer-${String(index).padStart(18, "0")}`, + count: 1, + approved: false, + })); + const finalSnapshot = { counts: [], revision: 201, terms, more: 0, termFilterReady: true }; + const order: string[] = []; + const { context, read, close, publishModuleOverlayMessage, publishModulePanelHint } = createAlarmContext(order); + read.mockResolvedValue(finalSnapshot); + close.mockResolvedValue(finalSnapshot); + const overlayOutcomes: string[] = []; + publishModuleOverlayMessage.mockImplementation(async (type, elementKind, payload) => { + order.push("overlay"); + const result = await prepareModuleOverlayRealtimeMessage( + database as unknown as D1Database, + "fictional-channel", + "chat_voting", + { kind: "overlay", type, elementKind, payload }, + ); + overlayOutcomes.push(result.outcome); + }); + + await closeChatVoteFromAlarm(context, repository, "fictional-poll"); + + expect(overlayOutcomes).toEqual(["rejected"]); + expect(publishModulePanelHint).toHaveBeenCalledWith("panel"); + expect(order.indexOf("panel-hint")).toBeLessThan(order.indexOf("overlay")); + }); + it("includes a vote question in the default result announcement and closed overlay state", async () => { const repository = await seedOpenVote(true, "Pizza today?"); const order: string[] = []; diff --git a/tests/unit/dashboard-query-data.test.tsx b/tests/unit/dashboard-query-data.test.tsx index fe38ece5..1ac663c8 100644 --- a/tests/unit/dashboard-query-data.test.tsx +++ b/tests/unit/dashboard-query-data.test.tsx @@ -154,11 +154,11 @@ describe("dashboard query data layer", () => { expect(await screen.findByText("1")).toBeInTheDocument(); const message = { version: 1 as const, - id: "chat-vote-tally", + id: "chat-vote-change", createdAt: "2026-10-09T08:00:00.000Z", channelId, - type: "modul.chat_voting.tally" as const, - payload: {}, + type: "modul.chat_voting.changed" as const, + payload: { part: "panel" as const }, }; invalidateDashboardRealtimeMessage(view.queryClient, message); @@ -187,11 +187,11 @@ describe("dashboard query data layer", () => { expect(await screen.findByText("1")).toBeInTheDocument(); const message = { version: 1 as const, - id: "chat-vote-tally-0", + id: "chat-vote-change-0", createdAt: "2026-10-09T08:00:00.000Z", channelId, - type: "modul.chat_voting.tally" as const, - payload: {}, + type: "modul.chat_voting.changed" as const, + payload: { part: "panel" as const }, }; for (let index = 0; index < 20; index += 1) { diff --git a/tests/unit/dashboard-realtime.test.ts b/tests/unit/dashboard-realtime.test.ts index db8c2eb2..1a59cd1d 100644 --- a/tests/unit/dashboard-realtime.test.ts +++ b/tests/unit/dashboard-realtime.test.ts @@ -38,12 +38,22 @@ describe("dashboard realtime messages", () => { id: "module-sample-1", createdAt: "2026-10-09T08:00:00.000Z", channelId: "channel-a", - type: "modul.belabox.sample", - payload: { at: "2026-10-09T08:00:00.000Z", connected: true }, + type: "modul.belabox.changed", + payload: { part: "live" }, }), "channel-a"); expect(result.kind).toBe("message"); - if (result.kind === "message") expect(result.message.type).toBe("modul.belabox.sample"); + if (result.kind === "message") expect(result.message.type).toBe("modul.belabox.changed"); + + const secretPayload = parseRealtimeMessage(JSON.stringify({ + version: 1, + id: "module-sample-secret", + createdAt: "2026-10-09T08:00:00.000Z", + channelId: "channel-a", + type: "modul.belabox.changed", + payload: { part: "live", statsUrl: "https://secret.invalid", publisherKey: "secret" }, + }), "channel-a"); + expect(secretPayload.kind).not.toBe("message"); }); it("invalidates event and originating module queries for event hints", () => { @@ -90,7 +100,7 @@ describe("dashboard realtime messages", () => { }); for (let index = 0; index < 20; index += 1) { - invalidateDashboardRealtimeMessage(queryClient, moduleHint("modul.chat_voting.tally", { voteId: "vote-a" })); + invalidateDashboardRealtimeMessage(queryClient, moduleHint("modul.chat_voting.changed", { part: "panel" })); invalidateDashboardRealtimeMessage(queryClient, moduleHint("event_log.new", { entries: [{ eventId: `event-${String(index)}`, @@ -137,7 +147,7 @@ describe("dashboard realtime messages", () => { expect(queryClient.getQueryState(ads)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(belabox)?.isInvalidated).toBe(false); - invalidateDashboardRealtimeMessage(queryClient, moduleHint("modul.belabox.sample", { at: "2026-10-09T08:00:00.000Z" })); + invalidateDashboardRealtimeMessage(queryClient, moduleHint("modul.belabox.changed", { part: "live" })); expect(queryClient.getQueryState(belabox)?.isInvalidated).toBe(true); }); @@ -174,15 +184,13 @@ describe("dashboard realtime messages", () => { it("invalidates the right module cache for each panel live-state hint", () => { const hints = [ - { moduleId: "chat_voting", type: "modul.chat_voting.opened", parts: ["panel"] }, - { moduleId: "chat_voting", type: "modul.chat_voting.tally", parts: ["panel"] }, - { moduleId: "belabox", type: "modul.belabox.state_changed", parts: ["status", "streams", "history-live", "history-stream-42"] }, - { moduleId: "belabox", type: "modul.belabox.sample", parts: ["status", "streams", "history-live", "history-stream-42"] }, - { moduleId: "votekick", type: "modul.votekick.opened", parts: ["panel"] }, - { moduleId: "votekick", type: "modul.votekick.tally", parts: ["panel"] }, + { moduleId: "chat_voting", type: "modul.chat_voting.changed", payload: { part: "panel" }, parts: ["panel"] }, + { moduleId: "belabox", type: "modul.belabox.changed", payload: { part: "live" }, parts: ["status", "streams", "history-live", "history-stream-42"] }, + { moduleId: "votekick", type: "modul.votekick.changed", payload: { part: "panel" }, parts: ["panel"] }, + { moduleId: "votekick", type: "modul.votekick.changed", payload: { part: "availability" }, parts: ["panel"] }, ] as const; - for (const { moduleId, type, parts } of hints) { + for (const { moduleId, type, payload, parts } of hints) { const queryClient = new QueryClient(); const relevant = parts.map((part) => moduleQueryKey(channelId, moduleId, part)); const settings = moduleQueryKey(channelId, moduleId, "settings"); @@ -195,7 +203,7 @@ describe("dashboard realtime messages", () => { createdAt: "2026-10-09T08:00:00.000Z", channelId, type, - payload: {}, + payload, }), channelId); expect(parsed.kind).toBe("message"); if (parsed.kind === "message") invalidateDashboardRealtimeMessage(queryClient, parsed.message); @@ -208,9 +216,8 @@ describe("dashboard realtime messages", () => { it("refreshes chat voting when a shared votekick ballot opens or closes", () => { const hints = [ - { type: "modul.votekick.opened", payload: { votekickId: "kick-a" }, refreshChatVoting: true }, - { type: "modul.votekick.tally", payload: { status: "running" }, refreshChatVoting: false }, - { type: "modul.votekick.tally", payload: { status: "cancelled" }, refreshChatVoting: true }, + { type: "modul.votekick.changed", payload: { part: "availability" }, refreshChatVoting: true }, + { type: "modul.votekick.changed", payload: { part: "panel" }, refreshChatVoting: false }, ] as const; for (const hint of hints) { diff --git a/tests/unit/dispatch.test.ts b/tests/unit/dispatch.test.ts index 2de013f8..aadffe4e 100644 --- a/tests/unit/dispatch.test.ts +++ b/tests/unit/dispatch.test.ts @@ -1394,7 +1394,6 @@ describe("dispatch and execution", () => { expect(publish).toHaveBeenCalledTimes(1); expect(publish.mock.calls[0]?.[0]).toMatchObject([ { type: "stream.state.changed", payload: { state: "online", startedAt: "2026-09-19T11:55:00.000Z" } }, - { type: "modul.belabox.state_changed", payload: { reason: "stream.state.changed" } }, { type: "variables.changed", payload: { set: [{ name: "score", value: 0 }], removed: [], overlayIdsByVariable: { score: ["overlay-a"] }, } }, diff --git a/tests/unit/module-overlay-realtime.test.ts b/tests/unit/module-overlay-realtime.test.ts index 55b7c058..350dacab 100644 --- a/tests/unit/module-overlay-realtime.test.ts +++ b/tests/unit/module-overlay-realtime.test.ts @@ -180,7 +180,7 @@ describe("module overlay realtime routing", () => { expect(result).toEqual({ outcome: "no_recipients" }); }); - it("still prepares panel state hints when a poll module has no overlay recipient", async () => { + it("keeps module overlay payloads out of panel sockets", async () => { const chatVoting: BotModule = { id: "chat_voting", navigationCategory: "chat", @@ -198,9 +198,6 @@ describe("module overlay realtime routing", () => { [chatVoting], ); - expect(result).toMatchObject({ - outcome: "ready", - message: { type: "modul.chat_voting.tally", overlayIds: [] }, - }); + expect(result).toEqual({ outcome: "no_recipients" }); }); }); diff --git a/tests/unit/module-panel-realtime.test.ts b/tests/unit/module-panel-realtime.test.ts new file mode 100644 index 00000000..153cb656 --- /dev/null +++ b/tests/unit/module-panel-realtime.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { realtimeRecipients } from "../../src/realtime-contract"; +import { modulePanelHintMessage } from "../../src/worker/module-panel-realtime"; + +describe("module panel realtime hints", () => { + it("publishes bounded panel-only hints without module data or secrets", () => { + const cases = [ + { moduleId: "belabox", part: "live" }, + { moduleId: "chat_voting", part: "panel" }, + { moduleId: "votekick", part: "availability" }, + ] as const; + + for (const { moduleId, part } of cases) { + const message = modulePanelHintMessage("channel-a", moduleId, part); + + expect(message).not.toBeNull(); + expect(message?.payload).toEqual({ part }); + expect(message === null ? [] : realtimeRecipients(message.type)).toEqual(["panel"]); + expect(JSON.stringify(message)).not.toMatch(/statsUrl|publisherKey|payloadData/iu); + } + }); + + it("rejects a module and part pair outside the allowlist", () => { + expect(modulePanelHintMessage("channel-a", "belabox", "panel")).toBeNull(); + expect(modulePanelHintMessage("channel-a", "unknown", "panel")).toBeNull(); + }); +}); diff --git a/tests/unit/stream-state-lookup.test.ts b/tests/unit/stream-state-lookup.test.ts index ef23684e..7c0f7bcd 100644 --- a/tests/unit/stream-state-lookup.test.ts +++ b/tests/unit/stream-state-lookup.test.ts @@ -177,7 +177,6 @@ describe("lookupAndRefreshStreamState", () => { ]); expect(publish.mock.calls[1]?.[0]).toMatchObject([ { type: "stream.state.changed", payload: { state: "online", startedAt } }, - { type: "modul.belabox.state_changed", payload: { reason: "stream.state.changed" } }, ]); await expect(database.prepare("SELECT value FROM channel_variables WHERE name = 'score'").first()) .resolves.toEqual({ value: 0 }); @@ -267,10 +266,6 @@ describe("lookupAndRefreshStreamState", () => { type: "stream.state.changed", channelId: "kanal-a", payload: { state: "online", startedAt: "2026-09-23T11:58:00.000Z" }, - }, { - type: "modul.belabox.state_changed", - channelId: "kanal-a", - payload: { reason: "stream.state.changed" }, }]); } finally { database.close(); @@ -291,9 +286,6 @@ describe("lookupAndRefreshStreamState", () => { expect(publish.mock.calls[0]?.[0]).toMatchObject([{ type: "stream.state.changed", payload: { state: "offline", changedAt: NOW, checkedAt: NOW }, - }, { - type: "modul.belabox.state_changed", - payload: { reason: "stream.state.changed" }, }]); } finally { database.close(); @@ -417,9 +409,6 @@ describe("lookupAndRefreshStreamState", () => { pause: { active: true, mode: "until_stream_end" }, }, }, - }, { - type: "modul.belabox.state_changed", - payload: { reason: "stream.state.changed" }, }]); } finally { database.close(); diff --git a/tests/unit/votekick-routes.test.ts b/tests/unit/votekick-routes.test.ts index e1ac2a1b..cf36e18f 100644 --- a/tests/unit/votekick-routes.test.ts +++ b/tests/unit/votekick-routes.test.ts @@ -195,10 +195,9 @@ describe("Votekick routes", () => { expect(publish).toHaveBeenCalledOnce(); const publishedMessage = publish.mock.calls[0]?.[0]?.[0]; expect(publishedMessage).toMatchObject({ - type: "modul.votekick.tally", + type: "modul.votekick.changed", channelId: "channel-a", - overlayIds: [], - payload: { votekickId: id, status: "passed" }, + payload: { part: "panel" }, }); }); diff --git a/tests/worker/channel-object.test.ts b/tests/worker/channel-object.test.ts index 53ae5b46..2985c8d2 100644 --- a/tests/worker/channel-object.test.ts +++ b/tests/worker/channel-object.test.ts @@ -1159,36 +1159,46 @@ describe("ChannelObject realtime path", () => { expect(panel.send.mock.calls).toHaveLength(0); }); - it("routes panel-enabled module messages to channel members and their overlays", async () => { + it("routes bounded module panel hints separately from module overlay messages", async () => { const overlay = overlaySocketFor({ v: 1, kind: "overlay", channelId: "kanal-a", tokenId: "token-a", overlayId: "overlay-a", expiresAt: null, }); const panel = socketFor(validPrincipal()); const object = objectFor([overlay, panel]); - const panelMessageTypes = [ + const panelMessages = [ + { type: "modul.chat_voting.changed", payload: { part: "panel" } }, + { type: "modul.belabox.changed", payload: { part: "live" } }, + { type: "modul.votekick.changed", payload: { part: "availability" } }, + ]; + const overlayMessageTypes = [ "modul.chat_voting.opened", "modul.chat_voting.tally", "modul.belabox.state_changed", "modul.belabox.sample", "modul.votekick.opened", "modul.votekick.tally", + "modul.text_library.blocks_updated", ]; - const messages = [...panelMessageTypes, "modul.text_library.blocks_updated"].map((type, index) => ({ + const messages = [ + ...panelMessages, + ...overlayMessageTypes.map((type) => ({ type, payload: { messageType: type } })), + ].map(({ type, payload }, index) => ({ version: 1, id: `module-message-${String(index)}`, createdAt: "2026-10-09T08:00:00.000Z", channelId: "kanal-a", type, - payload: { messageType: type }, overlayIds: ["overlay-a"], + payload, } as unknown as RealtimeMessage)); await object.publish(messages); - expect(panel.send.mock.calls.map(([serialized]) => typeOfSerializedMessage(serialized))).toEqual(panelMessageTypes); + expect(panel.send.mock.calls.map(([serialized]) => typeOfSerializedMessage(serialized))).toEqual( + panelMessages.map(({ type }) => type), + ); expect(overlay.send.mock.calls.map(([serialized]) => typeOfSerializedMessage(serialized))).toEqual([ - ...panelMessageTypes, - "modul.text_library.blocks_updated", + ...overlayMessageTypes, ]); }); From debbe7720cb92acebfedde90e7d744d884f9fbd3 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 12:18:12 +0200 Subject: [PATCH 06/16] =?UTF-8?q?=E2=9C=A8=20feat(realtime):=20revision-ba?= =?UTF-8?q?sed=20panel=20invalidation=20for=20all=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #387 Durable revisions: add per-resource D1 triggers and Durable Object counters with bounded, data-free panel hints. - #387 Notifier coverage: fan out committed writes from channel, module, auth, EventSub, platform, alarm, and maintenance paths. - #387 Dashboard: share one channel socket, reconcile revisions on hints, connect, and focus, and poll only while offline. - #387 Regressions: cover query mapping, channel isolation, failure commits, secret-free hints, fallback polling, and navigation without socket churn. --- migrations/0047_panel_resource_revisions.sql | 515 ++++++++++++++++ scripts/d1-baseline.mjs | 3 +- src/dashboard/ChannelVariablesPage.tsx | 19 +- src/dashboard/OverlaysPage.tsx | 79 +-- src/dashboard/api.ts | 11 +- src/dashboard/data/keys.ts | 2 + src/dashboard/data/lists.ts | 24 +- src/dashboard/data/realtime.ts | 228 +++++-- src/dashboard/main.tsx | 9 +- src/dashboard/realtime.ts | 58 +- src/modules/belabox/service.ts | 22 +- src/modules/chat_voting/routes.ts | 11 - src/modules/chat_voting/service.ts | 10 - src/modules/contract.ts | 7 - src/modules/votekick/routes.ts | 4 - src/modules/votekick/service.ts | 19 +- src/realtime-contract.ts | 67 +- src/worker/auth/overlay-token-service.ts | 6 +- src/worker/auth/routes.ts | 151 ++--- src/worker/config.ts | 4 +- src/worker/dispatch.ts | 7 - src/worker/durable/ChannelObject.ts | 242 ++++++-- src/worker/eventsub.ts | 80 ++- src/worker/index.ts | 28 + src/worker/module-overlay-realtime.ts | 4 +- src/worker/module-panel-realtime.ts | 56 -- src/worker/panel-resources.ts | 156 +++++ src/worker/panel/module-routes.ts | 4 - src/worker/panel/routes.ts | 10 + src/worker/realtime.ts | 2 + src/worker/scheduled.ts | 10 +- tests/components/ads-panel.test.tsx | 49 +- .../belabox-alert-surfaces.test.tsx | 16 +- tests/components/belabox-panel.test.tsx | 20 +- .../channel-variables-page.test.tsx | 6 +- tests/components/dashboard.test.tsx | 61 +- tests/components/events-page.test.tsx | 31 +- tests/components/overlay-editor-page.test.tsx | 2 +- tests/components/stream-manager.test.tsx | 12 +- tests/e2e/host-stable-layout.spec.ts | 7 + tests/e2e/realtime-navigation.spec.ts | 59 +- tests/query-test-utils.tsx | 20 + tests/unit/belabox-polling.test.ts | 10 +- tests/unit/chat-voting-close.test.ts | 12 +- tests/unit/dashboard-query-data.test.tsx | 41 +- tests/unit/dashboard-realtime.test.ts | 377 ++++++++---- tests/unit/module-panel-realtime.test.ts | 28 - tests/unit/overlay-realtime-route.test.ts | 5 +- tests/unit/panel-resource-revisions.test.ts | 577 ++++++++++++++++++ tests/unit/sql-contract.test.ts | 7 +- tests/unit/votekick-routes.test.ts | 12 +- tests/unit/wire-shapes.test.ts | 7 +- tests/worker/channel-object.test.ts | 38 +- tests/worker/healthz.test.ts | 4 +- 54 files changed, 2458 insertions(+), 791 deletions(-) create mode 100644 migrations/0047_panel_resource_revisions.sql delete mode 100644 src/worker/module-panel-realtime.ts create mode 100644 src/worker/panel-resources.ts delete mode 100644 tests/unit/module-panel-realtime.test.ts create mode 100644 tests/unit/panel-resource-revisions.test.ts diff --git a/migrations/0047_panel_resource_revisions.sql b/migrations/0047_panel_resource_revisions.sql new file mode 100644 index 00000000..db590fbd --- /dev/null +++ b/migrations/0047_panel_resource_revisions.sql @@ -0,0 +1,515 @@ +-- D1 cannot join a Channel Durable Object storage transaction, so D1 triggers +-- commit their revisions beside each D1 write. DO-only writes advance their +-- own counters in DO transactions; the authenticated vector combines both. +-- Socket publication is a wake-up and is repaired by reconnect/focus reads. +CREATE TABLE panel_resource_revisions ( + channel_id TEXT NOT NULL, + resource TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0), + PRIMARY KEY (channel_id, resource) +); + +CREATE TABLE panel_global_resource_revisions ( + resource TEXT PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0) +); + +INSERT INTO panel_global_resource_revisions (resource, revision) VALUES ('channels', 1), ('weather.cache', 1), ('api.cache', 1); + +CREATE TABLE panel_global_resource_publications ( + resource TEXT PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0) +); + +INSERT INTO panel_global_resource_publications (resource, revision) VALUES ('channels', 1), ('weather.cache', 1), ('api.cache', 1); + +-- This is the dependency map for every D1 table that feeds a dashboard query. +-- Module resources use the stable `module::` naming convention. +CREATE TABLE panel_resource_dependencies ( + source_table TEXT NOT NULL, + resource TEXT NOT NULL, + PRIMARY KEY (source_table, resource) +); + +INSERT INTO panel_resource_dependencies (source_table, resource) VALUES + ('channels', 'channel.overview'), ('channels', 'channel.settings'), ('channels', 'channel.system'), + ('channels', 'channels'), + ('channel_members', 'channel.members'), ('channel_members', 'channel.overview'), ('channel_members', 'channels'), + ('channel_modules', 'channel.overview'), ('channel_modules', 'channel.modules'), ('channel_modules', 'channel.variables'), ('channel_modules', 'channels'), ('channel_modules', 'module:*:settings'), + ('bot_identity', 'channel.overview'), ('bot_identity', 'channel.system'), ('bot_identity', 'channels'), + ('bot_identity_status', 'channel.overview'), ('bot_identity_status', 'channel.system'), ('bot_identity_status', 'channels'), + ('bot_channel_status', 'channel.overview'), ('bot_channel_status', 'channel.system'), ('bot_channel_status', 'channels'), + ('twitch_login_identity', 'channel.overview'), ('twitch_login_identity', 'channel.system'), ('twitch_login_identity', 'channels'), + ('channel_controls', 'channel.overview'), ('channel_controls', 'channels'), + ('channel_stream_state', 'channel.overview'), ('channel_stream_state', 'module:belabox:live'), ('channel_stream_state', 'channels'), + ('twitch_connections', 'channel.overview'), ('twitch_connections', 'channel.system'), ('twitch_connections', 'channels'), + ('eventsub_subscriptions', 'channel.overview'), ('eventsub_subscriptions', 'channel.system'), ('eventsub_subscriptions', 'channels'), + ('eventsub_revocations', 'channel.system'), + ('audit_log', 'channel.audit'), + ('event_log', 'channel.events'), ('event_log', 'module:*:data'), + ('channel_variables', 'channel.variables'), ('channel_variables', 'channel.overlays'), ('channel_variables', 'channel.library'), + ('overlays', 'channel.overlays'), ('overlays', 'channel.variables'), ('overlays', 'channel.library'), + ('overlay_elements', 'channel.overlays'), ('overlay_elements', 'channel.variables'), ('overlay_elements', 'channel.library'), + ('overlay_tokens', 'channel.overlays'), ('overlay_tokens', 'channel.overlay-accesses'), + ('text_commands', 'module:text_commands:commands'), ('text_commands', 'channel.variables'), ('text_commands', 'channel.library'), + ('text_command_aliases', 'module:text_commands:commands'), + ('text_library_settings', 'module:text_library:library'), ('text_library_settings', 'channel.library'), + ('text_library_categories', 'module:text_library:library'), ('text_library_categories', 'channel.library'), + ('text_blocks', 'module:text_library:library'), ('text_blocks', 'channel.library'), + ('text_block_variants', 'module:text_library:library'), ('text_block_variants', 'channel.library'), + ('timers', 'module:timers:panel'), ('timers', 'channel.library'), + ('faq_entries', 'module:faq:panel'), ('faq_entries', 'channel.library'), + ('chat_votes', 'module:chat_voting:panel'), + ('chat_vote_term_approvals', 'module:chat_voting:panel'), + ('votekicks', 'module:votekick:panel'), + ('module_secrets', 'module:belabox:settings'), ('module_secrets', 'module:belabox:live'), + ('belabox_status', 'module:belabox:live'), ('belabox_minutes', 'module:belabox:live'), + ('belabox_streams', 'module:belabox:live'), + ('ads_countdown_state', 'module:ads:schedule'), + ('api_sources', 'module:api_source:sources'), ('api_sources', 'channel.library'), + ('api_source_cache', 'api.cache'), + ('weather_cache', 'weather.cache'), + ('weather_settings', 'module:weather:provider-settings'), ('weather_settings', 'channel.library'), + ('sun_settings', 'module:sun:error-texts'), + ('moon_settings', 'module:moon:unavailable-texts'); + +CREATE INDEX panel_resource_dependencies_source_idx ON panel_resource_dependencies(source_table); + +-- A trigger per operation makes revision advancement part of the same D1 +-- transaction as the row write, including batches that later return an error. +CREATE TRIGGER panel_rev_channels_insert AFTER INSERT ON channels BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channels' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_channels_update AFTER UPDATE ON channels BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channels' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_channels_delete AFTER DELETE ON channels BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; + DELETE FROM panel_resource_revisions WHERE channel_id = OLD.channel_id; +END; + +-- Channel-scoped tables share a small generated trigger body. These writers +-- are the commit seam for all route, EventSub, command, alarm and maintenance +-- code that persists dashboard-visible D1 rows. +CREATE TRIGGER panel_rev_members_insert AFTER INSERT ON channel_members BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_members' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_members_update AFTER UPDATE ON channel_members BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_members' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_members_delete AFTER DELETE ON channel_members BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_members' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +CREATE TRIGGER panel_rev_modules_insert AFTER INSERT ON channel_modules BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource NOT IN ('channels', 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, replace(resource, '*', NEW.module_id), 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource = 'module:*:settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_modules_update AFTER UPDATE ON channel_modules BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource NOT IN ('channels', 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, replace(resource, '*', NEW.module_id), 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource = 'module:*:settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_modules_delete AFTER DELETE ON channel_modules BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource NOT IN ('channels', 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, replace(resource, '*', OLD.module_id), 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource = 'module:*:settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +CREATE TRIGGER panel_rev_bot_status_insert AFTER INSERT ON bot_channel_status BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'bot_channel_status' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_status_update AFTER UPDATE ON bot_channel_status BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'bot_channel_status' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_status_delete AFTER DELETE ON bot_channel_status BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'bot_channel_status' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +CREATE TRIGGER panel_rev_controls_insert AFTER INSERT ON channel_controls BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_controls' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_controls_update AFTER UPDATE ON channel_controls BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_controls' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_controls_delete AFTER DELETE ON channel_controls BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_controls' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +CREATE TRIGGER panel_rev_stream_insert AFTER INSERT ON channel_stream_state BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_stream_state' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_stream_update AFTER UPDATE ON channel_stream_state BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_stream_state' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_stream_delete AFTER DELETE ON channel_stream_state BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_stream_state' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +CREATE TRIGGER panel_rev_connection_insert AFTER INSERT ON twitch_connections BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'twitch_connections' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_connection_update AFTER UPDATE ON twitch_connections BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'twitch_connections' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_connection_delete AFTER DELETE ON twitch_connections BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'twitch_connections' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +CREATE TRIGGER panel_rev_subscriptions_insert AFTER INSERT ON eventsub_subscriptions BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'eventsub_subscriptions' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_subscriptions_update AFTER UPDATE ON eventsub_subscriptions BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'eventsub_subscriptions' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_subscriptions_delete AFTER DELETE ON eventsub_subscriptions BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'eventsub_subscriptions' AND resource <> 'channels' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +CREATE TRIGGER panel_rev_revocations_insert AFTER INSERT ON eventsub_revocations BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies JOIN channels ON channels.channel_id = NEW.channel_id WHERE source_table = 'eventsub_revocations' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_revocations_update AFTER UPDATE ON eventsub_revocations BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies JOIN channels ON channels.channel_id = NEW.channel_id WHERE source_table = 'eventsub_revocations' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_revocations_delete AFTER DELETE ON eventsub_revocations BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies JOIN channels ON channels.channel_id = OLD.channel_id WHERE source_table = 'eventsub_revocations' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +CREATE TRIGGER panel_rev_audit_insert AFTER INSERT ON audit_log BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'audit_log' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_audit_update AFTER UPDATE ON audit_log BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'audit_log' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_audit_delete AFTER DELETE ON audit_log BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'audit_log' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +CREATE TRIGGER panel_rev_events_insert AFTER INSERT ON event_log BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'event_log' AND resource NOT LIKE 'module:%*%' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, replace(resource, '*', NEW.module_id), 1 FROM panel_resource_dependencies WHERE source_table = 'event_log' AND resource = 'module:*:data' AND NEW.module_id IS NOT NULL ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, 'module:ads:schedule', 1 WHERE NEW.module_id = 'ads' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_events_update AFTER UPDATE ON event_log BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'event_log' AND resource NOT LIKE 'module:%*%' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, replace(resource, '*', NEW.module_id), 1 FROM panel_resource_dependencies WHERE source_table = 'event_log' AND resource = 'module:*:data' AND NEW.module_id IS NOT NULL ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, 'module:ads:schedule', 1 WHERE NEW.module_id = 'ads' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_events_delete AFTER DELETE ON event_log BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'event_log' AND resource NOT LIKE 'module:%*%' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, replace(resource, '*', OLD.module_id), 1 FROM panel_resource_dependencies WHERE source_table = 'event_log' AND resource = 'module:*:data' AND OLD.module_id IS NOT NULL ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, 'module:ads:schedule', 1 WHERE OLD.module_id = 'ads' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +CREATE TRIGGER panel_rev_variables_insert AFTER INSERT ON channel_variables BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_variables' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_variables_update AFTER UPDATE ON channel_variables BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_variables' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_variables_delete AFTER DELETE ON channel_variables BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_variables' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +CREATE TRIGGER panel_rev_overlays_insert AFTER INSERT ON overlays BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'overlays' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_overlays_update AFTER UPDATE ON overlays BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'overlays' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_overlays_delete AFTER DELETE ON overlays BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'overlays' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_overlay_elements_insert AFTER INSERT ON overlay_elements BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'overlay_elements' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_overlay_elements_update AFTER UPDATE ON overlay_elements BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'overlay_elements' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_overlay_elements_delete AFTER DELETE ON overlay_elements BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'overlay_elements' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_overlay_tokens_insert AFTER INSERT ON overlay_tokens BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'overlay_tokens' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_overlay_tokens_update AFTER UPDATE ON overlay_tokens BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'overlay_tokens' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_overlay_tokens_delete AFTER DELETE ON overlay_tokens BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'overlay_tokens' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +-- Module tables use the same trigger pattern. The dependency rows above are +-- deliberately kept separate from route handlers so maintenance, alarms and +-- compensation writes receive the same coverage. +CREATE TRIGGER panel_rev_text_commands_insert AFTER INSERT ON text_commands BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_commands' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_text_commands_update AFTER UPDATE ON text_commands BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_commands' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_text_commands_delete AFTER DELETE ON text_commands BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_commands' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_command_aliases_insert AFTER INSERT ON text_command_aliases BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_command_aliases' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_command_aliases_update AFTER UPDATE ON text_command_aliases BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_command_aliases' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_command_aliases_delete AFTER DELETE ON text_command_aliases BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_command_aliases' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +CREATE TRIGGER panel_rev_library_settings_insert AFTER INSERT ON text_library_settings BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_library_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_library_settings_update AFTER UPDATE ON text_library_settings BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_library_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_library_settings_delete AFTER DELETE ON text_library_settings BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_library_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_library_categories_insert AFTER INSERT ON text_library_categories BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_library_categories' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_library_categories_update AFTER UPDATE ON text_library_categories BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_library_categories' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_library_categories_delete AFTER DELETE ON text_library_categories BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_library_categories' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_text_blocks_insert AFTER INSERT ON text_blocks BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_blocks' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_text_blocks_update AFTER UPDATE ON text_blocks BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_blocks' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_text_blocks_delete AFTER DELETE ON text_blocks BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_blocks' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_text_block_variants_insert AFTER INSERT ON text_block_variants BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_block_variants' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_text_block_variants_update AFTER UPDATE ON text_block_variants BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_block_variants' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_text_block_variants_delete AFTER DELETE ON text_block_variants BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'text_block_variants' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +CREATE TRIGGER panel_rev_timers_insert AFTER INSERT ON timers BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'timers' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_timers_update AFTER UPDATE ON timers BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'timers' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_timers_delete AFTER DELETE ON timers BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'timers' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_faq_insert AFTER INSERT ON faq_entries BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'faq_entries' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_faq_update AFTER UPDATE ON faq_entries BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'faq_entries' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_faq_delete AFTER DELETE ON faq_entries BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'faq_entries' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +CREATE TRIGGER panel_rev_chat_votes_insert AFTER INSERT ON chat_votes BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'chat_votes' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_chat_votes_update AFTER UPDATE ON chat_votes BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'chat_votes' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_chat_votes_delete AFTER DELETE ON chat_votes BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'chat_votes' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_chat_vote_approvals_insert AFTER INSERT ON chat_vote_term_approvals BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'chat_vote_term_approvals' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_chat_vote_approvals_update AFTER UPDATE ON chat_vote_term_approvals BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'chat_vote_term_approvals' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_chat_vote_approvals_delete AFTER DELETE ON chat_vote_term_approvals BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'chat_vote_term_approvals' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_votekicks_insert AFTER INSERT ON votekicks BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'votekicks' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_votekicks_update AFTER UPDATE ON votekicks BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'votekicks' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_votekicks_delete AFTER DELETE ON votekicks BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'votekicks' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +CREATE TRIGGER panel_rev_module_secrets_insert AFTER INSERT ON module_secrets BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'module_secrets' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_module_secrets_update AFTER UPDATE ON module_secrets BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'module_secrets' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_module_secrets_delete AFTER DELETE ON module_secrets BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'module_secrets' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_belabox_status_insert AFTER INSERT ON belabox_status BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'belabox_status' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_belabox_status_update AFTER UPDATE ON belabox_status BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'belabox_status' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_belabox_status_delete AFTER DELETE ON belabox_status BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'belabox_status' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_belabox_minutes_insert AFTER INSERT ON belabox_minutes BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'belabox_minutes' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_belabox_minutes_update AFTER UPDATE ON belabox_minutes BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'belabox_minutes' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_belabox_minutes_delete AFTER DELETE ON belabox_minutes BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'belabox_minutes' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_belabox_streams_insert AFTER INSERT ON belabox_streams BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'belabox_streams' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_belabox_streams_update AFTER UPDATE ON belabox_streams BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'belabox_streams' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_belabox_streams_delete AFTER DELETE ON belabox_streams BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'belabox_streams' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_api_sources_insert AFTER INSERT ON api_sources BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'api_sources' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_api_sources_update AFTER UPDATE ON api_sources BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'api_sources' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_api_sources_delete AFTER DELETE ON api_sources BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'api_sources' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_api_source_cache_insert AFTER INSERT ON api_source_cache BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'api.cache'; +END; +CREATE TRIGGER panel_rev_api_source_cache_update AFTER UPDATE ON api_source_cache BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'api.cache'; +END; +CREATE TRIGGER panel_rev_api_source_cache_delete AFTER DELETE ON api_source_cache BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'api.cache'; +END; +CREATE TRIGGER panel_rev_weather_settings_insert AFTER INSERT ON weather_settings BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'weather_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_weather_settings_update AFTER UPDATE ON weather_settings BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'weather_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_weather_settings_delete AFTER DELETE ON weather_settings BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'weather_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_sun_settings_insert AFTER INSERT ON sun_settings BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'sun_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_sun_settings_update AFTER UPDATE ON sun_settings BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'sun_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_sun_settings_delete AFTER DELETE ON sun_settings BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'sun_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_moon_settings_insert AFTER INSERT ON moon_settings BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'moon_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_moon_settings_update AFTER UPDATE ON moon_settings BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'moon_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_moon_settings_delete AFTER DELETE ON moon_settings BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'moon_settings' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +-- Global bot identity and weather-cache commits fan out through every channel. +CREATE TRIGGER panel_rev_bot_identity_insert AFTER INSERT ON bot_identity BEGIN + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.overview', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.system', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_identity_update AFTER UPDATE ON bot_identity BEGIN + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.overview', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.system', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_identity_delete AFTER DELETE ON bot_identity BEGIN + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.overview', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.system', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_identity_status_insert AFTER INSERT ON bot_identity_status BEGIN + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.overview', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.system', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_identity_status_update AFTER UPDATE ON bot_identity_status BEGIN + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.overview', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.system', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_identity_status_delete AFTER DELETE ON bot_identity_status BEGIN + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.overview', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.system', 1 FROM channels WHERE 1 ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +CREATE TRIGGER panel_rev_login_identity_insert AFTER INSERT ON twitch_login_identity BEGIN + INSERT INTO panel_resource_revisions SELECT DISTINCT channels.channel_id, dependency.resource, 1 FROM channels JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'twitch_login_identity' LEFT JOIN channel_members ON channel_members.channel_id = channels.channel_id WHERE channels.channel_id = NEW.user_id OR channel_members.user_id = NEW.user_id ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_login_identity_update AFTER UPDATE ON twitch_login_identity BEGIN + INSERT INTO panel_resource_revisions SELECT DISTINCT channels.channel_id, dependency.resource, 1 FROM channels JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'twitch_login_identity' LEFT JOIN channel_members ON channel_members.channel_id = channels.channel_id WHERE channels.channel_id = NEW.user_id OR channel_members.user_id = NEW.user_id ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_login_identity_delete AFTER DELETE ON twitch_login_identity BEGIN + INSERT INTO panel_resource_revisions SELECT DISTINCT channels.channel_id, dependency.resource, 1 FROM channels JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'twitch_login_identity' LEFT JOIN channel_members ON channel_members.channel_id = channels.channel_id WHERE channels.channel_id = OLD.user_id OR channel_members.user_id = OLD.user_id ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +CREATE TRIGGER panel_rev_weather_cache_insert AFTER INSERT ON weather_cache BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'weather.cache'; +END; +CREATE TRIGGER panel_rev_weather_cache_update AFTER UPDATE ON weather_cache BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'weather.cache'; +END; +CREATE TRIGGER panel_rev_weather_cache_delete AFTER DELETE ON weather_cache BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'weather.cache'; +END; + +CREATE TRIGGER panel_rev_ads_countdown_insert AFTER INSERT ON ads_countdown_state BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'ads_countdown_state' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_ads_countdown_update AFTER UPDATE ON ads_countdown_state BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'ads_countdown_state' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_ads_countdown_delete AFTER DELETE ON ads_countdown_state BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'ads_countdown_state' ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; diff --git a/scripts/d1-baseline.mjs b/scripts/d1-baseline.mjs index 94f30e84..89f8b55b 100644 --- a/scripts/d1-baseline.mjs +++ b/scripts/d1-baseline.mjs @@ -29,7 +29,8 @@ export const generateBaseline = () => { } } const objects = database.prepare( - "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY tbl_name, CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name", + `SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL + ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, tbl_name, name`, ).all(); return objects.map((object) => statementWithTerminator(object.sql)).join("\n\n"); } finally { diff --git a/src/dashboard/ChannelVariablesPage.tsx b/src/dashboard/ChannelVariablesPage.tsx index b2458488..1dec3ea1 100644 --- a/src/dashboard/ChannelVariablesPage.tsx +++ b/src/dashboard/ChannelVariablesPage.tsx @@ -6,7 +6,6 @@ import { deleteChannelVariable, fetchOverlay, fetchOverlays, - fetchOverlayTokens, PanelApiError, saveOverlay, updateChannelVariable, @@ -20,7 +19,7 @@ import { Button, ConfirmDialog, EmptyCellValue, Field, Icon, InspectorActions, I import { CHANNEL_VARIABLE_MAXIMUM_COUNT, CHANNEL_VARIABLE_MAXIMUM_VALUE, CHANNEL_VARIABLE_MINIMUM_VALUE } from "../contracts/values"; import { dashboardDataKeys } from "./data/keys"; import { useDashboardQueryClient } from "./data"; -import { useChannelVariablesQuery, useOverlaysQuery } from "./data/lists"; +import { useChannelVariablesQuery, useLegacyOverlayTokensQuery, useOverlaysQuery } from "./data/lists"; interface ChannelVariablesPageProperties { channelId: string; @@ -34,6 +33,8 @@ interface ChannelVariablesPageProperties { } const normalizedVariableName = (value: string): string => value.trim().toLowerCase(); +const hasTokens = (page: unknown): boolean => typeof page === "object" && page !== null && + "tokens" in page && Array.isArray(page.tokens) && page.tokens.length > 0; const variableNamePattern = /^[a-z][a-z0-9_]{0,31}$/u; const EMPTY_VARIABLES: readonly PanelChannelVariable[] = []; const saveableOverlayElement = (element: PanelOverlayElement): Omit => ({ @@ -54,6 +55,7 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o const language = dashboardLanguage(); const labels = channelVariablesTexts(language); const variablesQuery = useChannelVariablesQuery(channelId); + const legacyTokensQuery = useLegacyOverlayTokensQuery(channelId); const queryClient = useDashboardQueryClient(); const variables = variablesQuery.data?.variables ?? EMPTY_VARIABLES; const maximum = variablesQuery.data?.maximum ?? CHANNEL_VARIABLE_MAXIMUM_COUNT; @@ -76,7 +78,6 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o const overlaysQuery = useOverlaysQuery(channelId, useOverlayOpen); const overlayOptions = (overlaysQuery.data?.overlays ?? []).map(({ id, name, elementCount }) => ({ id, name, elementCount })); const refetchVariables = variablesQuery.refetch; - const [legacyLinkStatus, setLegacyLinkStatus] = useState<{ channelId: string; hasLinks: boolean } | null>(null); const [overlaySelection, setOverlaySelection] = useState(""); const [creatingOverlay, setCreatingOverlay] = useState(false); const [newOverlayName, setNewOverlayName] = useState(""); @@ -120,23 +121,13 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o notify({ tone: "error", message: variablesQuery.error instanceof PanelApiError ? apiErrorText(variablesQuery.error.code, loadErrorText) : loadErrorText }); }, [variablesQuery.error, variablesQuery.isError]); - useEffect(() => { - let active = true; - void fetchOverlayTokens(channelId).then(({ tokens }) => { - if (active) setLegacyLinkStatus({ channelId, hasLinks: tokens.length > 0 }); - }).catch(() => { - if (active) setLegacyLinkStatus({ channelId, hasLinks: false }); - }); - return () => { active = false; }; - }, [channelId]); - const selected = useMemo(() => variables.find((variable) => variable.name === selectedName) ?? null, [selectedName, variables]); useLayoutEffect(() => { if (editingValue) document.getElementById("channel-variable-inline-value")?.focus(); else if (wasEditingValue.current) editValueButtonRef.current?.focus(); wasEditingValue.current = editingValue; }, [editingValue]); - const hasLegacyLinks = legacyLinkStatus?.channelId === channelId && legacyLinkStatus.hasLinks; + const hasLegacyLinks = hasTokens(legacyTokensQuery.data?.pages[0]); const selectedUsages = selected?.usages ?? []; const overlayUsageLabel = (usage: PanelChannelVariable["usages"][number]): string => usage.elementLabel === undefined ? usage.itemName : `${usage.itemName} → ${usage.elementLabel}`; diff --git a/src/dashboard/OverlaysPage.tsx b/src/dashboard/OverlaysPage.tsx index 10b034d6..ac5af8f5 100644 --- a/src/dashboard/OverlaysPage.tsx +++ b/src/dashboard/OverlaysPage.tsx @@ -7,8 +7,6 @@ import streamElementsJs from "../../docs/embedding/streamelements/widget.js?raw" import { createOverlay, deleteOverlay, - fetchOverlayAccesses, - fetchOverlayTokens, importLegacyOverlay, issueOverlayAccess, removeOverlayAccess, @@ -25,7 +23,7 @@ import { } from "./api"; import { apiErrorText, dashboardCommonTexts, dashboardLanguage, formatTimestamp, overlaysTexts } from "./locale"; import { ActionMenu, Button, ConfirmDialog, EmptyCellValue, Field, FormDialog, InspectorActions, InspectorSection, Led, ListDetail, ListPaginationFooter, ListToolbar, LoadState, notify, NumberField, PageHeader, Select, Skeleton, SubInspector } from "./ui"; -import { useOverlayQuery, useOverlaysQuery } from "./data/lists"; +import { useLegacyOverlayTokensQuery, useOverlayAccessesQuery, useOverlayQuery, useOverlaysQuery } from "./data/lists"; import { dashboardDataKeys } from "./data/keys"; import { useDashboardQueryError } from "./data"; @@ -245,11 +243,9 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const [selectedId, setSelectedId] = useState(initialSelection ?? null); const selectedIdRef = useRef(initialSelection ?? null); const selectedOverlayQuery = useOverlayQuery(channelId, selectedId ?? "", selectedId !== null); + const selectedOverlayAccessesQuery = useOverlayAccessesQuery(channelId, selectedId ?? "", selectedId !== null); + const legacyTokensQuery = useLegacyOverlayTokensQuery(channelId); const selectedOverlayError = useDashboardQueryError(dashboardDataKeys.overlay(channelId, selectedId ?? "")); - const [selectedOverlayData, setSelectedOverlayData] = useState<{ - id: string; - accesses: readonly PanelOverlayAccess[]; - } | null>(null); const [creating, setCreating] = useState(false); const [draftName, setDraftName] = useState(blankOverlayName); const [draftSize, setDraftSize] = useState("1920x1080"); @@ -266,10 +262,6 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const [setupCopiedSnippet, setSetupCopiedSnippet] = useState(null); const [revokeTarget, setRevokeTarget] = useState(null); const [replaceTarget, setReplaceTarget] = useState(null); - const [legacyTokens, setLegacyTokens] = useState([]); - const [legacyLoading, setLegacyLoading] = useState(true); - const [legacyLoadFailed, setLegacyLoadFailed] = useState(false); - const [legacyNextOffset, setLegacyNextOffset] = useState(null); const [legacyError, setLegacyError] = useState(null); const [legacyRevokeTarget, setLegacyRevokeTarget] = useState(null); const [legacyImportOpen, setLegacyImportOpen] = useState(false); @@ -278,7 +270,6 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const [confirmDelete, setConfirmDelete] = useState(false); const [now, setNow] = useState(() => Date.now()); const secretVersion = useRef(0); - const legacyRequestVersion = useRef(0); const pageActiveRef = useRef(true); const permissionRef = useRef(canManage); const channelIdRef = useRef(channelId); @@ -313,7 +304,6 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi if (accessContextChanged) invalidateSecret(); if (channelIdRef.current !== channelId) { changeSelection(null); - setSelectedOverlayData(null); } if (accessContextChanged) { setLegacyImportOpen(false); @@ -327,7 +317,8 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const refetchOverlays = overlaysQuery.refetch; const selectedOverlay = selectedOverlayQuery.data?.overlay ?? null; const selectedOverlayLoadFailed = selectedOverlayError !== null; - const accesses = selectedOverlayData?.id === selectedId ? selectedOverlayData.accesses : []; + const accesses = selectedOverlayAccessesQuery.data?.accesses ?? []; + const legacyTokens = legacyTokensQuery.data?.pages.flatMap((page) => page.tokens) ?? []; const revokedAccesses = accesses.filter((access) => access.revokedAt !== null); const setupAccess = accesses.find((access) => access.tokenId === setupAccessId) ?? null; @@ -345,31 +336,11 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi } }, [changeSelection, initialSelection, labels.loadError, refetchOverlays]); - const loadLegacyTokens = useCallback(async (offset = 0, append = false): Promise => { - const version = ++legacyRequestVersion.current; - if (!append) setLegacyLoading(true); - try { - const result = await fetchOverlayTokens(channelId, offset); - if (!pageActiveRef.current || legacyRequestVersion.current !== version || channelIdRef.current !== channelId) return; - setLegacyTokens((current) => append ? [...current, ...result.tokens] : result.tokens); - setLegacyNextOffset(result.nextOffset); - setLegacyLoadFailed(false); - setLegacyError(null); - } catch (caught) { - if (!pageActiveRef.current || legacyRequestVersion.current !== version || channelIdRef.current !== channelId) return; - setLegacyLoadFailed(true); - notify({ tone: "error", message: caught instanceof PanelApiError ? apiErrorText(caught.code, labels.loadError) : labels.loadError }); - } finally { - if (!append && pageActiveRef.current && legacyRequestVersion.current === version) setLegacyLoading(false); - } - }, [channelId, labels.loadError]); - useEffect(() => { let active = true; void Promise.resolve().then(() => load(() => active)); return () => { active = false; }; }, [load]); - useEffect(() => { void Promise.resolve().then(() => loadLegacyTokens()); }, [loadLegacyTokens]); useEffect(() => { pageActiveRef.current = true; return () => { @@ -381,36 +352,18 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi return () => { window.clearInterval(timer); }; }, []); - useEffect(() => { - if (selectedId === null) return; - let active = true; - void fetchOverlayAccesses(channelId, selectedId).then((accessResult) => { - if (!active) return; - setSelectedOverlayData({ id: selectedId, accesses: accessResult.accesses }); - setError(null); - }).catch((caught: unknown) => { - if (!active) return; - notify({ tone: "error", message: caught instanceof PanelApiError ? apiErrorText(caught.code, labels.loadError) : labels.loadError }); - }); - return () => { active = false; }; - }, [canManage, channelId, labels.loadError, selectedId]); - const refreshSelected = async (): Promise => { const overlayId = selectedIdRef.current; if (overlayId === null) return; - const [, accessResult] = await Promise.all([ + await Promise.all([ selectedOverlayQuery.refetch({ throwOnError: true }), - fetchOverlayAccesses(channelId, overlayId), + selectedOverlayAccessesQuery.refetch({ throwOnError: true }), ]); - if (channelIdRef.current === channelId && selectedIdRef.current === overlayId) { - setSelectedOverlayData({ id: overlayId, accesses: accessResult.accesses }); - } }; const beginCreate = (): void => { setCreating(true); changeSelection(null); - setSelectedOverlayData(null); setDraftName(""); setDraftSize("1920x1080"); setDraftWidth(1920); @@ -437,7 +390,6 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const closeInspector = (): void => { setCreating(false); changeSelection(null); - setSelectedOverlayData(null); setError(null); }; @@ -449,7 +401,6 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const result = await deleteOverlay(channelId, selectedOverlay.id, selectedOverlay.revision); setConfirmDelete(false); changeSelection(null); - setSelectedOverlayData(null); await load(); if (result?.closingPending) notify({ tone: "info", message: labels.revokedPending }); } catch (caught) { @@ -658,7 +609,7 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi notify(result.closingPending ? { tone: "info", message: labels.revokedPending } : { tone: "success", message: labels.revoked }); - await loadLegacyTokens(); + await legacyTokensQuery.refetch({ throwOnError: true }); } catch (caught) { setLegacyError(caught instanceof PanelApiError ? apiErrorText(caught.code, labels.actionError) : labels.actionError); } finally { @@ -680,7 +631,7 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi setLegacyImportOpen(false); setLegacyImportLink(""); changeSelection(result.overlay.id); - await Promise.all([load(), loadLegacyTokens()]); + await Promise.all([load(), legacyTokensQuery.refetch({ throwOnError: true })]); notify(result.closingPending ? { tone: "info", message: labels.legacyImportClosingPending } : { tone: "success", message: labels.legacyImportSuccess(result.overlay.name) }); @@ -737,8 +688,8 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi {visibleOverlays.map((overlay) => { setCreating(false); setSelectedOverlayData(null); changeSelection(overlay.id); }} - onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setCreating(false); setSelectedOverlayData(null); changeSelection(overlay.id); } }}> + onClick={() => { setCreating(false); changeSelection(overlay.id); }} + onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setCreating(false); changeSelection(overlay.id); } }}> @@ -760,7 +711,7 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi

{labels.legacyDescription}

{manageReason === undefined ? null :

{manageReason}

}
} empty={

{labels.legacyEmpty}

} @@ -779,9 +730,9 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi ]} /> )}
- {legacyTokens.length === 0 && legacyNextOffset === null ? null : - {legacyNextOffset === null ? null : } + {legacyTokens.length === 0 && !legacyTokensQuery.hasNextPage ? null : + {!legacyTokensQuery.hasNextPage ? null : } } diff --git a/src/dashboard/api.ts b/src/dashboard/api.ts index 7ffaa29f..07416a4c 100644 --- a/src/dashboard/api.ts +++ b/src/dashboard/api.ts @@ -157,8 +157,11 @@ export interface PanelOverlayAccess { const overlayTokensPath = (channelId: string, tokenId?: string): string => `${channelPath(channelId, "overlay-tokens")}${tokenId === undefined ? "" : `/${encodeURIComponent(tokenId)}/revoke`}`; -export const fetchOverlayTokens = (channelId: string, offset = 0): Promise => - requestJson(`${overlayTokensPath(channelId)}${offset === 0 ? "" : `?offset=${String(offset)}`}`); +export const fetchOverlayTokens = (channelId: string, offset = 0, signal?: AbortSignal): Promise => + requestJson( + `${overlayTokensPath(channelId)}${offset === 0 ? "" : `?offset=${String(offset)}`}`, + requestOptions(signal), + ); export const revokeOverlayToken = async ( channelId: string, @@ -225,11 +228,11 @@ export const saveOverlay = ( export const deleteOverlay = (channelId: string, overlayId: string, baseRevision: number): Promise<{ closingPending?: boolean } | undefined> => requestMutation(overlaysPath(channelId, overlayId), "DELETE", { baseRevision }); -export const fetchOverlayAccesses = (channelId: string, overlayId: string): Promise<{ +export const fetchOverlayAccesses = (channelId: string, overlayId: string, signal?: AbortSignal): Promise<{ accesses: readonly PanelOverlayAccess[]; activeCount: number; maximum: number; -}> => requestJson(overlayAccessesPath(channelId, overlayId)); +}> => requestJson(overlayAccessesPath(channelId, overlayId), requestOptions(signal)); export interface PanelIssuedOverlayAccess { tokenId: string; diff --git a/src/dashboard/data/keys.ts b/src/dashboard/data/keys.ts index 9a90ed7a..3c6aa081 100644 --- a/src/dashboard/data/keys.ts +++ b/src/dashboard/data/keys.ts @@ -36,6 +36,8 @@ export const dashboardDataKeys = { variables: (channelId: string) => queryKeys.channel(channelId, "variables"), overlays: (channelId: string) => queryKeys.channel(channelId, "overlays"), overlay: (channelId: string, overlayId: string) => queryKeys.channel(channelId, "overlays", { overlayId }), + overlayAccesses: (channelId: string, overlayId: string) => queryKeys.channel(channelId, "overlay-accesses", { overlayId }), + legacyOverlayTokens: (channelId: string) => queryKeys.channel(channelId, "legacy-overlay-tokens"), audit: (channelId: string, filters: PanelAuditFilters) => queryKeys.channel(channelId, "audit-log", { person: filters.person, area: filters.area, diff --git a/src/dashboard/data/lists.ts b/src/dashboard/data/lists.ts index a168791a..e9266c61 100644 --- a/src/dashboard/data/lists.ts +++ b/src/dashboard/data/lists.ts @@ -1,6 +1,6 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { fetchAuditLog, fetchChannelVariables, fetchEvents, fetchMembers, fetchOverlay, fetchOverlays, fetchSystemOverview } from "../api"; +import { fetchAuditLog, fetchChannelVariables, fetchEvents, fetchMembers, fetchOverlay, fetchOverlayAccesses, fetchOverlayTokens, fetchOverlays, fetchSystemOverview } from "../api"; import type { PanelAuditFilters, PanelEventFilters } from "../../panel-contract"; import { dashboardDataKeys } from "./keys"; @@ -39,11 +39,11 @@ export const useEventsQuery = (channelId: string, filters: PanelEventFilters) => }); }; -export const useSystemQuery = (channelId: string, enabled: boolean, refetchInterval: number | false = false) => useQuery({ +export const useSystemQuery = (channelId: string, enabled: boolean, offlineRefetchInterval: number | false) => useQuery({ queryKey: dashboardDataKeys.system(channelId), queryFn: ({ signal }) => fetchSystemOverview(channelId, signal), enabled, - refetchInterval, + refetchInterval: offlineRefetchInterval, refetchOnWindowFocus: false, placeholderData: (previousData, previousQuery) => previousQuery?.queryKey[1] === channelId ? previousData : undefined, @@ -74,3 +74,21 @@ export const useOverlayQuery = (channelId: string, overlayId: string, enabled = refetchOnWindowFocus: false, placeholderData: () => undefined, }); + +export const useOverlayAccessesQuery = (channelId: string, overlayId: string, enabled = true) => useQuery({ + queryKey: dashboardDataKeys.overlayAccesses(channelId, overlayId), + queryFn: ({ signal }) => fetchOverlayAccesses(channelId, overlayId, signal), + enabled, + refetchOnWindowFocus: false, + placeholderData: () => undefined, +}); + +export const useLegacyOverlayTokensQuery = (channelId: string) => useInfiniteQuery({ + queryKey: dashboardDataKeys.legacyOverlayTokens(channelId), + initialPageParam: 0, + queryFn: ({ pageParam, signal }) => fetchOverlayTokens(channelId, pageParam, signal), + getNextPageParam: (lastPage) => lastPage.nextOffset ?? undefined, + staleTime: 0, + refetchOnWindowFocus: false, + placeholderData: () => undefined, +}); diff --git a/src/dashboard/data/realtime.ts b/src/dashboard/data/realtime.ts index 660370c9..0ff8aa58 100644 --- a/src/dashboard/data/realtime.ts +++ b/src/dashboard/data/realtime.ts @@ -4,6 +4,7 @@ import { hashKey, type QueryClient } from "@tanstack/react-query"; import type { RealtimeMessage } from "../../realtime-contract"; import { dashboardDataKeys, queryKeys } from "./keys"; import { refreshQuery } from "./refresh"; +import { dispatchDashboardAuthenticationRequired } from "./events"; const REALTIME_REFRESH_INTERVAL_MS = 1_000; @@ -14,6 +15,14 @@ interface ScheduledRealtimeRefresh { } const scheduledRefreshes = new WeakMap>(); +const panelRevisionSnapshots = new WeakMap>>>(); +interface PanelRevisionFetch { + dirty: boolean; + promise: Promise; +} + +const panelRevisionFetches = new WeakMap>(); +const revisionFetchWasDirtied = (state: PanelRevisionFetch): boolean => state.dirty; export type DashboardRealtimeStatus = "connecting" | "connected" | "reconnecting" | "offline" | "renew"; @@ -46,30 +55,182 @@ export const useDashboardRealtimeStatus = (channelId: string): DashboardRealtime return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); }; -const invalidate = (queryClient: QueryClient, queryKey: readonly unknown[]): void => { +const invalidate = ( + queryClient: QueryClient, + queryKey: readonly unknown[], + scheduledQueryKeys?: Set, +): void => { const matchingQueries = queryClient.getQueryCache().findAll({ queryKey }); void queryClient.invalidateQueries({ queryKey, refetchType: "none" }); for (const query of matchingQueries) { - if (query.isActive()) scheduleRealtimeRefresh(queryClient, query.queryKey); + if (!query.isActive()) continue; + const queryId = hashKey(query.queryKey); + if (scheduledQueryKeys?.has(queryId)) continue; + scheduledQueryKeys?.add(queryId); + scheduleRealtimeRefresh(queryClient, query.queryKey); } }; -const invalidateBelaboxLiveQueries = (queryClient: QueryClient, channelId: string): void => { +const invalidateBelaboxLiveQueries = (queryClient: QueryClient, channelId: string, scheduledQueryKeys: Set): void => { const moduleQueries = queryClient.getQueryCache().findAll({ queryKey: dashboardDataKeys.module(channelId, "belabox") }); for (const query of moduleQueries) { const part = query.queryKey[4]; if (part === "status" || part === "streams" || part === "history-live" || (typeof part === "string" && part.startsWith("history-stream-"))) { - invalidate(queryClient, query.queryKey); + invalidate(queryClient, query.queryKey, scheduledQueryKeys); } } }; -const invalidateModuleDataQueries = (queryClient: QueryClient, channelId: string, moduleId: string): void => { +const invalidateModuleDataQueries = (queryClient: QueryClient, channelId: string, moduleId: string, scheduledQueryKeys: Set): void => { const moduleQueries = queryClient.getQueryCache().findAll({ queryKey: dashboardDataKeys.module(channelId, moduleId) }); for (const query of moduleQueries) { const part = query.queryKey[4]; - if (typeof part === "string" && part !== "settings") invalidate(queryClient, query.queryKey); + if (typeof part === "string" && part !== "settings") invalidate(queryClient, query.queryKey, scheduledQueryKeys); + } +}; + +const invalidateModuleSettingsQueries = (queryClient: QueryClient, channelId: string, scheduledQueryKeys: Set): void => { + const moduleQueries = queryClient.getQueryCache().findAll({ queryKey: ["channel", channelId, "module"] }); + for (const query of moduleQueries) { + if (query.queryKey[4] === "settings") invalidate(queryClient, query.queryKey, scheduledQueryKeys); + } +}; + +const invalidatePanelResource = (queryClient: QueryClient, channelId: string, resource: string, scheduledQueryKeys: Set): void => { + switch (resource) { + case "channels": + invalidate(queryClient, queryKeys.channels(), scheduledQueryKeys); + return; + case "channel.all": + // This is only the bounded socket hint for a large batch. The following + // authoritative vector names each changed resource exactly. + return; + case "channel.overview": + invalidate(queryClient, queryKeys.channel(channelId, "overview"), scheduledQueryKeys); + return; + case "channel.system": + invalidate(queryClient, dashboardDataKeys.system(channelId), scheduledQueryKeys); + return; + case "channel.settings": + invalidate(queryClient, queryKeys.channel(channelId, "settings"), scheduledQueryKeys); + return; + case "channel.members": + invalidate(queryClient, dashboardDataKeys.members(channelId), scheduledQueryKeys); + return; + case "channel.modules": + invalidate(queryClient, queryKeys.channel(channelId, "modules"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.channel(channelId, "overview"), scheduledQueryKeys); + return; + case "channel.events": + invalidate(queryClient, queryKeys.channel(channelId, "events"), scheduledQueryKeys); + return; + case "channel.audit": + invalidate(queryClient, queryKeys.channel(channelId, "audit-log"), scheduledQueryKeys); + return; + case "channel.variables": + invalidate(queryClient, dashboardDataKeys.variables(channelId), scheduledQueryKeys); + invalidate(queryClient, queryKeys.module(channelId, "text_commands", "commands"), scheduledQueryKeys); + invalidateModuleSettingsQueries(queryClient, channelId, scheduledQueryKeys); + return; + case "channel.overlays": + invalidate(queryClient, dashboardDataKeys.overlays(channelId), scheduledQueryKeys); + return; + case "channel.overlay-accesses": + invalidate(queryClient, dashboardDataKeys.overlays(channelId), scheduledQueryKeys); + invalidate(queryClient, queryKeys.channel(channelId, "overlay-accesses"), scheduledQueryKeys); + invalidate(queryClient, dashboardDataKeys.legacyOverlayTokens(channelId), scheduledQueryKeys); + return; + case "weather.cache": + invalidate(queryClient, queryKeys.module(channelId, "text_library", "library"), scheduledQueryKeys); + return; + case "api.cache": + invalidate(queryClient, queryKeys.module(channelId, "api_source", "sources"), scheduledQueryKeys); + return; + case "channel.library": + invalidate(queryClient, queryKeys.module(channelId, "text_library", "library"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.module(channelId, "timers", "panel"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.module(channelId, "faq", "panel"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.module(channelId, "text_commands", "template-variables"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.module(channelId, "api_source", "sources"), scheduledQueryKeys); + return; + default: + break; + } + + const moduleMatch = /^module:([a-z][a-z0-9_]*):([a-z][a-z0-9_-]*)$/u.exec(resource); + const moduleId = moduleMatch?.[1]; + const part = moduleMatch?.[2]; + if (moduleId === undefined || part === undefined) return; + if (part === "data") { + invalidateModuleDataQueries(queryClient, channelId, moduleId, scheduledQueryKeys); + return; + } + if (moduleId === "belabox" && part === "live") { + invalidateBelaboxLiveQueries(queryClient, channelId, scheduledQueryKeys); + return; + } + invalidate(queryClient, queryKeys.module(channelId, moduleId, part), scheduledQueryKeys); +}; + +const validPanelRevisionVector = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value) && + Object.entries(value).length <= 4_096 && Object.entries(value).every(([resource, revision]) => + resource.length <= 128 && Number.isSafeInteger(revision) && Number(revision) >= 0); + +/** Fetches and compares the channel vector; duplicate hints share an in-flight read. */ +export const reconcileDashboardPanelResourceRevisions = ( + queryClient: QueryClient, + channelId: string, +): Promise => { + let snapshots = panelRevisionSnapshots.get(queryClient); + if (snapshots === undefined) { + snapshots = new Map(); + panelRevisionSnapshots.set(queryClient, snapshots); + } + let fetches = panelRevisionFetches.get(queryClient); + if (fetches === undefined) { + fetches = new Map(); + panelRevisionFetches.set(queryClient, fetches); } + const inFlight = fetches.get(channelId); + if (inFlight !== undefined) { + inFlight.dirty = true; + return inFlight.promise; + } + const state: PanelRevisionFetch = { dirty: false, promise: Promise.resolve() }; + const request = (async (): Promise => { + do { + state.dirty = false; + try { + const response = await fetch(`/api/channels/${encodeURIComponent(channelId)}/revisions`, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + if (response.status === 401 || response.status === 403) { + dispatchDashboardAuthenticationRequired(); + return; + } + if (!response.ok) continue; + const body: unknown = await response.json(); + if (typeof body !== "object" || body === null || Array.isArray(body)) continue; + const revisions = (body as Record).revisions; + if (!validPanelRevisionVector(revisions)) continue; + const previous = snapshots.get(channelId) ?? {}; + const scheduledQueryKeys = new Set(); + for (const [resource, revision] of Object.entries(revisions)) { + if (revision > (previous[resource] ?? 0)) invalidatePanelResource(queryClient, channelId, resource, scheduledQueryKeys); + } + snapshots.set(channelId, revisions); + } catch { + // The durable vector is compared again on the next hint, reconnect, or focus. + } + } while (revisionFetchWasDirtied(state)); + })().finally(() => { + if (fetches.get(channelId) === state) fetches.delete(channelId); + }); + state.promise = request; + fetches.set(channelId, state); + return request; }; /** Runs a leading refresh, then limits hints for this key to one refresh per interval. */ @@ -103,60 +264,15 @@ const scheduleRealtimeRefresh = (queryClient: QueryClient, queryKey: readonly un }, wait); }; -/** Invalidates the channel caches affected by a realtime hint; API responses remain authoritative. */ -export const invalidateDashboardRealtimeMessage = ( +/** A realtime message is a wake-up; persisted resource revisions decide what to invalidate. */ +export const reconcileDashboardRealtimeMessage = ( queryClient: QueryClient, message: RealtimeMessage, -): void => { - const { channelId } = message; - switch (message.type) { - case "stream.state.changed": - invalidate(queryClient, queryKeys.channels()); - invalidate(queryClient, ["channel", channelId]); - break; - case "event_log.new": { - invalidate(queryClient, queryKeys.channel(channelId, "events")); - const moduleIds = new Set(message.payload.entries.map((entry) => entry.moduleId)); - for (const moduleId of moduleIds) invalidateModuleDataQueries(queryClient, channelId, moduleId); - break; - } - case "variables.changed": - invalidate(queryClient, dashboardDataKeys.variables(channelId)); - invalidate(queryClient, dashboardDataKeys.overlays(channelId)); - invalidate(queryClient, ["channel", channelId, "module"]); - break; - case "overlay.changed": - invalidate(queryClient, dashboardDataKeys.overlays(channelId)); - invalidate(queryClient, dashboardDataKeys.variables(channelId)); - break; - case "ads.schedule.updated": - invalidate(queryClient, queryKeys.module(channelId, "ads", "schedule")); - break; - default: { - switch (message.type) { - case "modul.chat_voting.changed": - invalidate(queryClient, queryKeys.module(channelId, "chat_voting", "panel")); - break; - case "modul.belabox.changed": - // Refresh only live data parts; stream history keys are selected dynamically by the panel. - invalidateBelaboxLiveQueries(queryClient, channelId); - break; - case "modul.votekick.changed": - invalidate(queryClient, queryKeys.module(channelId, "votekick", "panel")); - if (message.payload.part === "availability") { - // Chat voting also reads channel-wide ballot availability from the shared ballot. - invalidate(queryClient, queryKeys.module(channelId, "chat_voting", "panel")); - } - break; - default: - break; - } - } - } -}; +): Promise => message.type === "system.hello" + ? Promise.resolve() + : reconcileDashboardPanelResourceRevisions(queryClient, message.channelId); /** A successful reconnect makes every cached resource for that channel stale. */ export const invalidateDashboardChannelQueries = (queryClient: QueryClient, channelId: string): void => { - invalidate(queryClient, queryKeys.channels()); invalidate(queryClient, ["channel", channelId]); }; diff --git a/src/dashboard/main.tsx b/src/dashboard/main.tsx index 6eb8777f..74c543fe 100644 --- a/src/dashboard/main.tsx +++ b/src/dashboard/main.tsx @@ -37,7 +37,6 @@ import { Led, ModuleCount, ModuleHeading, ModuleIcon, ModulePage, ModuleTile, Mo import { BroadcasterConsentAction, ChannelNotices, ImmediateActions, ModeratorCheckAction, WarningsAndErrorsFeed } from "./stream-manager"; import { refreshAfterModuleToggle } from "./data/module-toggle"; import { useSystemQuery } from "./data/lists"; -import { useDashboardRealtimeStatus } from "./data/realtime"; import { ChannelSpotlight } from "./spotlight"; import { MembersPage } from "./members"; import { PlatformPage } from "./platform"; @@ -58,6 +57,7 @@ import { OverlaysPage } from "./OverlaysPage"; import { OverlayEditorPage } from "./OverlayEditorPage"; import { emptyAuditFilter, auditFilterIsActive } from "./audit/model"; import { useRealtimePanelMessages } from "./realtime"; +import { useDashboardRealtimeStatus } from "./data/realtime"; import { channelSettingsTexts } from "./channel-settings-locale"; import { ChannelTimeZoneField } from "./ChannelTimeZoneField"; import { ChannelLocationField } from "./ChannelLocationField"; @@ -1223,6 +1223,7 @@ export const DashboardApp = (): ReactElement => { const [route, navigate] = useDashboardRoute(); const realtimeChannelId = route.kind === "channel" || route.kind === "module" ? route.channelId : null; const realtimeStatus = useDashboardRealtimeStatus(realtimeChannelId ?? ""); + const offlineRefetchInterval = realtimeStatus === "connected" ? false : 180_000; const routeRef = useRef(route); useLayoutEffect(() => { routeRef.current = route; }, [route]); const queryClient = useQueryClient(); @@ -1237,7 +1238,6 @@ export const DashboardApp = (): ReactElement => { const channelsQuery = useQuery({ queryKey: queryKeys.channels(), refetchOnWindowFocus: false, - refetchInterval: realtimeStatus === "connected" ? false : 180_000, queryFn: async ({ signal }) => { const response = await fetchChannels(signal); return { @@ -1247,6 +1247,7 @@ export const DashboardApp = (): ReactElement => { ), }; }, + refetchInterval: offlineRefetchInterval, enabled: !authenticationRequired, }); const channelsResponse = channelsQuery.data; @@ -1270,12 +1271,12 @@ export const DashboardApp = (): ReactElement => { const overviewQuery = useQuery({ queryKey: queryKeys.channel(overviewChannelId ?? "", "overview"), refetchOnWindowFocus: false, - refetchInterval: realtimeStatus === "connected" ? false : 180_000, queryFn: async ({ signal }) => { if (overviewChannelId === null) throw new Error("An overview query needs a channel ID."); const response = await fetchChannelOverview(overviewChannelId, signal); return mergeAndRememberChannelStreamVersion(response, latestStreamByChannel.current); }, + refetchInterval: overviewChannelId === null ? false : offlineRefetchInterval, enabled: !authenticationRequired && overviewChannelId !== null && (route.kind === "module" || (route.kind === "channel" && route.section === "overview")), placeholderData: (previousData, previousQuery) => @@ -1287,7 +1288,7 @@ export const DashboardApp = (): ReactElement => { const systemQuery = useSystemQuery( systemChannelId ?? "", !authenticationRequired && systemChannelId !== null, - realtimeStatus === "connected" ? false : 180_000, + systemChannelId === null ? false : offlineRefetchInterval, ); const [, setFreshnessTick] = useState(0); const routeKey = dashboardRoutePath(route); diff --git a/src/dashboard/realtime.ts b/src/dashboard/realtime.ts index f087eea9..93f2fa74 100644 --- a/src/dashboard/realtime.ts +++ b/src/dashboard/realtime.ts @@ -3,11 +3,11 @@ import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import type { PanelChannelControl, PanelChannelControls } from "../panel-contract"; import type { RealtimeEnvelope, RealtimeEventLogHint, RealtimeMessage } from "../realtime-contract"; -import type { AdsSchedule } from "../modules/ads/contracts"; -import { REALTIME_PROTOCOL, isPanelModuleRealtimeMessageType, isPanelModuleRealtimePayload } from "../realtime-contract"; +import { REALTIME_PROTOCOL } from "../realtime-contract"; import { invalidateDashboardChannelQueries, - invalidateDashboardRealtimeMessage, + reconcileDashboardRealtimeMessage, + reconcileDashboardPanelResourceRevisions, setDashboardRealtimeStatus, useDashboardRealtimeStatus, } from "./data/realtime"; @@ -41,14 +41,6 @@ const isHint = (value: unknown): value is RealtimeEventLogHint => { (value.actorUserId === null || typeof value.actorUserId === "string"); }; -const isAdSchedule = (value: unknown): value is AdsSchedule => isRecord(value) && - (value.nextAdAt === null || typeof value.nextAdAt === "string") && - (value.duration === null || typeof value.duration === "number") && - (value.lastAdAt === null || typeof value.lastAdAt === "string") && - (value.prerollFreeTime === null || typeof value.prerollFreeTime === "number") && - (value.snoozeCount === null || typeof value.snoozeCount === "number") && - (value.snoozeRefreshAt === null || typeof value.snoozeRefreshAt === "string"); - const isPanelChannelControl = (value: unknown): value is PanelChannelControl => isRecord(value) && typeof value.active === "boolean" && (value.pending === undefined || typeof value.pending === "boolean") && @@ -75,10 +67,9 @@ const isKnownEnvelope = (value: Record): value is Record 0 && typeof value.createdAt === "string" && value.createdAt.length > 0 && typeof value.channelId === "string" && value.channelId.length > 0 && - (value.type === "system.hello" || value.type === "event_log.new" || + (value.type === "system.hello" || value.type === "panel.resources.changed" || value.type === "event_log.new" || value.type === "variables.changed" || value.type === "overlay.changed" || - value.type === "ads.schedule.updated" || value.type === "stream.state.changed" || - typeof value.type === "string" && isPanelModuleRealtimeMessageType(value.type)); + value.type === "stream.state.changed"); export const parseRealtimeMessage = (raw: string, channelId: string): RealtimeParseResult => { let parsed: unknown; @@ -92,16 +83,21 @@ export const parseRealtimeMessage = (raw: string, channelId: string): RealtimePa return { kind: "foreign-channel" }; } if (!isKnownEnvelope(parsed)) return { kind: "ignored" }; - if (isPanelModuleRealtimeMessageType(parsed.type)) { - return isPanelModuleRealtimePayload(parsed.type, parsed.payload) - ? { kind: "message", message: parsed as RealtimeMessage } - : { kind: "ignored" }; - } if (parsed.type === "system.hello") { return parsed.payload !== null && isRecord(parsed.payload) && Object.keys(parsed.payload).length === 0 ? { kind: "message", message: parsed as RealtimeEnvelope<"system.hello"> } : { kind: "ignored" }; } + if (parsed.type === "panel.resources.changed") { + if (!isRecord(parsed.payload) || !Array.isArray(parsed.payload.resources) || + parsed.payload.resources.length > 128 || !parsed.payload.resources.every((resource) => + typeof resource === "string" && resource.length > 0 && resource.length <= 128) || + !isRecord(parsed.payload.revisions) || Object.keys(parsed.payload.revisions).length > 128 || + !Object.entries(parsed.payload.revisions).every(([resource, revision]) => + resource.length > 0 && resource.length <= 128 && typeof revision === "number" && + Number.isSafeInteger(revision) && revision >= 0)) return { kind: "ignored" }; + return { kind: "message", message: parsed as RealtimeEnvelope<"panel.resources.changed"> }; + } if (parsed.type === "variables.changed") { return isVariablesChangedPayload(parsed.payload) ? { kind: "message", message: parsed as RealtimeEnvelope<"variables.changed"> } @@ -113,12 +109,6 @@ export const parseRealtimeMessage = (raw: string, channelId: string): RealtimePa ? { kind: "message", message: parsed as RealtimeEnvelope<"overlay.changed"> } : { kind: "ignored" }; } - if (parsed.type === "ads.schedule.updated") { - return isRecord(parsed.payload) && isAdSchedule(parsed.payload.schedule) && - typeof parsed.payload.asOf === "string" && parsed.payload.asOf.length > 0 - ? { kind: "message", message: parsed as RealtimeEnvelope<"ads.schedule.updated"> } - : { kind: "ignored" }; - } if (parsed.type === "stream.state.changed") { return isRecord(parsed.payload) && (parsed.payload.state === "online" || parsed.payload.state === "offline") && @@ -202,9 +192,11 @@ const connectPanelSocket = (channelId: string, connection: PanelSocketConnection connection.socket = socket; socket.addEventListener("open", () => { if (connection.disposed) return; - invalidateDashboardChannelQueries(connection.queryClient, channelId); + const reconnected = connection.reconnectAttempt > 0; connection.reconnectAttempt = 0; setDashboardRealtimeStatus(channelId, "connected"); + if (reconnected) invalidateDashboardChannelQueries(connection.queryClient, channelId); + void reconcileDashboardPanelResourceRevisions(connection.queryClient, channelId); }); socket.addEventListener("message", (event: MessageEvent) => { if (typeof event.data !== "string") return; @@ -215,7 +207,7 @@ const connectPanelSocket = (channelId: string, connection: PanelSocketConnection } if (parsed.kind !== "message" || connection.seenMessageIds.has(parsed.message.id)) return; connection.seenMessageIds.add(parsed.message.id); - invalidateDashboardRealtimeMessage(connection.queryClient, parsed.message); + void reconcileDashboardRealtimeMessage(connection.queryClient, parsed.message); }); socket.addEventListener("close", (event: CloseEvent) => { if (connection.disposed) return; @@ -287,7 +279,17 @@ export const useRealtimePanelMessages = (channelId: string | null, enabled: bool return; } activeChannelId.current = channelId; - return acquirePanelSocket(channelId, queryClient); + const refreshOnFocus = (): void => { + if (document.visibilityState === "visible") { + void reconcileDashboardPanelResourceRevisions(queryClient, channelId); + } + }; + window.addEventListener("focus", refreshOnFocus); + const release = acquirePanelSocket(channelId, queryClient); + return () => { + window.removeEventListener("focus", refreshOnFocus); + release(); + }; }, [channelId, enabled, queryClient]); }; diff --git a/src/modules/belabox/service.ts b/src/modules/belabox/service.ts index 223aeb17..5abba597 100644 --- a/src/modules/belabox/service.ts +++ b/src/modules/belabox/service.ts @@ -64,10 +64,6 @@ const LIVE_BUFFER_MAX_POINTS = 120; const FETCH_FAILURE_THRESHOLD = 3; const INFRASTRUCTURE_RETRY_MAX_MS = 60_000; -const publishBelaboxPanelHint = async (context: Pick): Promise => { - try { await context.publishModulePanelHint?.("live"); } catch { /* Panel refresh hints are best-effort. */ } -}; - /** Sets fresh chat templates to the channel language whenever BELABOX is enabled. */ export const belaboxAlertDefaultsOnEnable = async ( context: ModuleEnableContext, @@ -296,7 +292,6 @@ const stopPolling = async ( ...(prerequisites.secretUnavailable ? { errorCode: BELABOX_SECRET_UNAVAILABLE_STATUS_CODE } : {}), }, ); - if (current) await publishBelaboxPanelHint(context); if (current && hadVisibleState && prerequisites.streamState === "offline") { try { await context.publishModuleOverlayMessage("state_changed", "belabox.status", { @@ -640,7 +635,6 @@ const sendPendingAlert = async ( latest.alertState.pendingChat.episodeStartedAt !== output.episodeStartedAt) return; const next = settleAlertChat(latest.alertState, { sent: result.sent, retryable: result.retryable }, Date.now()); if (await writeBelaboxAlertState(context.DB, context.channelId, next, latest.revision) !== null) { - await publishBelaboxPanelHint(context); return; } } @@ -729,7 +723,6 @@ const runBelaboxPoll = async ( currentStatus.revision, prerequisites.streamSnapshot, ); - if (cleared) await publishBelaboxPanelHint(context); if (!cleared) await context.schedule(BELABOX_POLL_ALARM_KEY, Date.now()); } return; @@ -737,7 +730,7 @@ const runBelaboxPoll = async ( const prerequisites = await alarmPrerequisites(context, requiredMode); delay = retryDelay(prerequisites.settings); if (prerequisites.secretReadFailed) { - const current = await finalizeAndResetBelaboxHistory( + await finalizeAndResetBelaboxHistory( context.DB, context.channelId, new Date().toISOString(), @@ -749,7 +742,6 @@ const runBelaboxPoll = async ( maxBaselineAgeMs: (prerequisites.historyIntervalSeconds ?? 0) * 2_000, }, ); - if (current) await publishBelaboxPanelHint(context); if (reason !== "on_demand") await reschedulePollAfterFailure(context, deadline, delay); return reason === "alarm" ? undefined : { ok: false, reason: "network" }; } @@ -760,12 +752,10 @@ const runBelaboxPoll = async ( } pollMode = prerequisites.settings.mode; if (pollMode === "interval") { - let panelStateChanged = false; if (prerequisites.moduleRevision !== null) { - panelStateChanged = await reopenCurrentBelaboxStream(context.DB, context.channelId, prerequisites.moduleRevision); + await reopenCurrentBelaboxStream(context.DB, context.channelId, prerequisites.moduleRevision); } - panelStateChanged = await markBelaboxPollingStarted(context.DB, context.channelId) || panelStateChanged; - if (panelStateChanged) await publishBelaboxPanelHint(context); + await markBelaboxPollingStarted(context.DB, context.channelId); } const originSession = streamSessionFromSnapshot(prerequisites.streamSnapshot); const result = await fetchRelaySample( @@ -782,11 +772,10 @@ const runBelaboxPoll = async ( originSession, pollMode, ); - if (stored.kind === "stored") await publishBelaboxPanelHint(context); const latest = await alarmPrerequisites(context, requiredMode); if (latest.secretReadFailed) { - const current = await finalizeAndResetBelaboxHistory( + await finalizeAndResetBelaboxHistory( context.DB, context.channelId, new Date().toISOString(), @@ -798,7 +787,6 @@ const runBelaboxPoll = async ( maxBaselineAgeMs: (latest.historyIntervalSeconds ?? 0) * 2_000, }, ); - if (current) await publishBelaboxPanelHint(context); if (pollMode === "interval") await reschedulePollAfterFailure(context, deadline, retryDelay(latest.settings)); return reason === "alarm" ? undefined : { ok: false, reason: "network" }; } @@ -820,7 +808,6 @@ const runBelaboxPoll = async ( maxBaselineAgeMs: (latest.historyIntervalSeconds ?? 0) * 2_000, }, ); - if (current) await publishBelaboxPanelHint(context); await ensureLifecycleRevision(context, current); if (current && pollMode === "interval") await context.schedule(BELABOX_POLL_ALARM_KEY, Date.now()); return reason === "alarm" ? undefined : { ok: false, reason: "stream_changed" }; @@ -842,7 +829,6 @@ const runBelaboxPoll = async ( maxBaselineAgeMs: (latest.historyIntervalSeconds ?? 0) * 2_000, }, ); - if (current) await publishBelaboxPanelHint(context); await ensureLifecycleRevision(context, current); if (current && pollMode === "interval") await context.schedule(BELABOX_POLL_ALARM_KEY, Date.now()); return reason === "alarm" ? undefined : { ok: false, reason: "not_configured" }; diff --git a/src/modules/chat_voting/routes.ts b/src/modules/chat_voting/routes.ts index 412ed392..957ab69e 100644 --- a/src/modules/chat_voting/routes.ts +++ b/src/modules/chat_voting/routes.ts @@ -31,14 +31,6 @@ const channelLanguage = async (db: D1Database, channelId: string): Promise<"de" export const chatVotingRoutes = new Hono(); -const publishChatVotingPanelHint = async ( - context: { get: (key: "publishModulePanelHint") => ModuleRouteEnvironment["Variables"]["publishModulePanelHint"] }, - channelId: string, -): Promise => { - try { await context.get("publishModulePanelHint")?.(channelId, CHAT_VOTING_MODULE_ID, "panel"); } - catch { /* Panel refresh hints are best-effort. */ } -}; - chatVotingRoutes.get("/current", async (context) => { const channelId = context.req.param("channelId") ?? ""; const repository = createChatVotingRepository(context.env.DB); @@ -172,7 +164,6 @@ chatVotingRoutes.post("/start", async (context) => { } if (result.status === "busy") return context.json({ error: "chat_voting_busy" }, 409); - await publishChatVotingPanelHint(context, channelId); await context.get("writeModuleAudit")({ channelId, moduleId: CHAT_VOTING_MODULE_ID, @@ -230,7 +221,6 @@ chatVotingRoutes.post("/approve-term", async (context) => { }; const publishTally = async (snapshot: BallotSnapshot): Promise => { - await publishChatVotingPanelHint(context, channelId); await context.get("publishModuleOverlayMessage")( channelId, CHAT_VOTING_MODULE_ID, @@ -290,7 +280,6 @@ chatVotingRoutes.post("/approve-term", async (context) => { if (!persisted.authorized) return context.json({ error: "chat_voting_not_authorized" }, 403); if (!persisted.changed) return context.json({ error: "chat_voting_not_running" }, 409); - await publishChatVotingPanelHint(context, channelId); const filtered = await ballots.setBlockedTerms(vote.id, blockedTerms); if (filtered === null) return context.json({ error: "chat_voting_not_running" }, 409); // A refresh can remove previously counted terms. Publish that durable state diff --git a/src/modules/chat_voting/service.ts b/src/modules/chat_voting/service.ts index 7ccfa9b3..78c5051d 100644 --- a/src/modules/chat_voting/service.ts +++ b/src/modules/chat_voting/service.ts @@ -27,12 +27,6 @@ export type VoteStartResult = | { status: "started"; vote: ChatVoteDraft } | { status: "busy" }; -const publishChatVotingPanelHint = async ( - context: Pick, -): Promise => { - try { await context.publishModulePanelHint?.("panel"); } catch { /* Panel refresh hints are best-effort. */ } -}; - export const chatVotingTemplateValues = ( vote: Pick, language: ModuleLanguage, @@ -289,7 +283,6 @@ export const processChatVotingMessage = async ( if (result.status === "busy") { return { actions: [directChat(chatVotingChatText(language, "busy"))], diagnostics: [] }; } - await publishChatVotingPanelHint(context); return { actions: [ { kind: "overlay", type: "opened", elementKind: CHAT_VOTING_ELEMENT_KIND, payload: { pollId: result.vote.id } }, @@ -313,7 +306,6 @@ export const processChatVotingMessage = async ( if (result.status !== "counted" && result.status !== "changed" && result.status !== "overflow") { return { actions: [], diagnostics: [] }; } - await publishChatVotingPanelHint(context); return { actions: [{ kind: "overlay", @@ -346,7 +338,6 @@ export const processChatVotingMessage = async ( const result = await context.ballots.cast(vote.id, userId, choice.choice, choice.source === "word" ? { onlyIfNew: true } : undefined); if (result.status !== "counted" && result.status !== "changed") return { actions: [], diagnostics: [] }; - await publishChatVotingPanelHint(context); return { actions: [{ kind: "overlay", @@ -528,7 +519,6 @@ export const closeChatVoteFromAlarm = async ( const closedVote = await repository.byId(context.channelId, pollId); if (closedVote === null || closedVote.status !== "closed") throw new Error("The chat vote could not be closed."); - await publishChatVotingPanelHint(context); await context.publishModuleOverlayMessage("tally", CHAT_VOTING_ELEMENT_KIND, { pollId, openedAt: closedVote.openedAt, diff --git a/src/modules/contract.ts b/src/modules/contract.ts index 2763439e..4413db84 100644 --- a/src/modules/contract.ts +++ b/src/modules/contract.ts @@ -5,7 +5,6 @@ import type { AuditWriteAction, ChannelRole, ChannelStreamState, ChannelVariable import type { TemplateContext, TemplateFields, TemplateVariable } from "../template"; import type { SettingsEditorDefinition } from "../dashboard/ui"; import type { ModerationResult, ModerationTimeoutExpectation } from "./contracts/moderation"; -import type { PanelModuleHintPart } from "../realtime-contract"; export type { PanelTemplateWarning, PanelTemplateWarningResponse } from "../panel-contract"; export { CHAT_OUTPUT_TARGETS } from "../contracts/values"; export type { ChatOutputTarget } from "../contracts/values"; @@ -566,8 +565,6 @@ export interface ModuleExecutionContext { authorizeMutation: AuthorizeModuleMutation; /** Ephemeral ballot access bound to the event channel and executing module. */ ballots: ModuleBallotAccess; - /** Publishes a bounded panel invalidation after a module commits visible state. */ - publishModulePanelHint?: (part: PanelModuleHintPart) => Promise; /** Lazily reads the current channel's public blocked terms with the bot moderator token. */ readChannelBlockedTerms?: () => Promise; /** Lazily resolves the connected bot identity so modules can ignore its own chat messages. */ @@ -662,8 +659,6 @@ export interface ModuleAlarmContext { elementKind: string, payload: Readonly>, ) => Promise; - /** Publishes a bounded panel invalidation independently of overlay payloads. */ - publishModulePanelHint?: (part: PanelModuleHintPart) => Promise; /** Sends scheduled automated output through the shared channel limit with an occurrence claim. */ sendChat: ( text: string, @@ -1179,8 +1174,6 @@ export interface ModuleRouteVariables { payload: Readonly>, recipientConfig?: { field: string; value: string }, ) => Promise; - /** Publishes a bounded panel invalidation independently of overlay payloads. */ - publishModulePanelHint?: (channelId: string, moduleId: string, part: PanelModuleHintPart) => Promise; publishOverlayHostEvent: (channelId: string, event: ModuleOverlayHostEvent) => Promise; writeModuleDiagnostics: ( db: D1Database, diff --git a/src/modules/votekick/routes.ts b/src/modules/votekick/routes.ts index d9d83bd0..3c3df77c 100644 --- a/src/modules/votekick/routes.ts +++ b/src/modules/votekick/routes.ts @@ -51,8 +51,6 @@ votekickRoutes.post("/votekicks/:id/cancel", async (context) => { }, now); const results = await context.env.DB.batch([mutation, audit]); if (results.at(0)?.meta.changes !== 1) return context.json({ error: "votekick_not_running" }, 409); - try { await context.get("publishModulePanelHint")?.(channelId, VOTEKICK_MODULE_ID, "availability"); } - catch { /* Panel refresh hints are best-effort. */ } try { const ballots = context.get("ballots")(channelId); const snapshot = await ballots.close(id); @@ -113,8 +111,6 @@ votekickRoutes.post("/votekicks/:id/lift", async (context) => { AND target_user_id IS NOT NULL ${authorization.sql}`, ).bind(now, channelId, id, ...authorization.values).run(); if (update.meta.changes === 0) return context.json({ error: "votekick_timeout_unavailable" }, 409); - try { await context.get("publishModulePanelHint")?.(channelId, VOTEKICK_MODULE_ID, "panel"); } - catch { /* Panel refresh hints are best-effort. */ } try { const lifted = await repository.byId(channelId, id); if (lifted !== null) { diff --git a/src/modules/votekick/service.ts b/src/modules/votekick/service.ts index 1e9d91d9..f2d33818 100644 --- a/src/modules/votekick/service.ts +++ b/src/modules/votekick/service.ts @@ -44,23 +44,15 @@ const votekickOverlayAction = (type: "opened" | "tally", payload: Readonly, + context: Pick, votekick: Votekick, ): Promise => { - await publishVotekickPanelHint(context, "availability"); if (typeof context.publishModuleOverlayMessage !== "function") return; try { await context.publishModuleOverlayMessage("tally", VOTEKICK_ELEMENT_KIND, votekickOverlayPayload(votekick)); } catch { /* Overlay presentation must never hold up moderation or alarm cleanup. */ } }; -const publishVotekickPanelHint = async ( - context: { publishModulePanelHint?: ModuleExecutionContext["publishModulePanelHint"] }, - part: "panel" | "availability", -): Promise => { - try { await context.publishModulePanelHint?.(part); } catch { /* Panel refresh hints are best-effort. */ } -}; - const chatAction = (text: string, target: VotekickSettings["chatTarget"], automated = false): ModuleAction | null => text.length === 0 ? null : { kind: "chat", text, target, automated }; @@ -140,7 +132,6 @@ const preparePass = async ( type VotekickFinalizeAccess = { ballots: ModuleExecutionContext["ballots"]; - publishModulePanelHint?: ModuleExecutionContext["publishModulePanelHint"]; channelLanguage: ModuleExecutionContext["channelLanguage"]; secureRandomInteger: ModuleExecutionContext["secureRandomInteger"]; renderTemplate: ( @@ -165,7 +156,6 @@ const finalizeVotekick = async ( const [yesVotes, noVotes] = snapshotCounts(finalized); if (finalized.outcome === "open") { await repository.updateCounts(channelId, running.id, yesVotes, noVotes, finalized.revision); - await publishVotekickPanelHint(access, "panel"); return { outcome: "open", claimed: false, counts: [yesVotes, noVotes], revision: finalized.revision }; } if (finalized.outcome === "not_open") { @@ -381,7 +371,6 @@ const commandResult = async ( } ballotOpened = false; alarmScheduled = false; - await publishVotekickPanelHint(context, "availability"); return { actions: [ votekickOverlayAction("opened", { votekickId: id }), @@ -416,7 +405,6 @@ const commandResult = async ( liftedAt: null, } : null; - if (failed !== null) await publishVotekickPanelHint(context, "availability"); return { actions: failed === null ? [] : [votekickOverlayAction("tally", votekickOverlayPayload(failed))], diagnostics: rejection("lookup_failure").diagnostics, @@ -442,14 +430,10 @@ export const processVotekickMessage = async ( if (!attempt.claimed) { let finalized: Votekick | null = null; try { finalized = await repository.byId(event.channelId, running.id); } catch { /* The other claimant owns moderation. */ } - if (finalized !== null && finalized.status !== "running") { - await publishVotekickPanelHint(context, "availability"); - } return { actions: finalized === null ? [] : [votekickOverlayAction("tally", votekickOverlayPayload(finalized))], diagnostics: [] }; } await bestEffortClose(context, running.id); await bestEffortClear(context, `close:${running.id}`); - await publishVotekickPanelHint(context, "availability"); return { actions: [attempt.prepared.action, votekickOverlayAction("tally", votekickOverlayPayload(attempt.votekick))], diagnostics: [...attempt.prepared.diagnostics], @@ -461,7 +445,6 @@ export const processVotekickMessage = async ( else await context.scheduleAlarm("close", `close:${running.id}`, Date.now()); let expired: Votekick | null = null; try { expired = await repository.byId(event.channelId, running.id); } catch { /* Overlay presentation is best-effort. */ } - if (expired !== null && expired.status !== "running") await publishVotekickPanelHint(context, "availability"); return { actions: expired === null ? [] : [votekickOverlayAction("tally", votekickOverlayPayload(expired))], diagnostics: [] }; } return null; diff --git a/src/realtime-contract.ts b/src/realtime-contract.ts index d8240ee9..8c2c053b 100644 --- a/src/realtime-contract.ts +++ b/src/realtime-contract.ts @@ -1,5 +1,4 @@ import type { ChannelRole, ChannelStreamState } from "./contracts/values"; -import type { AdsSchedule } from "./modules/ads/contracts"; import type { PanelChannelControls } from "./panel-contract"; /** The only protocol version used on the wire. */ @@ -12,25 +11,15 @@ export const OVERLAY_ACCESS_BOUND_CLOSE_REASON = "Overlay access bound"; export const REALTIME_MESSAGE_TYPES = [ "system.hello", + "panel.resources.changed", "event_log.new", "variables.changed", "overlay.changed", - "ads.schedule.updated", "stream.state.changed", ] as const; export type FixedRealtimeMessageType = (typeof REALTIME_MESSAGE_TYPES)[number]; export type ModuleOverlayRealtimeMessageType = `modul.${string}.${string}`; -export type PanelModuleHintPart = "panel" | "live" | "availability"; - -export interface PanelModuleRealtimePayloads { - "modul.chat_voting.changed": { part: "panel" }; - "modul.belabox.changed": { part: "live" }; - "modul.votekick.changed": { part: "panel" | "availability" }; -} - -export type PanelModuleRealtimeMessageType = keyof PanelModuleRealtimePayloads; - export interface ModuleOverlayRealtimePayloads { "modul.text_library.blocks_updated": { blockName: string }; "modul.text_library.state_changed": { @@ -64,6 +53,10 @@ export interface RealtimeEventLogHint { export interface RealtimePayloads { "system.hello": Record; + "panel.resources.changed": { + resources: readonly string[]; + revisions: Readonly>; + }; "event_log.new": { entries: readonly RealtimeEventLogHint[]; }; @@ -77,7 +70,6 @@ export interface RealtimePayloads { overlayId: string; revision: number; }; - "ads.schedule.updated": { schedule: AdsSchedule; asOf: string }; "stream.state.changed": { state: ChannelStreamState; startedAt: string | null; @@ -109,21 +101,9 @@ export type ModuleOverlayRealtimeEnvelope = { - [EnvelopeType in Type]: { - version: RealtimeProtocolVersion; - id: string; - createdAt: string; - channelId: string; - type: EnvelopeType; - payload: PanelModuleRealtimePayloads[EnvelopeType]; - } -}[Type]; - export type RealtimeEnvelope = Type extends FixedRealtimeMessageType ? FixedRealtimeEnvelope - : Type extends PanelModuleRealtimeMessageType ? PanelModuleRealtimeEnvelope - : Type extends ModuleOverlayRealtimeMessageType ? ModuleOverlayRealtimeEnvelope + : Type extends ModuleOverlayRealtimeMessageType ? ModuleOverlayRealtimeEnvelope : never; /** @@ -133,59 +113,34 @@ export type RealtimeEnvelope> + | ModuleOverlayRealtimeEnvelope> | ModuleOverlayRealtimeEnvelope; export type RealtimeMessage = { [Type in FixedRealtimeMessageType]: FixedRealtimeEnvelope }[FixedRealtimeMessageType] - | AnyModuleOverlayRealtimeEnvelope - | PanelModuleRealtimeEnvelope; + | AnyModuleOverlayRealtimeEnvelope; export const isModuleOverlayRealtimeMessageType = (type: string): type is ModuleOverlayRealtimeMessageType => /^modul\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_-]*$/u.test(type); -/** Bounded panel-only invalidation hints; module overlay payloads stay overlay-only. */ -const PANEL_MODULE_REALTIME_MESSAGE_TYPES = new Set([ - "modul.chat_voting.changed", - "modul.belabox.changed", - "modul.votekick.changed", -]); - -export const isPanelModuleRealtimeMessageType = ( - type: string, -): type is PanelModuleRealtimeMessageType => PANEL_MODULE_REALTIME_MESSAGE_TYPES.has(type as PanelModuleRealtimeMessageType); - -export const isPanelModuleRealtimePayload = (type: string, payload: unknown): boolean => { - if (!isPanelModuleRealtimeMessageType(type) || typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return false; - } - const values = payload as Readonly>; - if (Object.keys(values).length !== 1 || !Object.hasOwn(values, "part")) return false; - if (type === "modul.chat_voting.changed") return values.part === "panel"; - if (type === "modul.belabox.changed") return values.part === "live"; - return values.part === "panel" || values.part === "availability"; -}; - export const isModuleOverlayRealtimeEnvelope = ( message: RealtimeMessage, ): message is AnyModuleOverlayRealtimeEnvelope => - isModuleOverlayRealtimeMessageType(message.type) && !isPanelModuleRealtimeMessageType(message.type); + isModuleOverlayRealtimeMessageType(message.type); export type RealtimeRecipientKind = "panel" | "overlay"; /** Every wire type is explicitly limited to the clients allowed to receive it. */ export const REALTIME_RECIPIENTS = { "system.hello": ["panel", "overlay"], + "panel.resources.changed": ["panel"], "event_log.new": ["panel"], "variables.changed": ["panel", "overlay"], "overlay.changed": ["panel", "overlay"], - "ads.schedule.updated": ["panel"], "stream.state.changed": ["panel"], } as const satisfies Record; export const realtimeRecipients = (type: RealtimeMessageType | ModuleOverlayRealtimeMessageType): readonly RealtimeRecipientKind[] => - isModuleOverlayRealtimeMessageType(type) - ? isPanelModuleRealtimeMessageType(type) ? ["panel"] : ["overlay"] - : REALTIME_RECIPIENTS[type]; + isModuleOverlayRealtimeMessageType(type) ? ["overlay"] : REALTIME_RECIPIENTS[type]; export type RealtimePanelPrincipal = { v: RealtimeProtocolVersion; diff --git a/src/worker/auth/overlay-token-service.ts b/src/worker/auth/overlay-token-service.ts index 521d5357..baae84fc 100644 --- a/src/worker/auth/overlay-token-service.ts +++ b/src/worker/auth/overlay-token-service.ts @@ -15,6 +15,7 @@ export interface AuthenticateOverlayTokenInput { token: string; pepper: string; now: string; + onCommittedTouch?: (channelId: string) => Promise; } export interface RevokeOverlayTokenInput { @@ -63,7 +64,10 @@ export const authenticateOverlayToken = async ( } catch { return record; } - if (touched) return { ...record, lastUsedAt: input.now }; + if (touched) { + await input.onCommittedTouch?.(record.channelId); + return { ...record, lastUsedAt: input.now }; + } return getUsableOverlayToken(db, tokenHash, input.now); }; diff --git a/src/worker/auth/routes.ts b/src/worker/auth/routes.ts index 8db2d033..7d26b4a4 100644 --- a/src/worker/auth/routes.ts +++ b/src/worker/auth/routes.ts @@ -74,6 +74,7 @@ import { hydrateModuleOverlayElements } from "../overlays/module-state"; import { hydrateCachedAdsCountdownSnapshot } from "../overlays/ads-countdown-cache"; import { ADS_COUNTDOWN_ELEMENT_KIND } from "../../modules/ads/overlay/kinds"; import { createOverlayElementContext } from "../overlays/element-context"; +import { notifyCommittedResources } from "../panel-resources"; const nowIso = (): string => new Date().toISOString(); const OVERLAY_VARIABLE_NAME_PATTERN = /^[a-z][a-z0-9_]{0,31}$/u; @@ -126,14 +127,18 @@ const redirectAfterLogin = (origin: string, path: string | null | undefined): st const maintainAfterBotAuthorization = async (env: Env, now: string): Promise => { try { - await maintainBotIdentity(env, now); - } catch { - // The authorization is already stored; the hourly run remains the safety net. - } - try { - await maintainEventSubSubscriptions(env, now); - } catch { - // Errors are logged by the maintenance run and must not break the callback. + try { + await maintainBotIdentity(env, now); + } catch { + // The authorization is already stored; the hourly run remains the safety net. + } + try { + await maintainEventSubSubscriptions(env, now); + } catch { + // Errors are logged by the maintenance run and must not break the callback. + } + } finally { + await notifyCommittedResources(env); } }; @@ -142,6 +147,8 @@ const maintainAfterBroadcasterAuthorization = async (env: Env, now: string): Pro await maintainEventSubSubscriptions(env, now); } catch { // The reconciliation is retried in the hourly run; the consent remains stored. + } finally { + await notifyCommittedResources(env); } }; @@ -271,70 +278,77 @@ authRouter.get("/api/overlay/bootstrap", async (context) => { token, pepper: context.env.OVERLAY_TOKEN_PEPPER, now: nowIso(), + onCommittedTouch: (channelId) => notifyCommittedResources(context.env, channelId), }); if (record === null) return context.json({ error: "overlay_token_invalid" satisfies ApiErrorCode }, 401); - const overlayId = await getOverlayBindingForToken(context.env.DB, record.channelId, record.tokenId); - if (overlayId === null) { - return context.json({ language: record.language, overlay: null, variables: {} }); - } - const overlay = await getOverlayForChannel(context.env.DB, record.channelId, overlayId); - if (overlay === null) { - return context.json({ language: record.language, overlay: null, variables: {} }); - } - if (overlay.elements.some((element) => element.kind === ADS_COUNTDOWN_ELEMENT_KIND)) { - await hydrateCachedAdsCountdownSnapshot(context.env, record.channelId); + try { + const overlayId = await getOverlayBindingForToken(context.env.DB, record.channelId, record.tokenId); + if (overlayId === null) { + return context.json({ language: record.language, overlay: null, variables: {} }); + } + const overlay = await getOverlayForChannel(context.env.DB, record.channelId, overlayId); + if (overlay === null) { + return context.json({ language: record.language, overlay: null, variables: {} }); + } + if (overlay.elements.some((element) => element.kind === ADS_COUNTDOWN_ELEMENT_KIND)) { + await hydrateCachedAdsCountdownSnapshot(context.env, record.channelId); + } + const variables = await getOverlayVariableValues(context.env.DB, record.channelId, overlayId); + const contextModuleIds = [...new Set(overlay.elements.flatMap((element) => { + const declaration = moduleOverlayElementForKind(element.kind); + return declaration?.definition.initialStateNeedsContext === true ? [declaration.module.id] : []; + }))]; + const elementContext = contextModuleIds.length === 0 ? undefined : new Map(await Promise.all( + contextModuleIds.map(async (moduleId) => [ + moduleId, + await createOverlayElementContext(context.env, record.channelId, moduleId, record.language, Date.now()), + ] as const), + )); + const elements = await hydrateModuleOverlayElements( + context.env.DB, + record.channelId, + overlay.elements, + MODULES, + elementContext === undefined ? undefined : (moduleId) => elementContext.get(moduleId), + ); + const responseNow = new Date().toISOString(); + return context.json({ + language: record.language, + overlay: { + id: overlay.id, + revision: overlay.revision, + width: overlay.width, + height: overlay.height, + css: overlay.css, + elements: elements.map((element) => { + const state = element.state !== undefined && element.state !== null && typeof element.state.serverNow === "string" + ? { ...element.state, serverNow: responseNow } + : element.state; + return { + id: element.id, + kind: element.kind, + label: element.label, + variableName: element.variableName, + text: element.text, + config: element.config, + ...(element.moduleEnabled === undefined ? {} : { moduleEnabled: element.moduleEnabled }), + ...(state === undefined ? {} : { state }), + x: element.x, + y: element.y, + scalePercent: element.scalePercent, + z: element.z, + inComposition: element.inComposition, + }; + }), + }, + variables, + }); + } finally { + // Hydration may populate shared API or weather caches after token auth's + // own committed-touch notification has already run. + await notifyCommittedResources(context.env, record.channelId); } - const variables = await getOverlayVariableValues(context.env.DB, record.channelId, overlayId); - const contextModuleIds = [...new Set(overlay.elements.flatMap((element) => { - const declaration = moduleOverlayElementForKind(element.kind); - return declaration?.definition.initialStateNeedsContext === true ? [declaration.module.id] : []; - }))]; - const elementContext = contextModuleIds.length === 0 ? undefined : new Map(await Promise.all( - contextModuleIds.map(async (moduleId) => [ - moduleId, - await createOverlayElementContext(context.env, record.channelId, moduleId, record.language, Date.now()), - ] as const), - )); - const elements = await hydrateModuleOverlayElements( - context.env.DB, - record.channelId, - overlay.elements, - MODULES, - elementContext === undefined ? undefined : (moduleId) => elementContext.get(moduleId), - ); - const responseNow = new Date().toISOString(); - return context.json({ - language: record.language, - overlay: { - id: overlay.id, - revision: overlay.revision, - width: overlay.width, - height: overlay.height, - css: overlay.css, - elements: elements.map((element) => { - const state = element.state !== undefined && element.state !== null && typeof element.state.serverNow === "string" - ? { ...element.state, serverNow: responseNow } - : element.state; - return { - id: element.id, - kind: element.kind, - label: element.label, - variableName: element.variableName, - text: element.text, - config: element.config, - ...(element.moduleEnabled === undefined ? {} : { moduleEnabled: element.moduleEnabled }), - ...(state === undefined ? {} : { state }), - x: element.x, - y: element.y, - scalePercent: element.scalePercent, - z: element.z, - inComposition: element.inComposition, - }; - }), - }, - variables, - }); }); authRouter.get("/api/overlay/variables/:name", async (context) => { @@ -346,6 +360,7 @@ authRouter.get("/api/overlay/variables/:name", async (context) => { token, pepper: context.env.OVERLAY_TOKEN_PEPPER, now: nowIso(), + onCommittedTouch: (channelId) => notifyCommittedResources(context.env, channelId), }); if (record === null) return context.json({ error: "overlay_token_invalid" satisfies ApiErrorCode }, 401); diff --git a/src/worker/config.ts b/src/worker/config.ts index fe550b1b..0e3bfb68 100644 --- a/src/worker/config.ts +++ b/src/worker/config.ts @@ -19,8 +19,8 @@ export const REQUIRED_SECRET_NAMES = [ // update it and /healthz reports a freshly set-up database as broken (503) // even though everything is fine. `tests/unit/schema-baseline.test.ts` // therefore pins it to the last file in `migrations/`. -export const LATEST_SCHEMA_MIGRATION = "0046_chat_voting_kind.sql"; -export const LATEST_SCHEMA_TABLE = "chat_votes"; +export const LATEST_SCHEMA_MIGRATION = "0047_panel_resource_revisions.sql"; +export const LATEST_SCHEMA_TABLE = "panel_resource_revisions"; const REQUIRED_BINDING_NAMES = ["DB", "CHANNEL", "ASSETS", "CF_VERSION_METADATA"] as const; diff --git a/src/worker/dispatch.ts b/src/worker/dispatch.ts index 45630b03..829c3ccb 100644 --- a/src/worker/dispatch.ts +++ b/src/worker/dispatch.ts @@ -11,7 +11,6 @@ import { fetchTwitchUserByLogin, sendShoutout } from "./shoutout"; import { isTwitchChannelModerator, sendModerationBan } from "./moderation"; import { publishRealtimeMessages, publishVariablesChanged } from "./realtime"; import { prepareModuleOverlayHostEventMessages, prepareModuleOverlayRealtimeMessage } from "./module-overlay-realtime"; -import { publishModulePanelHint } from "./module-panel-realtime"; import { writeModuleDiagnostics, type WrittenModuleDiagnostic } from "./event-log"; import { authorizeModuleMutation } from "./module-authorization"; import { getAppAccessToken } from "./app-token"; @@ -960,12 +959,6 @@ export const dispatchEventSubNotification = async ( DB: environment.DB, authorizeMutation: authorizeModuleMutation, ballots: moduleBallots(environment.CHANNEL, event.channelId, module.id), - publishModulePanelHint: (part) => publishModulePanelHint( - environment.CHANNEL, - event.channelId, - module.id, - part, - ), readChannelBlockedTerms: channelBlockedTerms, botUserId, lookupUserByLogin: async (login) => { diff --git a/src/worker/durable/ChannelObject.ts b/src/worker/durable/ChannelObject.ts index a9989fcf..a2a5110c 100644 --- a/src/worker/durable/ChannelObject.ts +++ b/src/worker/durable/ChannelObject.ts @@ -46,7 +46,12 @@ import { broadcasterHasScope } from "../broadcaster-scope"; import { helixRequest } from "../twitch/helix"; import { TWITCH_RATE_LIMIT_COOLDOWN_MS } from "../twitch/rate-limit"; import { prepareModuleOverlayRealtimeMessage } from "../module-overlay-realtime"; -import { modulePanelHintMessage } from "../module-panel-realtime"; +import { + notifyCommittedResources, + panelResourceChangedMessage, + readPanelResourceRevisions, + type PanelResourceRevisionVector, +} from "../panel-resources"; import { createModuleSecretAccess } from "../module-secrets"; import { sendChatMessage } from "../chat"; import { sendModerationBan } from "../moderation"; @@ -102,6 +107,7 @@ const D1_IN_PARAMETER_LIMIT = 100; const FIXED_D1_PARAMETER_COUNT = 2; const D1_IDS_PER_QUERY = D1_IN_PARAMETER_LIMIT - FIXED_D1_PARAMETER_COUNT; const ALARM_TABLE_KEY = "channel:alarm_schedule"; +const PANEL_DURABLE_REVISIONS_KEY = "channel:panel_resource_revisions"; const CHAT_ACTIVITY_COUNT_KEY = "channel:chat_activity_count"; const LEGACY_ACTIVE_CHATTER_STREAM_KEY = "chatter:stream"; const ACTIVE_CHATTER_TABLE = "active_chatters"; @@ -123,6 +129,7 @@ const TWITCH_RETRY_AFTER_KEY = "twitch:retry_after"; const AD_COUNTDOWN_REFRESH_ATTEMPT_KEY = "ads:countdown_refresh_attempt"; const AD_COUNTDOWN_REFRESH_DEADLINE_KEY = "ads:countdown_refresh_deadline"; const AD_COUNTDOWN_REFRESH_RETRY_COUNT_KEY = "ads:countdown_refresh_retry_count"; +const PANEL_PUBLISHED_REVISIONS_KEY = "panel:published_resource_revisions"; const OVERLAY_STREAM_DETAILS_CACHE_KEY = "overlay:stream_details"; const CHANNEL_GAME_ID_CACHE_KEY = "channel:game_id"; // A failed lookup retries almost immediately instead of sitting on the full @@ -131,6 +138,16 @@ const CHANNEL_GAME_ID_CACHE_KEY = "channel:game_id"; const CHANNEL_GAME_ID_FAILURE_CACHE_TTL_MS = 5_000; const AD_SCHEDULE_REFRESH_TIMEOUT_MS = 15_000; const AD_COUNTDOWN_SCHEDULE_TTL_MS = 60_000; + +const bumpDurablePanelResources = async ( + transaction: Pick, + resources: readonly string[], +): Promise => { + const revisions = await transaction.get(PANEL_DURABLE_REVISIONS_KEY) ?? {}; + const next = { ...revisions }; + for (const resource of new Set(resources)) next[resource] = (next[resource] ?? 0) + 1; + await transaction.put(PANEL_DURABLE_REVISIONS_KEY, next); +}; const AD_COUNTDOWN_REFRESH_BUFFER_MS = 30_000; const AD_COUNTDOWN_REFRESH_RETRY_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000] as const; const ALARM_HANDLER_RETRY_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000] as const; @@ -636,6 +653,7 @@ export class ChannelObject extends DurableObject { const previousCountdownState = previous === undefined ? null : adCountdownStateForSchedule(previous.schedule, previous.asOf); await transaction.put(AD_SCHEDULE_GENERATION_KEY, currentGeneration + 1); await transaction.put(AD_SCHEDULE_CACHE_KEY, cache); + await bumpDurablePanelResources(transaction, ["module:ads:schedule"]); return { changed: previous === undefined || JSON.stringify(previous.schedule) !== JSON.stringify(schedule), countdownChanged: previousCountdownState === null || @@ -646,11 +664,14 @@ export class ChannelObject extends DurableObject { }; }); if (change === null) return null; + await this.notifyCommittedPanelResources(); if (reconcileAlarm) await this.reconcileAdPrewarning(schedule, grantedScopes); try { await writeAdCountdownState(this.env.DB, channelId, countdownState, asOf); } catch (error: unknown) { console.warn("Ad countdown state snapshot could not be stored.", error); + } finally { + await this.notifyCommittedPanelResources(); } if (change.changed) await this.notifyScheduleInputsChanged("event_times"); await this.reconcileAdCountdownRefresh(schedule); @@ -668,14 +689,6 @@ export class ChannelObject extends DurableObject { console.warn("Ad countdown overlay update could not be sent.", error); } } - await this.publish([{ - version: 1, - id: crypto.randomUUID(), - createdAt: asOf, - channelId, - type: "ads.schedule.updated", - payload: { schedule, asOf }, - }]); return change.changed; }); } @@ -1287,8 +1300,22 @@ export class ChannelObject extends DurableObject { }, storage: { get: (key: string) => this.ctx.storage.get(`${storagePrefix}${key}`), - put: (key: string, value: unknown) => this.ctx.storage.put(`${storagePrefix}${key}`, value), - delete: (key: string) => this.ctx.storage.delete(`${storagePrefix}${key}`), + put: async (key: string, value: unknown) => { + await this.ctx.storage.transaction(async (transaction) => { + await transaction.put(`${storagePrefix}${key}`, value); + await bumpDurablePanelResources(transaction, [`module:${moduleId}:data`]); + }); + await this.notifyCommittedPanelResources(); + }, + delete: async (key: string) => { + const deleted = await this.ctx.storage.transaction(async (transaction) => { + const removed = await transaction.delete(`${storagePrefix}${key}`); + if (removed) await bumpDurablePanelResources(transaction, [`module:${moduleId}:data`]); + return removed; + }); + if (deleted) await this.notifyCommittedPanelResources(); + return deleted; + }, }, schedule: async (key, deadline, ownerRevision) => { const scheduledRegistration = MODULES.find((module) => module.id === moduleId)?.alarms?.find((alarm) => alarm.key === key); @@ -1332,15 +1359,6 @@ export class ChannelObject extends DurableObject { }); if (prepared.outcome === "ready") await this.publish([prepared.message]); }, - publishModulePanelHint: async (part) => { - const message = modulePanelHintMessage(channelId, moduleId, part); - if (message === null) return; - try { - await this.publish([message]); - } catch { - console.warn("Module panel update could not be sent."); - } - }, sendChat: async (text, idempotencyKey, attributions = [], stillValid, target = "source_only") => { const suppression = { reason: null as string | null }; const validateOutput = async (): Promise => { @@ -1562,7 +1580,11 @@ export class ChannelObject extends DurableObject { undefined, invocation, ); - return await this.runModuleAlarmHandler(moduleId, handlerKey, execute); + try { + return await this.runModuleAlarmHandler(moduleId, handlerKey, execute); + } finally { + await this.notifyCommittedPanelResources(); + } } private ballotAccess(moduleId: string): ModuleBallotAccess { @@ -1570,8 +1592,8 @@ export class ChannelObject extends DurableObject { open: (ballotId, optionCount, expiresAt, rule, termFilter) => this.openBallot(moduleId, ballotId, optionCount, expiresAt, rule, termFilter), cast: (ballotId, userId, choice, options) => this.castBallot(moduleId, ballotId, userId, choice, options), castTerm: (ballotId, userId, term, matchText) => this.castBallotTerm(moduleId, ballotId, userId, term, matchText), - setBlockedTerms: (ballotId, terms) => setStoredBallotBlockedTerms(this.ctx.storage, moduleId, ballotId, terms), - approveTerm: (ballotId, term) => approveStoredBallotTerm(this.ctx.storage, moduleId, ballotId, term), + setBlockedTerms: (ballotId, terms) => setStoredBallotBlockedTerms(this.ballotStorage(moduleId), moduleId, ballotId, terms), + approveTerm: (ballotId, term) => approveStoredBallotTerm(this.ballotStorage(moduleId), moduleId, ballotId, term), read: (ballotId) => this.readBallot(moduleId, ballotId), close: (ballotId) => this.closeBallot(moduleId, ballotId), finalize: (ballotId) => this.finalizeBallot(moduleId, ballotId), @@ -1592,7 +1614,7 @@ export class ChannelObject extends DurableObject { if (channelId === null) throw new Error("Ballots require a named Durable Object."); const requestedAlarmKey = ballotExpiryAlarmKey(moduleId, ballotId); const result = await openStoredBallot( - this.ctx.storage, + this.ballotStorage(moduleId), moduleId, ballotId, optionCount, @@ -1613,6 +1635,9 @@ export class ChannelObject extends DurableObject { }, termFilter, ); + if (result.status === "opened" || result.activeBallot !== undefined) { + await this.notifyCommittedPanelResources(); + } if (result.status === "opened") { return { status: "opened" }; } @@ -1628,7 +1653,11 @@ export class ChannelObject extends DurableObject { ): Promise { const channelId = this.ownChannelId(); if (channelId === null) return { status: "not_open", counts: [], revision: 0 }; - return await castStoredBallot(this.ctx.storage, channelId, moduleId, ballotId, userId, choice, options); + const result = await castStoredBallot(this.ballotStorage(moduleId), channelId, moduleId, ballotId, userId, choice, options); + if (result.status === "counted" || result.status === "changed") { + await this.notifyCommittedPanelResources(); + } + return result; } public async castBallotTerm( @@ -1640,7 +1669,11 @@ export class ChannelObject extends DurableObject { ): Promise { const channelId = this.ownChannelId(); if (channelId === null) return { status: "not_open", counts: [], revision: 0 }; - return await castStoredBallotTerm(this.ctx.storage, channelId, moduleId, ballotId, userId, term, matchText); + const result = await castStoredBallotTerm(this.ballotStorage(moduleId), channelId, moduleId, ballotId, userId, term, matchText); + if (result.status === "counted" || result.status === "changed" || result.status === "overflow") { + await this.notifyCommittedPanelResources(); + } + return result; } public async setBlockedTerms( @@ -1648,7 +1681,9 @@ export class ChannelObject extends DurableObject { ballotId: string, blockedTerms: readonly string[], ): Promise { - return await setStoredBallotBlockedTerms(this.ctx.storage, moduleId, ballotId, blockedTerms); + const snapshot = await setStoredBallotBlockedTerms(this.ballotStorage(moduleId), moduleId, ballotId, blockedTerms); + if (snapshot !== null) await this.notifyCommittedPanelResources(); + return snapshot; } public async approveTerm( @@ -1666,11 +1701,15 @@ export class ChannelObject extends DurableObject { WHERE channel_id = ? AND poll_id = ? AND term = ?`, ).bind(channelId, ballotId, term).first<{ approved: number }>(); if (approval === null) return { status: "not_open", snapshot: null }; - return await approveStoredBallotTerm(this.ctx.storage, moduleId, ballotId, term); + const result = await approveStoredBallotTerm(this.ballotStorage(moduleId), moduleId, ballotId, term); + if (result.status === "approved" && result.snapshot !== null) { + await this.notifyCommittedPanelResources(); + } + return result; } public async readBallot(moduleId: string, ballotId: string): Promise { - return await readStoredBallot(this.ctx.storage, moduleId, ballotId, async (transaction, finalizedModuleId, finalizedBallotId, hardDeleteAt) => { + const snapshot = await readStoredBallot(this.ballotStorage(moduleId), moduleId, ballotId, async (transaction, finalizedModuleId, finalizedBallotId, hardDeleteAt) => { await this.writeBallotAlarmInTransaction( transaction, ballotExpiryAlarmKey(finalizedModuleId, finalizedBallotId), @@ -1678,10 +1717,14 @@ export class ChannelObject extends DurableObject { hardDeleteAt, ); }); + if (snapshot?.outcome !== undefined) { + await this.notifyCommittedPanelResources(); + } + return snapshot; } public async hasOpenBallot(): Promise { - return await hasOpenStoredBallot(this.ctx.storage, async (transaction, moduleId, ballotId, hardDeleteAt) => { + const hasOpen = await hasOpenStoredBallot(this.ballotStorage(CHAT_VOTING_MODULE_ID), async (transaction, moduleId, ballotId, hardDeleteAt) => { await this.writeBallotAlarmInTransaction( transaction, ballotExpiryAlarmKey(moduleId, ballotId), @@ -1689,16 +1732,19 @@ export class ChannelObject extends DurableObject { hardDeleteAt, ); }); + if (!hasOpen) await this.notifyCommittedPanelResources(); + return hasOpen; } public async closeBallot(moduleId: string, ballotId: string): Promise { - const result = await closeStoredBallot(this.ctx.storage, moduleId, ballotId); + const result = await closeStoredBallot(this.ballotStorage(moduleId), moduleId, ballotId); + if (result !== null) await this.notifyCommittedPanelResources(); await this.clearAlarmEntry(ballotExpiryAlarmKey(moduleId, ballotId)); return result; } public async finalizeBallot(moduleId: string, ballotId: string): Promise { - return await finalizeStoredBallot(this.ctx.storage, moduleId, ballotId, async (transaction, finalizedModuleId, finalizedBallotId, hardDeleteAt) => { + const result = await finalizeStoredBallot(this.ballotStorage(moduleId), moduleId, ballotId, async (transaction, finalizedModuleId, finalizedBallotId, hardDeleteAt) => { await this.writeBallotAlarmInTransaction( transaction, ballotExpiryAlarmKey(finalizedModuleId, finalizedBallotId), @@ -1706,6 +1752,10 @@ export class ChannelObject extends DurableObject { hardDeleteAt, ); }); + if (result.outcome !== "not_open" && result.outcome !== "open") { + await this.notifyCommittedPanelResources(); + } + return result; } public async acknowledgeClosedBallot(moduleId: string, ballotId: string): Promise { @@ -1716,7 +1766,7 @@ export class ChannelObject extends DurableObject { const identity = ballotIdentityFromExpiryAlarmKey(key); if (identity === null) return; const nextExpiry = await expireStoredBallot( - this.ctx.storage, + this.ballotStorage(identity.moduleId), identity.moduleId, identity.ballotId, async (transaction, moduleId, ballotId, hardDeleteAt) => { @@ -1725,6 +1775,7 @@ export class ChannelObject extends DurableObject { ); }, ); + if (nextExpiry === null) await this.notifyCommittedPanelResources(); if (nextExpiry !== null) { await this.scheduleAlarmEntry(key, BALLOT_EXPIRY_ALARM_HANDLER, nextExpiry); } @@ -1733,10 +1784,107 @@ export class ChannelObject extends DurableObject { private async hardDeleteBallot(key: string): Promise { const identity = ballotIdentityFromExpiryAlarmKey(key); if (identity === null) return; - const retryAt = await hardDeleteFinalizedStoredBallot(this.ctx.storage, identity.moduleId, identity.ballotId); + const retryAt = await hardDeleteFinalizedStoredBallot(this.ballotStorage(identity.moduleId), identity.moduleId, identity.ballotId); if (retryAt !== null) await this.scheduleAlarmEntry(key, BALLOT_HARD_DELETE_ALARM_HANDLER, retryAt); } + private ballotPanelResources(moduleId: string): string[] { + return [...new Set([`module:${moduleId}:panel`, "module:chat_voting:panel"])]; + } + + /** Wraps ballot storage transactions so the vector commits with ballot state. */ + private ballotStorage(moduleId: string): Pick { + const storage = this.ctx.storage; + const transaction = ( + operation: (transaction: DurableObjectTransaction) => Promise, + ): Promise => storage.transaction(async (underlying) => { + const changedResources = new Set(); + const recordWrite = (keyArgument: unknown): void => { + const keys = typeof keyArgument === "string" ? [keyArgument] + : Array.isArray(keyArgument) ? keyArgument.filter((key): key is string => typeof key === "string") + : typeof keyArgument === "object" && keyArgument !== null ? Object.keys(keyArgument) : []; + for (const key of keys) { + if (key === "ballot:active") { + this.ballotPanelResources(moduleId).forEach((resource) => changedResources.add(resource)); + continue; + } + const match = /^ballot:(?:closed:)?([A-Za-z0-9_-]{1,128}):/u.exec(key); + if (match?.[1] !== undefined) this.ballotPanelResources(match[1]).forEach((resource) => changedResources.add(resource)); + } + }; + const monitored = new Proxy(underlying, { + get: (target, property, receiver) => { + if (property === "put" || property === "delete") { + const method = Reflect.get(target, property, target) as (...args: unknown[]) => unknown; + return (...args: unknown[]): unknown => { + recordWrite(args[0]); + return Reflect.apply(method, target, args); + }; + } + const value: unknown = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) as unknown : value; + }, + }); + const result = await operation(monitored); + if (changedResources.size > 0) await bumpDurablePanelResources(underlying, [...changedResources]); + return result; + }); + return { + get: storage.get.bind(storage), + list: storage.list.bind(storage), + put: storage.put.bind(storage), + delete: storage.delete.bind(storage), + transaction, + }; + } + + private async notifyCommittedPanelResources(): Promise { + const channelId = this.ownChannelId(); + if (channelId === null) return; + await notifyCommittedResources(this.env, channelId, (revisions) => this.reconcilePanelResources(revisions)); + } + + public async getPanelResourceRevisions(): Promise { + return await this.ctx.storage.get(PANEL_DURABLE_REVISIONS_KEY) ?? {}; + } + + /** Delivers only advanced, data-free resources to panel principals in this channel. */ + public async reconcilePanelResources(revisions: PanelResourceRevisionVector): Promise { + const channelId = this.ownChannelId(); + if (channelId === null) return; + const durableRevisions = await this.getPanelResourceRevisions(); + const combinedRevisions = { ...revisions }; + for (const [resource, revision] of Object.entries(durableRevisions)) { + combinedRevisions[resource] = (combinedRevisions[resource] ?? 0) + revision; + } + const channelRevisionTotal = Object.entries(combinedRevisions) + .filter(([resource]) => resource !== "channel.all") + .reduce((sum, [, revision]) => Math.min(Number.MAX_SAFE_INTEGER, sum + revision), 0); + const previous = await this.ctx.storage.get(PANEL_PUBLISHED_REVISIONS_KEY) ?? {}; + const advanced = Object.entries(combinedRevisions) + .filter(([resource, revision]) => revision > (previous[resource] ?? 0)) + .sort(([left], [right]) => left.localeCompare(right)); + if (advanced.length === 0) return; + const next = Object.fromEntries(advanced); + const overflow = advanced.length > 128; + // Large batches carry one bounded wake-up hint; the authenticated GET + // still returns the exact per-resource vector for client invalidation. + const resources = overflow ? ["channel.all"] : advanced.map(([resource]) => resource); + const messageRevisions = overflow + ? { "channel.all": channelRevisionTotal } + : next; + const message = panelResourceChangedMessage(channelId, resources, messageRevisions); + await this.publish([message]); + await this.ctx.storage.transaction(async (transaction) => { + const latest = await transaction.get(PANEL_PUBLISHED_REVISIONS_KEY) ?? {}; + const published = { ...latest }; + for (const [resource, revision] of Object.entries(combinedRevisions)) { + published[resource] = Math.max(published[resource] ?? 0, revision); + } + await transaction.put(PANEL_PUBLISHED_REVISIONS_KEY, published); + }); + } + private async writeBallotAlarmInTransaction( transaction: AlarmStorageAccess & Pick, key: string, @@ -2114,6 +2262,14 @@ export class ChannelObject extends DurableObject { closeSocket(pair[1], OVERLAY_ACCESS_BOUND_CLOSE_CODE, OVERLAY_ACCESS_BOUND_CLOSE_REASON); } else { pair[1].send(JSON.stringify(envelopeFor(ownChannelId, "system.hello"))); + if (principal.kind === "panel") { + try { + const revisions = await readPanelResourceRevisions(this.env.DB, ownChannelId); + await this.reconcilePanelResources(revisions); + } catch (error: unknown) { + console.warn("Panel resource revision snapshot could not be sent.", error); + } + } } return new Response(null, { status: 101, @@ -2133,6 +2289,7 @@ export class ChannelObject extends DurableObject { } const now = Date.now(); const ordinarySockets = messages.some((message) => message.type !== "overlay.changed" && + message.type !== "panel.resources.changed" && !isModuleOverlayRealtimeEnvelope(message)) ? this.ctx.getWebSockets() : []; @@ -2162,11 +2319,10 @@ export class ChannelObject extends DurableObject { ...this.ctx.getWebSockets("kind:panel"), ...(overlaySocketsById.get(envelope.payload.overlayId) ?? []), ] + : envelope.type === "panel.resources.changed" + ? this.ctx.getWebSockets("kind:panel") : isModuleOverlayRealtimeEnvelope(envelope) - ? [ - ...this.ctx.getWebSockets("kind:panel"), - ...new Set((envelope.overlayIds ?? []).flatMap((overlayId) => overlaySocketsById.get(overlayId) ?? [])), - ] + ? [...new Set((envelope.overlayIds ?? []).flatMap((overlayId) => overlaySocketsById.get(overlayId) ?? []))] : ordinarySockets, })); for (const message of serialized) { @@ -2465,9 +2621,13 @@ export class ChannelObject extends DurableObject { const now = Date.now(); const nowIso = new Date(now).toISOString(); const externalFetchBudget = createModuleExternalFetchBudget(); - await this.pruneRevokedTokenMarkers(now); - await this.stopSecurityAlarmIfIdle(); - await this.dispatchDueAlarmEntries(now, nowIso, this.alarmHandlers(MODULES, externalFetchBudget)); + try { + await this.pruneRevokedTokenMarkers(now); + await this.stopSecurityAlarmIfIdle(); + await this.dispatchDueAlarmEntries(now, nowIso, this.alarmHandlers(MODULES, externalFetchBudget)); + } finally { + await this.notifyCommittedPanelResources(); + } } override webSocketMessage(webSocket: WebSocket, message: string | ArrayBuffer): void { diff --git a/src/worker/eventsub.ts b/src/worker/eventsub.ts index b0b54478..49efffc5 100644 --- a/src/worker/eventsub.ts +++ b/src/worker/eventsub.ts @@ -6,6 +6,7 @@ import { confirmLoginIdentityAuthorization } from "./login-maintenance"; import { dispatchEventSubNotification } from "./dispatch"; import { refreshAdPrewarning, isAdPrewarningTrigger } from "./ad-prewarning"; import { eventSubDefinitionForCondition } from "./eventsub-subscriptions"; +import { notifyCommittedResources } from "./panel-resources"; import type { EventSubSubscriptionType } from "../contracts/values"; import { hasEventSubMessage, @@ -204,6 +205,14 @@ const notificationTarget = (body: Record): { }; }; +/** Resolves the channel from the registered subscription condition for the worker notifier. */ +export const eventSubChannelIdForNotification = (value: unknown, now = new Date().toISOString()): string | null => { + if (!isRecord(value)) return null; + const target = notificationTarget(value); + if (target !== null) return target.channelId; + return subscriptionRecord(value, now)?.channelId ?? null; +}; + const confirmRevocationAuthorization = async ( env: Env, revocation: EventSubRevocationRecord, @@ -267,47 +276,60 @@ eventSubRouter.post("/api/twitch/eventsub", async (context) => { } const now = new Date().toISOString(); + const notifierChannelId = eventSubChannelIdForNotification(body, now); const revocation = messageType === "revocation" ? subscriptionRecord(body, now) : null; - if (messageType === "revocation" && revocation === null) return response("Invalid revocation.", 400); + if (messageType === "revocation" && (revocation === null || notifierChannelId !== revocation.channelId)) { + return response("Invalid revocation.", 400); + } - if (revocation !== null) { - if (await hasEventSubMessage(context.env.DB, messageId)) return response(null, 204); - const authorizationConfirmedRevoked = await confirmRevocationAuthorization(context.env, revocation, now); - const isNew = await rememberEventSubMessageAndRevocation( - context.env.DB, - messageId, - now, - revocation, - authorizationConfirmedRevoked, - ); - if (!isNew) return response(null, 204); - return response(null, 204); + if (messageType === "revocation" && revocation !== null && notifierChannelId !== null) { + try { + if (await hasEventSubMessage(context.env.DB, messageId)) return response(null, 204); + const authorizationConfirmedRevoked = await confirmRevocationAuthorization(context.env, revocation, now); + const isNew = await rememberEventSubMessageAndRevocation( + context.env.DB, + messageId, + now, + revocation, + authorizationConfirmedRevoked, + ); + if (!isNew) return response(null, 204); + return response(null, 204); + } finally { + await notifyCommittedResources(context.env, revocation.channelId); + } } const isNew = await rememberEventSubMessage(context.env.DB, messageId, now); if (!isNew) return response(null, 204); const target = notificationTarget(body); - if (target === null) return response("Invalid EventSub notification.", 400); + if (target === null || notifierChannelId === null || target.channelId !== notifierChannelId) { + return response("Invalid EventSub notification.", 400); + } // Deliberately awaited instead of run in the background: a chat call is // short, and this keeps the outcome visible in tests. If dispatch later // takes longer, it belongs after the response. - await dispatchEventSubNotification(context.env, { - channelId: target.channelId, - subscriptionType: target.subscriptionType, - subscriptionVariant: target.subscriptionVariant, - triggerId: messageId, - payload: target.payload, - receivedAt: now, - eventSubTimestamp: timestamp, - }); - if (isAdPrewarningTrigger(target.subscriptionType)) { - try { - await refreshAdPrewarning(context.env, target.channelId, messageId, now); - } catch (error: unknown) { - console.error("Ad prewarning could not be refreshed.", error); + try { + await dispatchEventSubNotification(context.env, { + channelId: target.channelId, + subscriptionType: target.subscriptionType, + subscriptionVariant: target.subscriptionVariant, + triggerId: messageId, + payload: target.payload, + receivedAt: now, + eventSubTimestamp: timestamp, + }); + if (isAdPrewarningTrigger(target.subscriptionType)) { + try { + await refreshAdPrewarning(context.env, target.channelId, messageId, now); + } catch (error: unknown) { + console.error("Ad prewarning could not be refreshed.", error); + } } + return response(null, 204); + } finally { + await notifyCommittedResources(context.env, notifierChannelId); } - return response(null, 204); }); diff --git a/src/worker/index.ts b/src/worker/index.ts index c0ee5fae..908c3b77 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -8,10 +8,12 @@ import { panelRouter } from "./panel/routes"; import { realtimeRouter } from "./realtime"; import { scheduled } from "./scheduled"; import { serverTimingMiddleware } from "./server-timing"; +import { notifyCommittedResources, PANEL_RESOURCE_NOTIFIER_COVERAGE } from "./panel-resources"; export { ChannelObject } from "./durable/ChannelObject"; const app = new Hono<{ Bindings: Env }>(); +const mutatingMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); const overlayContentSecurityPolicy = (publicOrigin: string): string => { const publicUrl = new URL(publicOrigin); @@ -76,6 +78,32 @@ const overlayApiPreflight = (): Response => addWildcardCors( ); app.use("/api/*", serverTimingMiddleware); +app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes, async (context, next) => { + try { + await next(); + } finally { + const channelId = context.req.param("channelId"); + if (channelId.length > 0) { + await notifyCommittedResources(context.env, channelId); + } + } +}); +app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.platformRoutes, async (context, next) => { + try { + await next(); + } finally { + if (mutatingMethods.has(context.req.method.toUpperCase())) { + await notifyCommittedResources(context.env); + } + } +}); +app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.authRoutes, async (context, next) => { + try { + await next(); + } finally { + await notifyCommittedResources(context.env); + } +}); app.route("/", authRouter); app.route("/", platformRouter); app.route("/", panelRouter); diff --git a/src/worker/module-overlay-realtime.ts b/src/worker/module-overlay-realtime.ts index a7267e24..bd8b6b82 100644 --- a/src/worker/module-overlay-realtime.ts +++ b/src/worker/module-overlay-realtime.ts @@ -1,5 +1,5 @@ import type { BotModule, ModuleAction, ModuleOverlayHostEvent } from "../modules/contract"; -import { isPanelModuleRealtimeMessageType, type ModuleOverlayRealtimeEnvelope } from "../realtime-contract"; +import type { ModuleOverlayRealtimeEnvelope } from "../realtime-contract"; import { MODULES } from "../modules/registry"; const OVERLAY_PAYLOAD_MAXIMUM_BYTES = 4_096; @@ -74,7 +74,7 @@ export const prepareModuleOverlayRealtimeMessage = async ( ORDER BY element.overlay_id`, ).bind(channelId, action.elementKind, ...recipientBindings, mandatory ? 1 : 0, moduleId).all<{ overlay_id: string }>(); const overlayIds = [...new Set(recipients.results.map(({ overlay_id }) => overlay_id))]; - if (overlayIds.length === 0 && !isPanelModuleRealtimeMessageType(`modul.${moduleId}.${action.type}`)) { + if (overlayIds.length === 0) { return { outcome: "no_recipients" }; } diff --git a/src/worker/module-panel-realtime.ts b/src/worker/module-panel-realtime.ts deleted file mode 100644 index 39e8741a..00000000 --- a/src/worker/module-panel-realtime.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { PanelModuleRealtimeEnvelope, PanelModuleRealtimeMessageType, PanelModuleHintPart } from "../realtime-contract"; - -type PanelModuleHint = - | { moduleId: "chat_voting"; part: "panel" } - | { moduleId: "belabox"; part: "live" } - | { moduleId: "votekick"; part: "panel" | "availability" }; - -const panelHintType = (moduleId: string, part: PanelModuleHintPart): PanelModuleRealtimeMessageType | null => { - if (moduleId === "chat_voting" && part === "panel" || - moduleId === "belabox" && part === "live" || - moduleId === "votekick" && (part === "panel" || part === "availability")) { - return `modul.${moduleId}.changed` as PanelModuleRealtimeMessageType; - } - return null; -}; - -/** Creates an allowlisted panel hint whose payload is exactly one bounded `part` field. */ -export const modulePanelHintMessage = ( - channelId: string, - moduleId: string, - part: PanelModuleHintPart, -): PanelModuleRealtimeEnvelope | null => { - const type = panelHintType(moduleId, part); - if (type === null) return null; - const hint: PanelModuleHint = moduleId === "chat_voting" - ? { moduleId, part: "panel" } - : moduleId === "belabox" - ? { moduleId, part: "live" } - : { moduleId: "votekick", part: part as "panel" | "availability" }; - return { - version: 1, - id: crypto.randomUUID(), - createdAt: new Date().toISOString(), - channelId, - type, - payload: { part: hint.part }, - } as PanelModuleRealtimeEnvelope; -}; - -/** Sends a panel hint without consulting overlay recipients or carrying module state. */ -export const publishModulePanelHint = async ( - namespace: Env["CHANNEL"] | undefined, - channelId: string, - moduleId: string, - part: PanelModuleHintPart, -): Promise => { - if (namespace === undefined) return; - const message = modulePanelHintMessage(channelId, moduleId, part); - if (message === null) return; - try { - const object = namespace.get(namespace.idFromName(channelId)); - await object.publish([message]); - } catch { - console.warn("Module panel update could not be sent."); - } -}; diff --git a/src/worker/panel-resources.ts b/src/worker/panel-resources.ts new file mode 100644 index 00000000..418fd683 --- /dev/null +++ b/src/worker/panel-resources.ts @@ -0,0 +1,156 @@ +import type { RealtimeEnvelope } from "../realtime-contract"; + +export type PanelResourceRevisionVector = Readonly>; + +export interface PanelResourceRevisionRow { + resource: string; + revision: number; +} + +const MAX_PANEL_RESOURCES = 128; + +export const PANEL_RESOURCE_NOTIFIER_COVERAGE = { + channelRoutes: "/api/channels/:channelId/*", + moduleRoutes: "channelRoutes", + platformRoutes: "/api/platform/*", + authRoutes: "/auth/*", + eventSubRoute: "/api/twitch/eventsub", + eventSubHandler: "eventSubRouter notification/revocation finally", + channelAlarm: "ChannelObject.alarm.finally", + scheduledMaintenance: "scheduled.finally", +} as const; + +const reconcileChannelResources = async ( + env: Pick, + channelId: string, +): Promise => { + const revisions = await readPanelResourceRevisions(env.DB, channelId); + await env.CHANNEL.get(env.CHANNEL.idFromName(channelId)).reconcilePanelResources(revisions); + return revisions; +}; + +const publishPendingSharedResourceChanges = async ( + env: Pick, + alreadyReconciled = new Map(), +): Promise => { + const pending = await env.DB.prepare( + `SELECT global.resource, global.revision + FROM panel_global_resource_revisions AS global + LEFT JOIN panel_global_resource_publications AS published USING (resource) + WHERE global.resource IN ('channels', 'weather.cache', 'api.cache') + AND global.revision > COALESCE(published.revision, 0)`, + ).all<{ resource: string; revision: number }>(); + if (pending.results.length === 0) return; + const channels = await env.DB.prepare("SELECT channel_id FROM channels").all<{ channel_id: string }>(); + await Promise.all(channels.results + .filter(({ channel_id }) => { + const vector = alreadyReconciled.get(channel_id); + return vector === undefined || pending.results.some(({ resource, revision }) => (vector[resource] ?? 0) < revision); + }) + .map(({ channel_id }) => reconcileChannelResources(env, channel_id))); + await env.DB.batch(pending.results.map(({ resource, revision }) => env.DB.prepare( + `INSERT INTO panel_global_resource_publications (resource, revision) VALUES (?, ?) + ON CONFLICT(resource) DO UPDATE SET revision = MAX(revision, excluded.revision)`, + ).bind(resource, revision))); +}; + +/** Reads the durable D1 vector, including cross-channel query resources. */ +export const readPanelResourceRevisions = async ( + database: D1Database, + channelId: string, +): Promise => { + const result = await database.prepare( + `SELECT resource, revision + FROM panel_resource_revisions + WHERE channel_id = ? + UNION ALL + SELECT resource, revision + FROM panel_global_resource_revisions`, + ).bind(channelId).all(); + return Object.fromEntries(result.results.map(({ resource, revision }) => [resource, revision])); +}; + +/** D1 and DO counters are summed for resources written in both transaction domains. */ +export const readCompletePanelResourceRevisions = async ( + env: Pick, + channelId: string, +): Promise => { + const [databaseRevisions, durableObjectRevisions] = await Promise.all([ + readPanelResourceRevisions(env.DB, channelId), + env.CHANNEL.get(env.CHANNEL.idFromName(channelId)).getPanelResourceRevisions(), + ]); + const revisions = { ...databaseRevisions }; + for (const [resource, revision] of Object.entries(durableObjectRevisions)) { + revisions[resource] = (revisions[resource] ?? 0) + revision; + } + return revisions; +}; + +/** + * D1 triggers and Durable Object transactions each persist revisions beside + * their own committed data. The panel vector combines both counter domains. + */ +/** + * The one panel resource notifier. D1 triggers advance revisions in the same + * commit as D1 writes; `resources` is for DO-only commits. Publication is a + * best-effort wake-up because the durable vector repairs a lost hint on the + * next hint, reconnect, or focus comparison. + */ +export const notifyCommittedResources = async ( + env: Pick, + channelId?: string, + reconcileLocal?: (revisions: PanelResourceRevisionVector) => Promise, +): Promise => { + if (channelId === undefined) { + try { + const channels = await env.DB.prepare("SELECT channel_id FROM channels").all<{ channel_id: string }>(); + const reconciled = new Map(); + await Promise.all(channels.results.map(async ({ channel_id }) => { + reconciled.set(channel_id, await reconcileChannelResources(env, channel_id)); + })); + await publishPendingSharedResourceChanges(env, reconciled); + } catch (error: unknown) { + console.warn("Cross-channel panel resources could not be published.", error); + } + return; + } + const reconciled = new Map(); + try { + const exists = await env.DB.prepare("SELECT 1 AS present FROM channels WHERE channel_id = ?") + .bind(channelId).first<{ present: number }>(); + if (exists !== null) { + const revisions = reconcileLocal === undefined + ? await reconcileChannelResources(env, channelId) + : await readPanelResourceRevisions(env.DB, channelId); + if (reconcileLocal !== undefined) await reconcileLocal(revisions); + reconciled.set(channelId, revisions); + } + } catch (error: unknown) { + console.warn("Committed panel resources could not be published.", error); + } + try { + await publishPendingSharedResourceChanges(env, reconciled); + } catch (error: unknown) { + console.warn("Shared panel resources could not be published.", error); + } +}; + +export const panelResourceChangedMessage = ( + channelId: string, + resources: readonly string[], + revisions: PanelResourceRevisionVector, +): RealtimeEnvelope<"panel.resources.changed"> => { + const boundedResources = [...new Set(resources)].sort((left, right) => left.localeCompare(right)).slice(0, MAX_PANEL_RESOURCES); + const boundedRevisions = Object.fromEntries(boundedResources.flatMap((resource) => { + const revision = revisions[resource]; + return revision === undefined ? [] : [[resource, revision] as const]; + })); + return { + version: 1, + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + channelId, + type: "panel.resources.changed", + payload: { resources: boundedResources, revisions: boundedRevisions }, + }; +}; diff --git a/src/worker/panel/module-routes.ts b/src/worker/panel/module-routes.ts index 373785fa..88694854 100644 --- a/src/worker/panel/module-routes.ts +++ b/src/worker/panel/module-routes.ts @@ -30,7 +30,6 @@ import { findChannelVariable } from "../db/channel-variables"; import { measureServerTiming, recordServerTiming, scheduleBackgroundWork } from "../server-timing"; import { publishOverlayChanged, publishOverlayHostEvent as publishOverlayHostEventHint, publishRealtimeMessages } from "../realtime"; import { prepareModuleOverlayRealtimeMessage } from "../module-overlay-realtime"; -import { publishModulePanelHint } from "../module-panel-realtime"; import { DEFAULT_CHANNEL_TIME_ZONE } from "../../modules/contract"; import { readChannelVariables } from "../db/channel-variables"; import { readChannelLocation } from "../db/channel-settings"; @@ -58,7 +57,6 @@ interface ModuleRouteEnvironment { | "notifyScheduleInputsChanged" | "validateTemplateContentMutation" | "resolveTextBlockConditions" | "publishModuleOverlayMessage" - | "publishModulePanelHint" | "publishOverlayHostEvent" | "templateUsageSources" | "listRegisteredTemplateVariables" | "liftModerationBan" @@ -311,8 +309,6 @@ moduleRouter.use("/api/channels/:channelId/*", (context, next) => { console.warn("Module overlay update could not be sent.", error); } }); - context.set("publishModulePanelHint", (channelId, moduleId, part) => - publishModulePanelHint(context.env.CHANNEL, channelId, moduleId, part)); context.set("publishOverlayHostEvent", async (channelId: string, event: ModuleOverlayHostEvent) => { await publishOverlayHostEventHint(context.env.CHANNEL, context.env.DB, channelId, event); }); diff --git a/src/worker/panel/routes.ts b/src/worker/panel/routes.ts index fde12476..4218f9da 100644 --- a/src/worker/panel/routes.ts +++ b/src/worker/panel/routes.ts @@ -61,6 +61,7 @@ import { TWITCH_RATE_LIMIT_COOLDOWN_MS } from "../twitch/rate-limit"; import { getChannelModuleForChannel } from "../db/channel-modules"; import { measureServerTiming, scheduleBackgroundWork } from "../server-timing"; import { DEFAULT_CHANNEL_TIME_ZONE, validChannelTimeZone } from "../../modules/contract"; +import { readCompletePanelResourceRevisions } from "../panel-resources"; interface PanelEnvironment { Bindings: Env; @@ -227,6 +228,15 @@ const isChannelControlKind = (value: string): value is ChannelControlKind => export const panelRouter = new Hono(); +panelRouter.get( + "/api/channels/:channelId/revisions", + requireChannelAuthorization(), + async (context) => { + const channelId = context.req.param("channelId"); + return context.json({ revisions: await readCompletePanelResourceRevisions(context.env, channelId) }); + }, +); + panelRouter.route("/", memberRouter); panelRouter.route("/", moduleRouter); panelRouter.route("/", variableRouter); diff --git a/src/worker/realtime.ts b/src/worker/realtime.ts index c9187435..586e80ca 100644 --- a/src/worker/realtime.ts +++ b/src/worker/realtime.ts @@ -17,6 +17,7 @@ import { import { getOverlayBindingForToken } from "./auth/overlay-token-repository"; import { REALTIME_PRINCIPAL_HEADER, REALTIME_PROTOCOL } from "./realtime-protocol"; import type { ApiErrorCode } from "../contracts/values"; +import { notifyCommittedResources } from "./panel-resources"; interface RealtimeRouteEnvironment { Bindings: Env; @@ -130,6 +131,7 @@ realtimeRouter.get("/ws/overlay", async (context) => { token, pepper: context.env.OVERLAY_TOKEN_PEPPER, now: new Date().toISOString(), + onCommittedTouch: (channelId) => notifyCommittedResources(context.env, channelId), }); if (record === null) return context.json({ error: "overlay_token_invalid" satisfies ApiErrorCode }, 401); diff --git a/src/worker/scheduled.ts b/src/worker/scheduled.ts index 300dad7b..032e500d 100644 --- a/src/worker/scheduled.ts +++ b/src/worker/scheduled.ts @@ -11,6 +11,7 @@ import { eventSubMessageCutoff } from "./eventsub"; import { maintainStreamStates } from "./stream-state-lookup"; import { purgeRevokedOverlayAccesses } from "./auth/overlay-access-repository"; import { MODULES } from "../modules/registry"; +import { notifyCommittedResources } from "./panel-resources"; export const scheduled: NonNullable["scheduled"]> = async ( _controller, @@ -38,7 +39,14 @@ export const scheduled: NonNullable["scheduled"]> = async ( if (new Date(now).getUTCHours() === EVENT_LOG_DAILY_TRIM_HOUR) { tasks.push(trimEventLogToLimit(env.DB), purgeRevokedOverlayAccesses(env.DB, now)); } - const work = Promise.all(tasks).then(() => undefined); + const work = Promise.allSettled(tasks) + .then((results) => { + const rejected = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (rejected !== undefined) throw rejected.reason; + }) + .finally(async () => { + await notifyCommittedResources(env); + }); executionContext.waitUntil(work); await work; }; diff --git a/tests/components/ads-panel.test.tsx b/tests/components/ads-panel.test.tsx index e537232f..8ceff0e3 100644 --- a/tests/components/ads-panel.test.tsx +++ b/tests/components/ads-panel.test.tsx @@ -6,8 +6,7 @@ import { UiProvider } from "../../src/dashboard/ui"; import { toastsSnapshot } from "../../src/dashboard/ui/toast-store"; import type { AdsScheduleResponse } from "../../src/modules/ads/contracts"; import { moduleQueryKey } from "../../src/dashboard/data/module-query"; -import { invalidateDashboardRealtimeMessage, setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; -import type { RealtimeMessage } from "../../src/realtime-contract"; +import { reconcileDashboardPanelResourceRevisions, setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; import { renderWithQuery } from "../query-test-utils"; import { jsonResponse } from "../unit/fixtures"; @@ -28,10 +27,14 @@ const requestPath = (input: RequestInfo | URL): string => input instanceof Reque ? new URL(input.url).pathname : input instanceof URL ? input.pathname : new URL(input, "https://brobot.example").pathname; -const renderAds = (fetcher: typeof fetch, ownRole: "manager" | "operator" = "manager"): ReturnType => { +const renderAds = ( + fetcher: typeof fetch, + ownRole: "manager" | "operator" = "manager", + channelId = "kanal-a", +): ReturnType => { vi.stubGlobal("fetch", fetcher); return renderWithQuery( { expect(screen.getByText(/11:00/)).toBeInTheDocument(); }); - it("updates the cached schedule and its as-of label from panel realtime", async () => { + it("updates the cached schedule after its resource revision advances", async () => { + const channelId = "ads-revisions-a"; const original = { ...schedule, schedule: { ...schedule.schedule, nextAdAt: "2026-09-24T17:00:00.000Z", duration: 60 }, @@ -200,15 +204,18 @@ describe("Ad settings editor declaration", () => { }; const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }); let serverSchedule = original; + let revision = 1; const fetcher = vi.fn((input, init) => { const path = input instanceof Request ? new URL(input.url).pathname : new URL(String(input), "https://brobot.example").pathname; + if (path === `/api/channels/${channelId}/revisions`) return Promise.resolve(jsonResponse({ revisions: { "module:ads:schedule": revision } })); if (path === "/api/csrf") return Promise.resolve(jsonResponse({ token: "csrf" })); if (path.endsWith("/settings")) return Promise.resolve(jsonResponse({ settings, revision: 1, variables: [] })); if (path.endsWith("/schedule")) return Promise.resolve(jsonResponse(serverSchedule)); if (path.endsWith("/snooze") && init?.method === "POST") return Promise.resolve(jsonResponse(serverSchedule)); return Promise.resolve(jsonResponse({}, 404)); }); - const rendered = renderAds(fetcher); + const rendered = renderAds(fetcher, "manager", channelId); + await act(async () => { await reconcileDashboardPanelResourceRevisions(rendered.queryClient, channelId); }); expect(await screen.findByText(`Stand ${formatter.format(new Date(original.asOf))}`)).toBeInTheDocument(); serverSchedule = { @@ -216,25 +223,22 @@ describe("Ad settings editor declaration", () => { schedule: { ...original.schedule, nextAdAt: "2026-09-24T18:00:00.000Z", snoozeCount: 1 }, asOf: "2026-09-24T13:00:00.000Z", }; - invalidateDashboardRealtimeMessage(rendered.queryClient, { - version: 1, - id: "ads-schedule-2", - createdAt: "2026-09-24T13:00:00.000Z", - channelId: "kanal-a", - type: "ads.schedule.updated", - payload: { schedule: serverSchedule.schedule, asOf: serverSchedule.asOf }, - } satisfies RealtimeMessage); + revision = 2; + await act(async () => { await reconcileDashboardPanelResourceRevisions(rendered.queryClient, channelId); }); expect(await screen.findByText(`Stand ${formatter.format(new Date("2026-09-24T13:00:00.000Z"))}`)).toBeInTheDocument(); - expect(rendered.queryClient.getQueryState(moduleQueryKey("kanal-a", "ads", "schedule"))?.isInvalidated).toBe(false); + expect(rendered.queryClient.getQueryState(moduleQueryKey(channelId, "ads", "schedule"))?.isInvalidated).toBe(false); }); - it("keeps the newest realtime schedule when it arrives before the initial response", async () => { + it("keeps the newest schedule when its revision advances before the initial response", async () => { + const channelId = "ads-revisions-b"; const formatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }); let serverSchedule = { ...schedule, asOf: "2026-09-24T12:00:00.000Z" }; let scheduleCalls = 0; + let revision = 1; const fetcher = vi.fn((input, init) => { const path = input instanceof Request ? new URL(input.url).pathname : new URL(String(input), "https://brobot.example").pathname; + if (path === `/api/channels/${channelId}/revisions`) return Promise.resolve(jsonResponse({ revisions: { "module:ads:schedule": revision } })); if (path.endsWith("/schedule")) { scheduleCalls += 1; return Promise.resolve(jsonResponse(scheduleCalls === 1 ? { ...serverSchedule, asOf: "2026-09-24T12:00:00.000Z" } : serverSchedule)); @@ -244,8 +248,9 @@ describe("Ad settings editor declaration", () => { if (path.endsWith("/snooze") && init?.method === "POST") return Promise.resolve(jsonResponse(serverSchedule)); return Promise.resolve(jsonResponse({}, 404)); }); - const rendered = renderAds(fetcher); + const rendered = renderAds(fetcher, "manager", channelId); await waitFor(() => { expect(scheduleCalls).toBe(1); }); + await act(async () => { await reconcileDashboardPanelResourceRevisions(rendered.queryClient, channelId); }); const newerAsOf = "2026-09-24T13:00:00.000Z"; serverSchedule = { @@ -253,14 +258,8 @@ describe("Ad settings editor declaration", () => { schedule: { ...schedule.schedule, nextAdAt: "2026-09-24T18:00:00.000Z", snoozeCount: 0 }, asOf: newerAsOf, }; - invalidateDashboardRealtimeMessage(rendered.queryClient, { - version: 1, - id: "ads-schedule-early", - createdAt: newerAsOf, - channelId: "kanal-a", - type: "ads.schedule.updated", - payload: { schedule: serverSchedule.schedule, asOf: newerAsOf }, - } satisfies RealtimeMessage); + revision = 2; + await act(async () => { await reconcileDashboardPanelResourceRevisions(rendered.queryClient, channelId); }); expect(await screen.findByText(`Stand ${formatter.format(new Date(newerAsOf))}`)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Snooze · 0 verfügbar/ })).toBeDisabled(); diff --git a/tests/components/belabox-alert-surfaces.test.tsx b/tests/components/belabox-alert-surfaces.test.tsx index 29eab8a5..61f5b696 100644 --- a/tests/components/belabox-alert-surfaces.test.tsx +++ b/tests/components/belabox-alert-surfaces.test.tsx @@ -7,7 +7,7 @@ import type { BelaboxStatusResponse } from "../../src/modules/belabox/contracts" import BelaboxPanel from "../../src/modules/belabox/panel/index"; import BelaboxStatusAction from "../../src/modules/belabox/panel/immediate-actions"; import { UiProvider } from "../../src/dashboard/ui"; -import { invalidateDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; +import { reconcileDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; import type { RealtimeMessage } from "../../src/realtime-contract"; import { renderWithQuery as render } from "../query-test-utils"; @@ -53,14 +53,20 @@ describe("BELABOX alert surfaces", () => { }); it("reloads status when stream availability changes from offline to live", async () => { - const fetcher = vi.fn(() => Promise.resolve(Response.json(status))); + let statusReads = 0; + const fetcher = vi.fn((input) => { + const url = new URL(input instanceof Request ? input.url : String(input), window.location.href); + if (url.pathname.endsWith("/revisions")) return Promise.resolve(Response.json({ revisions: { "module:belabox:live": 1 } })); + statusReads += 1; + return Promise.resolve(Response.json(status)); + }); vi.stubGlobal("fetch", fetcher); const view = renderWithUi(); expect(await screen.findByTestId("belabox-immediate-status-slot")).toHaveTextContent("Stream is offline."); - expect(fetcher).toHaveBeenCalledTimes(1); + expect(statusReads).toBe(1); view.rerender(); - invalidateDashboardRealtimeMessage(view.queryClient, { + await reconcileDashboardRealtimeMessage(view.queryClient, { version: 1, id: "stream-now-live", createdAt: new Date().toISOString(), @@ -69,7 +75,7 @@ describe("BELABOX alert surfaces", () => { payload: { state: "online", startedAt: new Date().toISOString(), changedAt: new Date().toISOString() }, } satisfies RealtimeMessage); - await waitFor(() => { expect(fetcher).toHaveBeenCalledTimes(2); }); + await waitFor(() => { expect(statusReads).toBe(2); }); expect(await screen.findByTestId("belabox-immediate-status-slot")).toHaveTextContent(/BELABOX encoder disconnected|BELABOX-Encoder getrennt/u); }); diff --git a/tests/components/belabox-panel.test.tsx b/tests/components/belabox-panel.test.tsx index cd92e6c0..0af245e4 100644 --- a/tests/components/belabox-panel.test.tsx +++ b/tests/components/belabox-panel.test.tsx @@ -7,7 +7,7 @@ import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store import { renderWithQuery as render } from "../query-test-utils"; import BelaboxPanel from "../../src/modules/belabox/panel"; import { moduleQueryKey } from "../../src/dashboard/data/module-query"; -import { invalidateDashboardRealtimeMessage, setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; +import { reconcileDashboardPanelResourceRevisions, setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; const mocks = vi.hoisted(() => ({ loadBelaboxStatus: vi.fn(), @@ -43,6 +43,7 @@ describe("BELABOX panel", () => { afterEach(() => { cleanup(); + vi.unstubAllGlobals(); setDashboardRealtimeStatus("kanal-a", "offline"); for (const toast of toastsSnapshot()) dismissToast(toast.id); vi.resetAllMocks(); @@ -138,7 +139,7 @@ describe("BELABOX panel", () => { expect(mocks.loadBelaboxStreams).not.toHaveBeenCalled(); }); - it("refreshes status and history after a Belabox realtime hint", async () => { + it("refreshes status and history after the Belabox resource revision advances", async () => { mocks.loadBelaboxStatus .mockResolvedValueOnce({ configured: true, updatedAt: null, mode: "on_demand", sample: null, errorCode: null, @@ -148,18 +149,17 @@ describe("BELABOX panel", () => { configured: true, updatedAt: null, mode: "interval", sample: null, errorCode: null, polling: true, pollingDesired: true, intervalSeconds: 15, streamId: "stream-42", belaboxStreamId: "stream-42", }); + let revision = 0; + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions: { "module:belabox:live": revision } }), { + status: 200, headers: { "Content-Type": "application/json" }, + })))); const view = render(<>); await waitFor(() => { expect(mocks.loadBelaboxStatus).toHaveBeenCalledOnce(); }); expect(mocks.loadBelaboxHistory).not.toHaveBeenCalled(); - act(() => invalidateDashboardRealtimeMessage(view.queryClient, { - version: 1, - id: "belabox-live-changed", - createdAt: "2026-10-09T08:00:00.000Z", - channelId: "channel-a", - type: "modul.belabox.changed", - payload: { part: "live" }, - })); + await act(async () => { await reconcileDashboardPanelResourceRevisions(view.queryClient, "channel-a"); }); + revision = 1; + await act(async () => { await reconcileDashboardPanelResourceRevisions(view.queryClient, "channel-a"); }); await waitFor(() => { expect(mocks.loadBelaboxStatus).toHaveBeenCalledTimes(2); diff --git a/tests/components/channel-variables-page.test.tsx b/tests/components/channel-variables-page.test.tsx index 2736c2f0..cfaf2813 100644 --- a/tests/components/channel-variables-page.test.tsx +++ b/tests/components/channel-variables-page.test.tsx @@ -6,7 +6,7 @@ import { ChannelVariablesPage } from "../../src/dashboard/ChannelVariablesPage"; import { UiProvider } from "../../src/dashboard/ui"; import { ToastHost } from "../../src/dashboard/ui/Toast"; import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store"; -import { invalidateDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; +import { reconcileDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; import type { RealtimeMessage } from "../../src/realtime-contract"; import { jsonResponse } from "../unit/fixtures"; import { renderWithQuery as render } from "../query-test-utils"; @@ -328,9 +328,11 @@ describe("Channel variables page", () => { it("revalidates variables from the shared query after a realtime hint", async () => { let variableRequestCount = 0; + const revisions = { "channel.variables": 1 }; const fetcher = vi.fn(); fetcher.mockImplementation((input) => { const url = new URL(input instanceof Request ? input.url : String(input), window.location.href); + if (url.pathname.endsWith("/revisions")) return Promise.resolve(jsonResponse({ revisions })); if (url.pathname.endsWith("/overlay-tokens")) return Promise.resolve(jsonResponse({ tokens: [], nextOffset: null })); variableRequestCount++; return Promise.resolve(jsonResponse({ variables: [variable], count: 1, maximum: 25 })); @@ -341,7 +343,7 @@ describe("Channel variables page", () => { await screen.findByRole("table"); expect(variableRequestCount).toBe(1); - invalidateDashboardRealtimeMessage(rendered.queryClient, { + await reconcileDashboardRealtimeMessage(rendered.queryClient, { version: 1, id: "variables-changed-1", createdAt: "2026-09-24T12:00:00.000Z", diff --git a/tests/components/dashboard.test.tsx b/tests/components/dashboard.test.tsx index 93428426..a4067e6d 100644 --- a/tests/components/dashboard.test.tsx +++ b/tests/components/dashboard.test.tsx @@ -8,7 +8,7 @@ import { emptyEventFilter } from "../../src/dashboard/events/model"; import { WarningsAndErrorsFeed } from "../../src/dashboard/stream-manager"; import { UiProvider } from "../../src/dashboard/ui"; import { queryKeys } from "../../src/dashboard/data/keys"; -import { renderWithQuery as render } from "../query-test-utils"; +import { renderWithQuery as renderWithQueryBase, type DashboardQueryTestOptions } from "../query-test-utils"; import { jsonResponse } from "../unit/fixtures"; const moderator = { @@ -73,12 +73,38 @@ const audit = { entries: [], nextCursor: null }; type DashboardRoute = (url: URL) => Response | Promise | undefined; +let activePanelRevisions: Record = {}; + +const render = ( + ui: Parameters[0], + options?: Parameters[1], + queryOptions: DashboardQueryTestOptions = {}, +): ReturnType => { + activePanelRevisions = { ...(queryOptions.panelRevisions ?? {}) }; + const rendered = renderWithQueryBase(ui, options, { + ...queryOptions, + panelRevisions: activePanelRevisions, + }); + const previousFetch = globalThis.fetch; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = requestUrl(input); + if (/^\/api\/channels\/[^/]+\/revisions$/u.test(url.pathname)) { + return Promise.resolve(jsonResponse({ revisions: activePanelRevisions })); + } + return previousFetch(input, init); + })); + return rendered; +}; + const stubDashboardFetch = ( route: DashboardRoute, channels: Array<{ bot?: unknown }> = [healthyChannel("kanal-a", "Alpha")], ) => { const fetcher = vi.fn((input: RequestInfo | URL) => { const url = requestUrl(input); + if (/^\/api\/channels\/[^/]+\/revisions$/u.test(url.pathname)) { + return jsonResponse({ revisions: activePanelRevisions }); + } if (url.pathname === "/api/channels") { return jsonResponse({ channels, bot: channels[0]?.bot }); } @@ -181,6 +207,14 @@ class TestWebSocket { } receive(data: string): void { + const message = JSON.parse(data) as { type?: unknown }; + if (message.type === "event_log.new") { + activePanelRevisions["channel.events"] = (activePanelRevisions["channel.events"] ?? 0) + 1; + } else if (message.type === "stream.state.changed") { + for (const resource of ["channel.overview", "channels", "module:belabox:live"]) { + activePanelRevisions[resource] = (activePanelRevisions[resource] ?? 0) + 1; + } + } this.emit("message", new MessageEvent("message", { data })); } @@ -197,6 +231,7 @@ class TestWebSocket { describe("Dashboard skeleton", () => { beforeEach(() => { + activePanelRevisions = {}; Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: 1440 }); window.history.replaceState({}, "", "/"); Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); @@ -205,6 +240,7 @@ describe("Dashboard skeleton", () => { afterEach(() => { cleanup(); + activePanelRevisions = {}; TestWebSocket.reset(); vi.unstubAllGlobals(); vi.useRealTimers(); @@ -613,7 +649,7 @@ describe("Dashboard skeleton", () => { }, channel); window.history.replaceState({}, "", "/channels/kanal-a/events"); - render(); + render(, undefined, { panelRevisions: { "channel.events": 1 } }); expect(await screen.findByText("Raid von raider_b mit 1 Zuschauern")).toBeInTheDocument(); const initialRequest = fetcher.mock.calls.map(([input]) => requestUrl(input)).find((url) => url.pathname.endsWith("/events")); @@ -648,7 +684,7 @@ describe("Dashboard skeleton", () => { }, channel); window.history.replaceState({}, "", "/channels/kanal-a/events"); - render(); + render(, undefined, { panelRevisions: { "channel.events": 1 } }); expect(await screen.findByText("alt")).toBeInTheDocument(); const feed = document.querySelector(".event-feed"); @@ -663,7 +699,7 @@ describe("Dashboard skeleton", () => { serverEntries = [neu, alt]; socket.receive(JSON.stringify({ version: 1, id: "message-lower", createdAt: "2026-09-18T04:00:01.000Z", channelId: "kanal-a", type: "event_log.new", payload: { entries: [{ eventId: neu.eventId, createdAt: neu.createdAt, moduleId: neu.moduleId, code: neu.code, actorUserId: null }] } })); - expect(await screen.findByRole("button", { name: "1 neue Ereignisse" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "1 neue Ereignisse" }, { timeout: 2_000 })).toBeInTheDocument(); // The query refreshes while the rows the reader is looking at stay frozen. await new Promise((resolve) => { setTimeout(resolve, 400); }); expect(screen.queryByText("Raid von unbekannt mit 8 Zuschauern")).not.toBeInTheDocument(); @@ -792,7 +828,7 @@ describe("Dashboard skeleton", () => { }, channel); window.history.replaceState({}, "", "/channels/kanal-a/events"); - render(); + render(, undefined, { panelRevisions: { "channel.events": 1 } }); expect(await screen.findByText("alt")).toBeInTheDocument(); const socket = TestWebSocket.instances[0]; @@ -2392,7 +2428,7 @@ describe("Dashboard skeleton", () => { vi.stubGlobal("WebSocket", TestWebSocket); window.history.replaceState({}, "", "/channels/kanal-a"); - render(); + render(, undefined, { panelRevisions: { "channel.overview": 1 } }); await screen.findByRole("button", { name: "Automatische Aktionen fortsetzen" }); await waitFor(() => { expect(TestWebSocket.instances).toHaveLength(1); }); TestWebSocket.instances[0]?.open(); @@ -2479,7 +2515,7 @@ describe("Dashboard skeleton", () => { expect(screen.getByRole("button", { name: "Stummschaltung aufheben" })).toBeInTheDocument(); }); - it("polls shell queries while realtime is offline and stops polling after connect", async () => { + it("polls shell queries while realtime is offline and stops polling while connected", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-23T09:00:00.000Z")); const channel = { ...healthyChannel("kanal-a", "Alpha"), modules: [] }; @@ -2568,7 +2604,7 @@ describe("Dashboard skeleton", () => { })); window.history.replaceState({}, "", "/channels/kanal-a"); - render(); + render(, undefined, { panelRevisions: { "channel.overview": 1 } }); await flushQueryUpdates(); expect(channelCalls).toBe(1); expect(overviewCalls).toBe(1); @@ -3343,13 +3379,13 @@ describe("Dashboard skeleton", () => { })); window.history.replaceState({}, "", "/channels/kanal-a"); - render(); + render(, undefined, { panelRevisions: { "channel.overview": 1 } }); expect(await screen.findByRole("heading", { name: "Alpha", level: 1 })).toBeInTheDocument(); await waitFor(() => expect(TestWebSocket.instances).toHaveLength(1)); const socket = TestWebSocket.instances[0]; if (socket === undefined) throw new Error("Realtime-Socket fehlt"); socket.open(); - await waitFor(() => expect(channelsCalls).toBe(2)); + await waitFor(() => expect(channelsCalls).toBe(1)); const changedAt = relativeIso(1_000); serverChannel = { @@ -3380,11 +3416,12 @@ describe("Dashboard skeleton", () => { }, }, })); + await waitFor(() => expect(channelsCalls).toBe(2)); await act(async () => { resolveDelayedChannels?.(jsonResponse({ channels: [channel], bot: channel.bot })); await delayedChannels; }); - await waitFor(() => expect(channelsCalls).toBe(3)); + await waitFor(() => expect(channelsCalls).toBe(2)); expect(await screen.findByText(/^Live ·/)).toBeInTheDocument(); expect(document.querySelector(".dashboard-header__stream")).toHaveAttribute("data-state", "live"); @@ -3505,7 +3542,7 @@ describe("Dashboard skeleton", () => { }, [initialChannel]); window.history.replaceState({}, "", "/channels/kanal-a"); - render(); + render(, undefined, { panelRevisions: { "channel.overview": 1 } }); await screen.findByRole("heading", { name: "Alpha", level: 1 }); await waitFor(() => expect(overviewCalls).toBe(1)); diff --git a/tests/components/events-page.test.tsx b/tests/components/events-page.test.tsx index faaccf5c..fa7a898d 100644 --- a/tests/components/events-page.test.tsx +++ b/tests/components/events-page.test.tsx @@ -12,6 +12,8 @@ import { dashboardAuthenticationRequiredEvent } from "../../src/dashboard/data/e import { renderWithQuery as renderWithQueryBase, type DashboardQueryTestOptions } from "../query-test-utils"; import { useRealtimePanelMessages } from "../../src/dashboard/realtime"; +let activePanelRevisions: Record = {}; + const RealtimeShell = ({ children }: { children: ReactNode }) => { useRealtimePanelMessages("kanal-a", true); return <>{children}; @@ -21,7 +23,25 @@ const renderWithQuery = ( ui: ReactElement, options?: Omit, queryOptions: DashboardQueryTestOptions = {}, -) => renderWithQueryBase({ui}, options, queryOptions); +): ReturnType => { + activePanelRevisions = { ...(queryOptions.panelRevisions ?? {}) }; + const rendered = renderWithQueryBase({ui}, options, { + ...queryOptions, + panelRevisions: activePanelRevisions, + }); + const previousFetch = globalThis.fetch; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = requestUrl(input); + if (/^\/api\/channels\/[^/]+\/revisions$/u.test(url.pathname)) { + return Promise.resolve(new Response(JSON.stringify({ revisions: activePanelRevisions }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + } + return previousFetch(input, init); + })); + return rendered; +}; const entry = (overrides: Partial): PanelEventEntry => ({ eventId: "event-1", @@ -88,6 +108,10 @@ class TestEventsWebSocket { } receive(data: string): void { + const message = JSON.parse(data) as { type?: unknown }; + if (message.type === "event_log.new") { + activePanelRevisions["channel.events"] = (activePanelRevisions["channel.events"] ?? 0) + 1; + } this.emit("message", new MessageEvent("message", { data })); } @@ -140,6 +164,7 @@ const positionEventReader = (container: HTMLElement) => { afterEach(() => { cleanup(); vi.unstubAllGlobals(); + activePanelRevisions = {}; for (const restore of positionSpies) restore(); positionSpies.clear(); Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 }); @@ -208,6 +233,7 @@ describe("EventsPage failure cause icon", () => { { gcTime: 600_000, staleTime: 0, + panelRevisions: { "channel.events": 1 }, initialData: [{ queryKey: dashboardDataKeys.events("kanal-a", filters), data: { pages: [{ entries: [cachedEntry], nextCursor: null }], pageParams: [null] }, @@ -535,6 +561,7 @@ describe("EventsPage failure cause icon", () => { { gcTime: 600_000, staleTime: 30_000, + panelRevisions: { "channel.events": 1 }, initialData: [{ queryKey: dashboardDataKeys.events("kanal-a", emptyEventFilter), data: { pages: [{ entries: [initialEntry], nextCursor: null }], pageParams: [null] }, @@ -1137,7 +1164,7 @@ describe("EventsPage failure cause icon", () => { const view = renderWithQuery( undefined} />, undefined, - { gcTime: 600_000 }, + { gcTime: 600_000, panelRevisions: { "channel.events": 1 } }, ); expect(await screen.findByText("Chat-Nachricht fehlgeschlagen")).toBeVisible(); const socket = TestEventsWebSocket.instances[0]; diff --git a/tests/components/overlay-editor-page.test.tsx b/tests/components/overlay-editor-page.test.tsx index 123616ae..c0d67d02 100644 --- a/tests/components/overlay-editor-page.test.tsx +++ b/tests/components/overlay-editor-page.test.tsx @@ -1028,7 +1028,7 @@ describe("Overlay composition editor", () => { render(); expect(await screen.findByRole("heading", { name: "Kanalvariablen", level: 1 })).toBeInTheDocument(); - fireEvent.click(screen.getByText("{var.score}")); + fireEvent.click(await screen.findByText("{var.score}")); fireEvent.click(await screen.findByRole("button", { name: "In Overlay verwenden" })); fireEvent.click(await screen.findByRole("button", { name: "Editor öffnen" })); expect(await screen.findByRole("heading", { name: "Overlay score", level: 1 })).toBeInTheDocument(); diff --git a/tests/components/stream-manager.test.tsx b/tests/components/stream-manager.test.tsx index 40632a83..436d1f12 100644 --- a/tests/components/stream-manager.test.tsx +++ b/tests/components/stream-manager.test.tsx @@ -8,7 +8,7 @@ import { ToastHost } from "../../src/dashboard/ui/Toast"; import { dismissToast, toastsSnapshot } from "../../src/dashboard/ui/toast-store"; import type { PanelChannelOverview } from "../../src/panel-contract"; import { ChannelNotices, ImmediateActions, WarningsAndErrorsFeed } from "../../src/dashboard/stream-manager"; -import { invalidateDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; +import { reconcileDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; import type { RealtimeMessage } from "../../src/realtime-contract"; import { jsonResponse } from "../unit/fixtures"; import { renderWithQuery as render } from "../query-test-utils"; @@ -451,7 +451,11 @@ describe("Stream Manager warnings and errors feed", () => { }; let requests = 0; vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { - if (requestUrl(input).pathname === "/api/channels/kanal-a/events") { + const pathname = requestUrl(input).pathname; + if (pathname === "/api/channels/kanal-a/revisions") { + return Promise.resolve(jsonResponse({ revisions: { "channel.events": 1, "module:host:data": 1 } })); + } + if (pathname === "/api/channels/kanal-a/events") { requests += 1; const next = { eventId: "realtime-error", @@ -472,14 +476,14 @@ describe("Stream Manager warnings and errors feed", () => { const rendered = renderWithMantine(); expect(await screen.findByText("Werbeeinblendung nicht gestartet: Stream ist offline")).toBeInTheDocument(); - act(() => invalidateDashboardRealtimeMessage(rendered.queryClient, { + await act(async () => { await reconcileDashboardRealtimeMessage(rendered.queryClient, { version: 1, id: "hint-1", createdAt: "2026-09-22T10:01:00.000Z", channelId: "kanal-a", type: "event_log.new", payload: { entries: [{ eventId: "realtime-error", createdAt: "2026-09-22T10:01:00.000Z", moduleId: "host", code: "host.action.failed", actorUserId: null }] }, - } satisfies RealtimeMessage)); + } satisfies RealtimeMessage); }); expect(await screen.findByText("Aktion fehlgeschlagen")).toBeInTheDocument(); expect(requests).toBe(2); diff --git a/tests/e2e/host-stable-layout.spec.ts b/tests/e2e/host-stable-layout.spec.ts index dd9d54ac..7054e3a5 100644 --- a/tests/e2e/host-stable-layout.spec.ts +++ b/tests/e2e/host-stable-layout.spec.ts @@ -3,6 +3,7 @@ import { expect, test, type Page } from "@playwright/test"; test.use({ locale: "en-US" }); const channelId = "stable-host-layout"; +let panelRevisions: Record = {}; const channel = { channelId, login: "stable-channel", @@ -66,8 +67,13 @@ interface ChannelMockData { } const installChannelMocks = async (page: Page, gates: ChannelGates = {}, data: ChannelMockData = {}): Promise => { + panelRevisions = {}; await page.route("**/api/**", async (route) => { const pathname = new URL(route.request().url()).pathname; + if (pathname === `/api/channels/${channelId}/revisions`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ revisions: panelRevisions }) }); + return; + } if (pathname === "/api/channels") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ channels: data.channelList ?? [channel], bot: channel.bot }) }); return; @@ -717,6 +723,7 @@ test("the new-events notice stays visible while reading older events", async ({ }); await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); events.entries.unshift(newEvent); + panelRevisions["channel.events"] = 1; const refreshResponse = page.waitForResponse((response) => response.url().includes(`/api/channels/${channelId}/events`)); await page.evaluate((timestamp) => { const sockets = (window as Window & { __layoutEventSockets?: Array<{ open: () => void; receive: (data: string) => void }> }).__layoutEventSockets ?? []; diff --git a/tests/e2e/realtime-navigation.spec.ts b/tests/e2e/realtime-navigation.spec.ts index 9b86618a..2da875e1 100644 --- a/tests/e2e/realtime-navigation.spec.ts +++ b/tests/e2e/realtime-navigation.spec.ts @@ -25,11 +25,17 @@ const channel = { pause: { active: false, until: null, mode: null }, }, lastError: null, + modules: [ + { id: "text_commands", enabled: true, settings: "{}" }, + { id: "chat_voting", enabled: true, settings: "{}" }, + ], }; const installSocketCounter = async (page: Page): Promise => { await page.addInitScript(() => { type TrackedSocket = EventTarget & { url: string; readyState: number }; + const browser = window as Window & { __realtimePanelAutoOpen?: boolean }; + browser.__realtimePanelAutoOpen = true; const sockets: TrackedSocket[] = []; class MockWebSocket extends EventTarget { static readonly CONNECTING = 0; @@ -43,6 +49,12 @@ const installSocketCounter = async (page: Page): Promise => { super(); this.url = String(url); sockets.push(this); + queueMicrotask(() => { + if (browser.__realtimePanelAutoOpen === true && this.readyState === MockWebSocket.CONNECTING) { + this.readyState = MockWebSocket.OPEN; + this.dispatchEvent(new Event("open")); + } + }); } close(): void { @@ -57,7 +69,9 @@ const installSocketCounter = async (page: Page): Promise => { }); }; -const installApiMocks = async (page: Page): Promise => { +const installApiMocks = async (page: Page): Promise<{ chatVotingReads: () => number; revisionReads: () => number }> => { + let chatVotingReads = 0; + let revisionReads = 0; await page.route("**/api/**", async (route) => { const pathname = new URL(route.request().url()).pathname; if (pathname === "/api/channels") { @@ -65,7 +79,9 @@ const installApiMocks = async (page: Page): Promise => { return; } if (pathname === `/api/channels/${channelId}/overview`) { - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...channel, activeModules: [{ moduleId: "text_commands", settings: "{}" }] }) }); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...channel, activeModules: [ + { moduleId: "text_commands", settings: "{}" }, { moduleId: "chat_voting", settings: "{}" }, + ] }) }); return; } if (pathname === `/api/channels/${channelId}/settings`) { @@ -73,7 +89,23 @@ const installApiMocks = async (page: Page): Promise => { return; } if (pathname === `/api/channels/${channelId}/modules`) { - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ modules: [{ id: "text_commands", enabled: true, settings: "{}" }] }) }); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ modules: [ + { id: "text_commands", enabled: true, settings: "{}" }, { id: "chat_voting", enabled: true, settings: "{}" }, + ] }) }); + return; + } + if (pathname === `/api/channels/${channelId}/revisions`) { + revisionReads += 1; + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ revisions: {} }) }); + return; + } + if (pathname === `/api/channels/${channelId}/modules/chat_voting/current`) { + chatVotingReads += 1; + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ + vote: null, counts: null, revision: 0, terms: null, moreTerms: null, hasOpenBallot: false, + defaultDurationSeconds: 120, + defaultLabels: { yes_no: ["Yes", "No"], digit_01: ["No", "Yes"], digit_12: ["1", "2"], scale_5: ["1", "2", "3", "4", "5"], options_n: Array.from({ length: 9 }, (_unused, index) => `Option ${String(index + 1)}`), free_text: [] }, + }) }); return; } if (pathname === `/api/channels/${channelId}/modules/text_commands/commands`) { @@ -94,6 +126,7 @@ const installApiMocks = async (page: Page): Promise => { } await route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); }); + return { chatVotingReads: () => chatVotingReads, revisionReads: () => revisionReads }; }; const panelSocketPaths = async (page: Page): Promise => page.evaluate(() => { @@ -105,7 +138,7 @@ const panelSocketPaths = async (page: Page): Promise => page.evaluate( test("keeps one channel socket while navigating between dashboard views", async ({ page }) => { await installSocketCounter(page); - await installApiMocks(page); + const apiReads = await installApiMocks(page); await page.goto(`/channels/${channelId}`); await expect(page.getByRole("heading", { name: channel.displayName, level: 1 })).toBeVisible(); await expect.poll(() => panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); @@ -124,4 +157,22 @@ test("keeps one channel socket while navigating between dashboard views", async await expect(page).toHaveURL(`/channels/${channelId}/modules/text_commands`); await expect(page.locator("h1")).toBeVisible(); expect(await panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); + + await page.locator(`a[href="/channels/${channelId}/modules/chat_voting"]`).click(); + await expect(page).toHaveURL(`/channels/${channelId}/modules/chat_voting`); + await expect(page.getByRole("heading", { name: "Chat voting", level: 1 })).toBeVisible(); + await expect.poll(() => apiReads.chatVotingReads()).toBeGreaterThan(0); + await expect.poll(() => apiReads.revisionReads()).toBe(1); + const connectedReads = apiReads.chatVotingReads(); + expect(connectedReads).toBeGreaterThan(0); + await page.waitForTimeout(2_200); + expect(apiReads.chatVotingReads()).toBe(connectedReads); + expect(await panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); + + await page.evaluate(() => { + (window as Window & { __realtimePanelAutoOpen?: boolean }).__realtimePanelAutoOpen = false; + const sockets = (window as Window & { __realtimePanelSockets?: Array<{ close: () => void }> }).__realtimePanelSockets ?? []; + sockets[0]?.close(); + }); + await expect.poll(() => apiReads.chatVotingReads(), { timeout: 5_000 }).toBeGreaterThan(connectedReads); }); diff --git a/tests/query-test-utils.tsx b/tests/query-test-utils.tsx index a0006c4c..460c0135 100644 --- a/tests/query-test-utils.tsx +++ b/tests/query-test-utils.tsx @@ -1,6 +1,7 @@ import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; import { render, type RenderOptions } from "@testing-library/react"; import type { ReactElement } from "react"; +import { vi } from "vitest"; import { createDashboardQueryClient } from "../src/dashboard/data/client"; import { dispatchDashboardAuthenticationRequired } from "../src/dashboard/data/events"; @@ -11,6 +12,8 @@ export interface DashboardQueryTestOptions { initialData?: readonly { queryKey: readonly unknown[]; data: unknown }[]; /** Reuse an existing client, e.g. to remount a page against a warm cache. */ queryClient?: QueryClient; + /** Revision vector returned to the connected panel socket in this render. */ + panelRevisions?: Readonly> | null; } export const renderWithQuery = ( @@ -18,6 +21,23 @@ export const renderWithQuery = ( options?: Omit, queryOptions: DashboardQueryTestOptions = {}, ) => { + if (queryOptions.panelRevisions !== undefined && queryOptions.panelRevisions !== null) { + const previousFetch = globalThis.fetch; + const revisions = queryOptions.panelRevisions; + const revisionFetch = vi.fn((input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = input instanceof Request + ? new URL(input.url) + : input instanceof URL ? input : new URL(input, window.location.href); + if (/^\/api\/channels\/[^/]+\/revisions$/u.test(url.pathname)) { + return Promise.resolve(new Response(JSON.stringify({ revisions }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + } + return previousFetch(input, init); + }); + vi.stubGlobal("fetch", revisionFetch); + } const queryClient = queryOptions.queryClient ?? createDashboardQueryClient(dispatchDashboardAuthenticationRequired); queryClient.setDefaultOptions({ ...queryClient.getDefaultOptions(), diff --git a/tests/unit/belabox-polling.test.ts b/tests/unit/belabox-polling.test.ts index a2197bd7..f32758c1 100644 --- a/tests/unit/belabox-polling.test.ts +++ b/tests/unit/belabox-polling.test.ts @@ -133,7 +133,6 @@ const alarmContext = ( const cleared: string[] = []; const diagnostics: Array<{ code: string; detail?: Readonly> }> = []; const overlayMessages: Array<{ type: string; elementKind: string; payload: Readonly> }> = []; - const panelHints: string[] = []; let currentDeadline: number | null = null; const sendChat = vi.fn(async (_text, _idempotencyKey, _attributions, stillValid) => { if (stillValid !== undefined && !await stillValid()) return { sent: false, reason: "stale_before_send", retryable: false }; @@ -168,12 +167,8 @@ const alarmContext = ( overlayMessages.push({ type, elementKind, payload }); return Promise.resolve(); }, - publishModulePanelHint: (part: string) => { - panelHints.push(part); - return Promise.resolve(); - }, } as unknown as ModuleAlarmContext; - return { context, scheduled, cleared, diagnostics, overlayMessages, panelHints, sendChat }; + return { context, scheduled, cleared, diagnostics, overlayMessages, sendChat }; }; const pollOnDemand = async (context: ModuleAlarmContext, fetcher: typeof fetch = fetch): Promise => { @@ -1265,7 +1260,7 @@ describe("BELABOX polling", () => { it("keeps relay fetch failures out of chat and raises the notice phase on failure three", async () => { await insertModule(database, { ...BELABOX_DEFAULT_SETTINGS, mode: "interval", intervalSeconds: 5, chatEnabled: true }); - const { context, sendChat, panelHints } = alarmContext(database); + const { context, sendChat } = alarmContext(database); await handleBelaboxPollAlarm( context, "poll", @@ -1284,7 +1279,6 @@ describe("BELABOX polling", () => { } expect(sendChat).not.toHaveBeenCalled(); - expect(panelHints).toEqual(["live", "live", "live", "live", "live"]); expect(await getBelaboxStatus(context.DB, CHANNEL_ID)).toMatchObject({ sample: { connected: true }, errorCode: "network", diff --git a/tests/unit/chat-voting-close.test.ts b/tests/unit/chat-voting-close.test.ts index 6ee2fec0..e493fbe5 100644 --- a/tests/unit/chat-voting-close.test.ts +++ b/tests/unit/chat-voting-close.test.ts @@ -57,11 +57,6 @@ describe("chat voting close service", () => { }); const read = vi.fn<() => Promise>(() => { order.push("read"); return Promise.resolve(snapshot); }); const close = vi.fn<() => Promise>(() => { order.push("close"); return Promise.resolve(snapshot); }); - const publishModulePanelHint = vi.fn((part: string) => { - order.push("panel-hint"); - void part; - return Promise.resolve(); - }); const publishModuleOverlayMessage = vi.fn( () => { order.push("overlay"); return Promise.resolve(); }, ); @@ -88,7 +83,6 @@ describe("chat voting close service", () => { clear: vi.fn(), renderTemplate: vi.fn(() => Promise.resolve({ text: "Yes: 7 (70%) · No: 3 (30%)" })), publishModuleOverlayMessage, - publishModulePanelHint, sendChat: vi.fn((...args: Parameters) => { void args; order.push("chat"); @@ -100,7 +94,7 @@ describe("chat voting close service", () => { streamStartedAt: vi.fn(() => Promise.resolve({ streamId: null, startedAt: null })), channelLanguage: vi.fn(() => Promise.resolve("en" as const)), } as unknown as ModuleAlarmContext; - return { context, values, close, read, publishModuleOverlayMessage, publishModulePanelHint, sendChat, storagePut }; + return { context, values, close, read, publishModuleOverlayMessage, sendChat, storagePut }; }; it("coalesces concurrent manual-close requests onto the same alarm key", async () => { @@ -243,7 +237,7 @@ describe("chat voting close service", () => { })); const finalSnapshot = { counts: [], revision: 201, terms, more: 0, termFilterReady: true }; const order: string[] = []; - const { context, read, close, publishModuleOverlayMessage, publishModulePanelHint } = createAlarmContext(order); + const { context, read, close, publishModuleOverlayMessage } = createAlarmContext(order); read.mockResolvedValue(finalSnapshot); close.mockResolvedValue(finalSnapshot); const overlayOutcomes: string[] = []; @@ -261,8 +255,6 @@ describe("chat voting close service", () => { await closeChatVoteFromAlarm(context, repository, "fictional-poll"); expect(overlayOutcomes).toEqual(["rejected"]); - expect(publishModulePanelHint).toHaveBeenCalledWith("panel"); - expect(order.indexOf("panel-hint")).toBeLessThan(order.indexOf("overlay")); }); it("includes a vote question in the default result announcement and closed overlay state", async () => { diff --git a/tests/unit/dashboard-query-data.test.tsx b/tests/unit/dashboard-query-data.test.tsx index 1ac663c8..9446542a 100644 --- a/tests/unit/dashboard-query-data.test.tsx +++ b/tests/unit/dashboard-query-data.test.tsx @@ -8,7 +8,7 @@ import { createDashboardQueryClient } from "../../src/dashboard/data/client"; import { moduleQueryKey, refetchModuleQueryData, runModuleQueryWrite, useModuleQuery } from "../../src/dashboard/data"; import { queryKeys } from "../../src/dashboard/data/keys"; import { dashboardDataKeys } from "../../src/dashboard/data/keys"; -import { setDashboardRealtimeStatus, useDashboardRealtimeStatus, invalidateDashboardRealtimeMessage } from "../../src/dashboard/data/realtime"; +import { reconcileDashboardPanelResourceRevisions, setDashboardRealtimeStatus, useDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; import { useOverlayQuery } from "../../src/dashboard/data/lists"; import { DashboardDataProvider } from "../../src/dashboard/data/provider"; import { emptyAuditFilter } from "../../src/dashboard/audit/model"; @@ -152,19 +152,18 @@ describe("dashboard query data layer", () => { const view = renderWithQuery(, {}, { gcTime: 600_000, staleTime: 600_000 }); expect(await screen.findByText("1")).toBeInTheDocument(); - const message = { - version: 1 as const, - id: "chat-vote-change", - createdAt: "2026-10-09T08:00:00.000Z", - channelId, - type: "modul.chat_voting.changed" as const, - payload: { part: "panel" as const }, - }; - - invalidateDashboardRealtimeMessage(view.queryClient, message); + let revision = 0; + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions: { "module:chat_voting:panel": revision } }), { + status: 200, headers: { "Content-Type": "application/json" }, + })))); + await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + revision = 1; + await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); await refreshStarted; - invalidateDashboardRealtimeMessage(view.queryClient, { ...message, id: "chat-vote-tally-2" }); - invalidateDashboardRealtimeMessage(view.queryClient, { ...message, id: "chat-vote-tally-3" }); + revision = 2; + await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + revision = 3; + await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); expect(readCount).toBe(2); releaseRefresh?.(); @@ -185,18 +184,16 @@ describe("dashboard query data layer", () => { const view = renderWithQuery(, {}, { gcTime: 600_000, staleTime: 600_000 }); expect(await screen.findByText("1")).toBeInTheDocument(); - const message = { - version: 1 as const, - id: "chat-vote-change-0", - createdAt: "2026-10-09T08:00:00.000Z", - channelId, - type: "modul.chat_voting.changed" as const, - payload: { part: "panel" as const }, - }; + let revision = 0; + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions: { "module:chat_voting:panel": revision } }), { + status: 200, headers: { "Content-Type": "application/json" }, + })))); + await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); for (let index = 0; index < 20; index += 1) { await new Promise((resolve) => { setTimeout(resolve, 50); }); - invalidateDashboardRealtimeMessage(view.queryClient, { ...message, id: `chat-vote-tally-${String(index + 1)}` }); + revision += 1; + await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); } await new Promise((resolve) => { setTimeout(resolve, 300); }); diff --git a/tests/unit/dashboard-realtime.test.ts b/tests/unit/dashboard-realtime.test.ts index 1a59cd1d..2bf61fb1 100644 --- a/tests/unit/dashboard-realtime.test.ts +++ b/tests/unit/dashboard-realtime.test.ts @@ -6,7 +6,8 @@ import { dashboardDataKeys, queryKeys } from "../../src/dashboard/data/keys"; import { getDashboardRealtimeStatus, invalidateDashboardChannelQueries, - invalidateDashboardRealtimeMessage, + reconcileDashboardRealtimeMessage, + reconcileDashboardPanelResourceRevisions, setDashboardRealtimeStatus, } from "../../src/dashboard/data/realtime"; import { moduleQueryKey } from "../../src/dashboard/data/module-query"; @@ -29,10 +30,11 @@ const addQuery = (queryClient: QueryClient, key: readonly unknown[]): void => { afterEach(() => { setDashboardRealtimeStatus(channelId, "offline"); vi.useRealTimers(); + vi.unstubAllGlobals(); }); describe("dashboard realtime messages", () => { - it("accepts module state hints for panel query invalidation", () => { + it("ignores legacy module panel hints", () => { const result = parseRealtimeMessage(JSON.stringify({ version: 1, id: "module-sample-1", @@ -42,8 +44,7 @@ describe("dashboard realtime messages", () => { payload: { part: "live" }, }), "channel-a"); - expect(result.kind).toBe("message"); - if (result.kind === "message") expect(result.message.type).toBe("modul.belabox.changed"); + expect(result.kind).toBe("ignored"); const secretPayload = parseRealtimeMessage(JSON.stringify({ version: 1, @@ -56,27 +57,6 @@ describe("dashboard realtime messages", () => { expect(secretPayload.kind).not.toBe("message"); }); - it("invalidates event and originating module queries for event hints", () => { - const queryClient = new QueryClient(); - const events = dashboardDataKeys.events(channelId, { origin: null, module: null, tone: null, person: null }); - const chatVoting = moduleQueryKey(channelId, "chat_voting", "panel"); - const belabox = moduleQueryKey(channelId, "belabox", "status"); - const unrelated = moduleQueryKey(channelId, "ads", "schedule"); - [events, chatVoting, belabox, unrelated].forEach((key) => { addQuery(queryClient, key); }); - - invalidateDashboardRealtimeMessage(queryClient, moduleHint("event_log.new", { - entries: [ - { eventId: "event-1", createdAt: "2026-10-09T08:00:00.000Z", moduleId: "chat_voting", code: "chat_voting.started", actorUserId: null }, - { eventId: "event-2", createdAt: "2026-10-09T08:00:00.000Z", moduleId: "belabox", code: "belabox.sample", actorUserId: null }, - ], - })); - - expect(queryClient.getQueryState(events)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(chatVoting)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(belabox)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(unrelated)?.isInvalidated).toBe(false); - }); - it("bounds sustained module hints and does not refresh module settings", async () => { vi.useFakeTimers(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -98,10 +78,17 @@ describe("dashboard realtime messages", () => { }); return observer.subscribe(() => undefined); }); + const revisions: Record = { "channel.events": 0, "module:chat_voting:data": 0 }; + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })))); + await reconcileDashboardPanelResourceRevisions(queryClient, channelId); for (let index = 0; index < 20; index += 1) { - invalidateDashboardRealtimeMessage(queryClient, moduleHint("modul.chat_voting.changed", { part: "panel" })); - invalidateDashboardRealtimeMessage(queryClient, moduleHint("event_log.new", { + revisions["channel.events"] = (revisions["channel.events"] ?? 0) + 1; + revisions["module:chat_voting:data"] = (revisions["module:chat_voting:data"] ?? 0) + 1; + await reconcileDashboardRealtimeMessage(queryClient, moduleHint("event_log.new", { entries: [{ eventId: `event-${String(index)}`, createdAt: "2026-10-09T08:00:00.000Z", @@ -122,35 +109,6 @@ describe("dashboard realtime messages", () => { queryClient.clear(); }); - it("maps variables, overlays, ad schedules, and module hints to their channel data keys", () => { - const queryClient = new QueryClient(); - const variables = dashboardDataKeys.variables(channelId); - const overlays = dashboardDataKeys.overlays(channelId); - const overlay = dashboardDataKeys.overlay(channelId, "overlay-a"); - const textCommands = moduleQueryKey(channelId, "text_commands", "template-variables"); - const ads = moduleQueryKey(channelId, "ads", "schedule"); - const belabox = moduleQueryKey(channelId, "belabox", "status"); - [variables, overlays, overlay, textCommands, ads, belabox].forEach((key) => { addQuery(queryClient, key); }); - - invalidateDashboardRealtimeMessage(queryClient, moduleHint("variables.changed", { set: [{ name: "score", value: 1 }], removed: [] })); - expect(queryClient.getQueryState(variables)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(overlays)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(overlay)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(textCommands)?.isInvalidated).toBe(true); - queryClient.setQueryData(belabox, { ready: true }); - - invalidateDashboardRealtimeMessage(queryClient, moduleHint("overlay.changed", { overlayId: "overlay-a", revision: 2 })); - expect(queryClient.getQueryState(overlays)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(overlay)?.isInvalidated).toBe(true); - - invalidateDashboardRealtimeMessage(queryClient, moduleHint("ads.schedule.updated", { asOf: "2026-10-09T08:00:00.000Z" })); - expect(queryClient.getQueryState(ads)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(belabox)?.isInvalidated).toBe(false); - - invalidateDashboardRealtimeMessage(queryClient, moduleHint("modul.belabox.changed", { part: "live" })); - expect(queryClient.getQueryState(belabox)?.isInvalidated).toBe(true); - }); - it("refreshes an overlay detail only once when its overlay list also changes", async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const overlays = dashboardDataKeys.overlays(channelId); @@ -171,8 +129,15 @@ describe("dashboard realtime messages", () => { }); const unsubscribeList = listObserver.subscribe(() => undefined); const unsubscribeDetail = detailObserver.subscribe(() => undefined); - - invalidateDashboardRealtimeMessage(queryClient, moduleHint("overlay.changed", { overlayId: "overlay-a", revision: 2 })); + let overlayRevision = 0; + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions: { "channel.overlays": overlayRevision } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })))); + + await reconcileDashboardPanelResourceRevisions(queryClient, channelId); + overlayRevision = 1; + await reconcileDashboardRealtimeMessage(queryClient, moduleHint("overlay.changed", { overlayId: "overlay-a", revision: 2 })); await new Promise((resolve) => { setTimeout(resolve, 320); }); expect(listReads).toBe(1); @@ -182,65 +147,21 @@ describe("dashboard realtime messages", () => { queryClient.clear(); }); - it("invalidates the right module cache for each panel live-state hint", () => { - const hints = [ - { moduleId: "chat_voting", type: "modul.chat_voting.changed", payload: { part: "panel" }, parts: ["panel"] }, - { moduleId: "belabox", type: "modul.belabox.changed", payload: { part: "live" }, parts: ["status", "streams", "history-live", "history-stream-42"] }, - { moduleId: "votekick", type: "modul.votekick.changed", payload: { part: "panel" }, parts: ["panel"] }, - { moduleId: "votekick", type: "modul.votekick.changed", payload: { part: "availability" }, parts: ["panel"] }, - ] as const; - - for (const { moduleId, type, payload, parts } of hints) { - const queryClient = new QueryClient(); - const relevant = parts.map((part) => moduleQueryKey(channelId, moduleId, part)); - const settings = moduleQueryKey(channelId, moduleId, "settings"); - const unrelated = moduleQueryKey(channelId, "ads", "schedule"); - [...relevant, settings, unrelated].forEach((key) => { addQuery(queryClient, key); }); - - const parsed = parseRealtimeMessage(JSON.stringify({ - version: 1, - id: `hint-${type}`, - createdAt: "2026-10-09T08:00:00.000Z", - channelId, - type, - payload, - }), channelId); - expect(parsed.kind).toBe("message"); - if (parsed.kind === "message") invalidateDashboardRealtimeMessage(queryClient, parsed.message); - - for (const key of relevant) expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(settings)?.isInvalidated).toBe(false); - expect(queryClient.getQueryState(unrelated)?.isInvalidated).toBe(false); - } - }); - - it("refreshes chat voting when a shared votekick ballot opens or closes", () => { - const hints = [ - { type: "modul.votekick.changed", payload: { part: "availability" }, refreshChatVoting: true }, - { type: "modul.votekick.changed", payload: { part: "panel" }, refreshChatVoting: false }, - ] as const; - - for (const hint of hints) { - const queryClient = new QueryClient(); - const chatVoting = moduleQueryKey(channelId, "chat_voting", "panel"); - const votekick = moduleQueryKey(channelId, "votekick", "panel"); - [chatVoting, votekick].forEach((key) => { addQuery(queryClient, key); }); - - invalidateDashboardRealtimeMessage(queryClient, moduleHint(hint.type, hint.payload)); - - expect(queryClient.getQueryState(votekick)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(chatVoting)?.isInvalidated).toBe(hint.refreshChatVoting); - } - }); - - it("invalidates channel queries on stream changes and reconnect", () => { + it("compares exact channel resources on hints and invalidates channel queries on reconnect", async () => { const queryClient = new QueryClient(); const overview = queryKeys.channel(channelId, "overview"); const system = queryKeys.channel(channelId, "system"); const channels = queryKeys.channels(); [overview, system, channels].forEach((key) => { addQuery(queryClient, key); }); - invalidateDashboardRealtimeMessage(queryClient, moduleHint("stream.state.changed", { + let revisions: Readonly> = { "channel.overview": 0, "channel.system": 0, channels: 0 }; + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })))); + await reconcileDashboardPanelResourceRevisions(queryClient, channelId); + revisions = { "channel.overview": 1, "channel.system": 1, channels: 1 }; + await reconcileDashboardRealtimeMessage(queryClient, moduleHint("stream.state.changed", { state: "online", startedAt: "2026-10-09T08:00:00.000Z", changedAt: "2026-10-09T08:00:00.000Z", })); expect(queryClient.getQueryState(overview)?.isInvalidated).toBe(true); @@ -251,7 +172,7 @@ describe("dashboard realtime messages", () => { invalidateDashboardChannelQueries(queryClient, channelId); expect(queryClient.getQueryState(overview)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(system)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(channels)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(channels)?.isInvalidated).toBe(false); }); it("tracks connection state per channel for conditional fallback queries", () => { @@ -259,4 +180,236 @@ describe("dashboard realtime messages", () => { expect(getDashboardRealtimeStatus(channelId)).toBe("connected"); expect(getDashboardRealtimeStatus("channel-b")).toBe("offline"); }); + + it("refreshes a second client from revisions and repairs a lost hint on focus", async () => { + const sharedChannel = "revision-two-client"; + const writer = new QueryClient(); + const reader = new QueryClient(); + const writerEvents = queryKeys.channel(sharedChannel, "events", { origin: "twitch" }); + const readerEvents = queryKeys.channel(sharedChannel, "events", { origin: "twitch" }); + const readerAudit = queryKeys.channel(sharedChannel, "audit-log", { person: "user-1" }); + const otherChannelEvents = queryKeys.channel("revision-other-channel", "events"); + [writerEvents].forEach((key) => { addQuery(writer, key); }); + [readerEvents, readerAudit, otherChannelEvents].forEach((key) => { addQuery(reader, key); }); + const vectors = new Map>>([[sharedChannel, {}]]); + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input), "https://brobot.example"); + const requestedChannel = decodeURIComponent(url.pathname.split("/")[3] ?? ""); + return Promise.resolve(new Response(JSON.stringify({ revisions: vectors.get(requestedChannel) ?? {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + })); + + await Promise.all([ + reconcileDashboardPanelResourceRevisions(writer, sharedChannel), + reconcileDashboardPanelResourceRevisions(reader, sharedChannel), + ]); + vectors.set(sharedChannel, { "channel.events": 1, "channel.audit": 1 }); + const hint: RealtimeMessage = { + version: 1, + id: "revision-two-client-hint", + createdAt: "2026-10-10T08:00:00.000Z", + channelId: sharedChannel, + type: "panel.resources.changed", + payload: { + resources: ["channel.all"], + revisions: { "channel.all": 2 }, + }, + }; + const parsedHint = parseRealtimeMessage(JSON.stringify(hint), sharedChannel); + expect(parsedHint.kind).toBe("message"); + if (parsedHint.kind === "message") await reconcileDashboardRealtimeMessage(reader, parsedHint.message); + expect(reader.getQueryState(readerEvents)?.isInvalidated).toBe(true); + expect(reader.getQueryState(readerAudit)?.isInvalidated).toBe(true); + expect(reader.getQueryState(otherChannelEvents)?.isInvalidated).toBe(false); + + reader.setQueryData(readerEvents, { refreshed: true }); + reader.setQueryData(readerAudit, { refreshed: true }); + vectors.set(sharedChannel, { "channel.events": 2, "channel.audit": 1 }); + await reconcileDashboardPanelResourceRevisions(reader, sharedChannel); + expect(reader.getQueryState(readerEvents)?.isInvalidated).toBe(true); + expect(reader.getQueryState(readerAudit)?.isInvalidated).toBe(false); + writer.clear(); + reader.clear(); + }); + + it("rechecks the revision vector when another hint arrives during an in-flight read", async () => { + const revisionChannel = "revision-overlap"; + const reader = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const panel = moduleQueryKey(revisionChannel, "chat_voting", "panel"); + let panelReads = 0; + reader.setQueryData(panel, { revision: 0 }); + const observer = new QueryObserver(reader, { + queryKey: panel, + queryFn: () => { panelReads += 1; return Promise.resolve({ revision: panelReads }); }, + staleTime: Infinity, + }); + const unsubscribe = observer.subscribe(() => undefined); + let releaseFirst: ((response: Response) => void) | undefined; + const firstResponse = new Promise((resolve) => { releaseFirst = resolve; }); + let vector: Readonly> = {}; + let revisionReads = 0; + vi.stubGlobal("fetch", vi.fn(() => { + revisionReads += 1; + if (revisionReads === 1) return firstResponse; + return Promise.resolve(new Response(JSON.stringify({ revisions: vector }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + })); + + const first = reconcileDashboardPanelResourceRevisions(reader, revisionChannel); + await vi.waitFor(() => { expect(revisionReads).toBe(1); }); + vector = { "module:chat_voting:panel": 1 }; + const second = reconcileDashboardPanelResourceRevisions(reader, revisionChannel); + releaseFirst?.(new Response(JSON.stringify({ revisions: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + await Promise.all([first, second]); + expect(revisionReads).toBe(2); + await vi.waitFor(() => { expect(panelReads).toBe(1); }); + unsubscribe(); + reader.clear(); + }); + + it("refreshes overlapping resource mappings once per query", async () => { + const revisionChannel = "revision-overlapping-resources"; + const reader = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const affectedKeys = [ + queryKeys.channel(revisionChannel, "overview"), + dashboardDataKeys.overlays(revisionChannel), + moduleQueryKey(revisionChannel, "api_source", "sources"), + ] as const; + const refetches = affectedKeys.map(() => 0); + const unsubscribers = affectedKeys.map((queryKey, index) => { + reader.setQueryData(queryKey, { ready: true }); + return new QueryObserver(reader, { + queryKey, + queryFn: () => { + refetches[index] = (refetches[index] ?? 0) + 1; + return Promise.resolve({ ready: true }); + }, + staleTime: Infinity, + }).subscribe(() => undefined); + }); + let vector: Readonly> = {}; + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions: vector }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })))); + + await reconcileDashboardPanelResourceRevisions(reader, revisionChannel); + vector = { + "channel.overview": 1, + "channel.modules": 1, + "channel.overlays": 1, + "channel.overlay-accesses": 1, + "channel.library": 1, + "api.cache": 1, + }; + await reconcileDashboardPanelResourceRevisions(reader, revisionChannel); + await vi.waitFor(() => { expect(refetches).toEqual([1, 1, 1]); }); + await new Promise((resolve) => { setTimeout(resolve, 1_100); }); + expect(refetches).toEqual([1, 1, 1]); + + unsubscribers.forEach((unsubscribe) => { unsubscribe(); }); + reader.clear(); + }); + + it.each([ + ["channel.overview", "overview"], + ["channel.system", "system"], + ["channel.settings", "settings"], + ["channel.members", "members"], + ["channel.modules", "modules"], + ["channel.events", "events"], + ["channel.audit", "audit-log"], + ["channel.variables", "variables"], + ["channel.overlays", "overlays"], + ["channel.overlay-accesses", "overlay-access-list"], + ["channel.library", "library-catalog"], + ["weather.cache", "weather-library"], + ["api.cache", "api-source-cache"], + ["module:chat_voting:panel", "chat-voting"], + ["module:votekick:panel", "votekick"], + ["module:belabox:live", "belabox"], + ["module:chat_voting:data", "module-data"], + ["module:ads:schedule", "ads"], + ["channels", "channel-list"], + ] as const)("maps revision %s to its query area", async (resource, area) => { + const revisionChannel = `revision-${area}`; + const writer = new QueryClient(); + const reader = new QueryClient(); + const affectedKeys: Record = { + overview: [queryKeys.channel(revisionChannel, "overview")], + system: [dashboardDataKeys.system(revisionChannel)], + settings: [queryKeys.channel(revisionChannel, "settings")], + members: [dashboardDataKeys.members(revisionChannel)], + modules: [queryKeys.channel(revisionChannel, "modules"), queryKeys.channel(revisionChannel, "overview")], + events: [queryKeys.channel(revisionChannel, "events", { module: "chat_voting" })], + "audit-log": [dashboardDataKeys.audit(revisionChannel, { person: "user-1", area: null })], + variables: [ + dashboardDataKeys.variables(revisionChannel), + moduleQueryKey(revisionChannel, "text_commands", "commands"), + moduleQueryKey(revisionChannel, "chat_voting", "settings"), + ], + overlays: [dashboardDataKeys.overlays(revisionChannel), dashboardDataKeys.overlay(revisionChannel, "overlay-a")], + "overlay-access-list": [ + dashboardDataKeys.overlays(revisionChannel), + dashboardDataKeys.overlayAccesses(revisionChannel, "overlay-a"), + dashboardDataKeys.legacyOverlayTokens(revisionChannel), + ], + "library-catalog": [ + moduleQueryKey(revisionChannel, "text_library", "library"), + moduleQueryKey(revisionChannel, "timers", "panel"), + moduleQueryKey(revisionChannel, "faq", "panel"), + moduleQueryKey(revisionChannel, "text_commands", "template-variables"), + moduleQueryKey(revisionChannel, "api_source", "sources"), + ], + "weather-library": [moduleQueryKey(revisionChannel, "text_library", "library")], + "api-source-cache": [moduleQueryKey(revisionChannel, "api_source", "sources")], + "chat-voting": [moduleQueryKey(revisionChannel, "chat_voting", "panel")], + votekick: [moduleQueryKey(revisionChannel, "votekick", "panel")], + belabox: [moduleQueryKey(revisionChannel, "belabox", "streams")], + "module-data": [moduleQueryKey(revisionChannel, "chat_voting", "panel")], + ads: [moduleQueryKey(revisionChannel, "ads", "schedule")], + "channel-list": [queryKeys.channels()], + }; + const affected = affectedKeys[area]; + const unrelated = queryKeys.channel(`other-${revisionChannel}`, "unrelated"); + affected.forEach((key) => { addQuery(writer, key); addQuery(reader, key); }); + addQuery(writer, unrelated); + addQuery(reader, unrelated); + const readerRefetches = affected.map(() => 0); + const observers = affected.map((queryKey, index) => new QueryObserver(reader, { + queryKey, + queryFn: () => { + readerRefetches[index] = (readerRefetches[index] ?? 0) + 1; + return Promise.resolve({ ready: true }); + }, + staleTime: Infinity, + })); + const unsubscribers = observers.map((observer) => observer.subscribe(() => undefined)); + const vectors = new Map>>([[revisionChannel, {}]]); + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions: vectors.get(revisionChannel) }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })))); + + await Promise.all([ + reconcileDashboardPanelResourceRevisions(writer, revisionChannel), + reconcileDashboardPanelResourceRevisions(reader, revisionChannel), + ]); + vectors.set(revisionChannel, { [resource]: 1 }); + await reconcileDashboardPanelResourceRevisions(reader, revisionChannel); + await vi.waitFor(() => { expect(readerRefetches).toEqual(Array.from({ length: affected.length }, () => 1)); }); + + expect(writer.getQueryState(affected[0] ?? [])?.isInvalidated).toBe(false); + expect(reader.getQueryState(unrelated)?.isInvalidated).toBe(false); + unsubscribers.forEach((unsubscribe) => { unsubscribe(); }); + writer.clear(); + reader.clear(); + }); }); diff --git a/tests/unit/module-panel-realtime.test.ts b/tests/unit/module-panel-realtime.test.ts deleted file mode 100644 index 153cb656..00000000 --- a/tests/unit/module-panel-realtime.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { realtimeRecipients } from "../../src/realtime-contract"; -import { modulePanelHintMessage } from "../../src/worker/module-panel-realtime"; - -describe("module panel realtime hints", () => { - it("publishes bounded panel-only hints without module data or secrets", () => { - const cases = [ - { moduleId: "belabox", part: "live" }, - { moduleId: "chat_voting", part: "panel" }, - { moduleId: "votekick", part: "availability" }, - ] as const; - - for (const { moduleId, part } of cases) { - const message = modulePanelHintMessage("channel-a", moduleId, part); - - expect(message).not.toBeNull(); - expect(message?.payload).toEqual({ part }); - expect(message === null ? [] : realtimeRecipients(message.type)).toEqual(["panel"]); - expect(JSON.stringify(message)).not.toMatch(/statsUrl|publisherKey|payloadData/iu); - } - }); - - it("rejects a module and part pair outside the allowlist", () => { - expect(modulePanelHintMessage("channel-a", "belabox", "panel")).toBeNull(); - expect(modulePanelHintMessage("channel-a", "unknown", "panel")).toBeNull(); - }); -}); diff --git a/tests/unit/overlay-realtime-route.test.ts b/tests/unit/overlay-realtime-route.test.ts index ac90e349..7ea97d1d 100644 --- a/tests/unit/overlay-realtime-route.test.ts +++ b/tests/unit/overlay-realtime-route.test.ts @@ -61,12 +61,14 @@ const setup = async (): Promise<{ return Promise.resolve(new Response("forwarded")); }); const idFromName = vi.fn((channelId: string) => `object:${channelId}`); + const reconcilePanelResources = vi.fn(() => Promise.resolve()); + const getPanelResourceRevisions = vi.fn(() => Promise.resolve({})); const env = { DB: database as unknown as D1Database, OVERLAY_TOKEN_PEPPER: pepper, CHANNEL: { idFromName, - get: () => ({ fetch: fetchStub }), + get: () => ({ fetch: fetchStub, reconcilePanelResources, getPanelResourceRevisions }), }, } as unknown as Env; return { database, env, idFromName, fetchStub, forwarded: () => request }; @@ -146,7 +148,6 @@ describe("overlay realtime route", () => { }), state.env); expect(response.status).toBe(200); - expect(state.idFromName).toHaveBeenCalledTimes(1); expect(state.idFromName).toHaveBeenCalledWith("kanal-b"); expect(state.fetchStub).toHaveBeenCalledTimes(1); }); diff --git a/tests/unit/panel-resource-revisions.test.ts b/tests/unit/panel-resource-revisions.test.ts new file mode 100644 index 00000000..002c1b57 --- /dev/null +++ b/tests/unit/panel-resource-revisions.test.ts @@ -0,0 +1,577 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; + +import { MODULES } from "../../src/modules/registry"; +import { dashboardDataKeys, queryKeys } from "../../src/dashboard/data/keys"; +import { reconcileDashboardRealtimeMessage, reconcileDashboardPanelResourceRevisions } from "../../src/dashboard/data/realtime"; +import { moduleQueryKey } from "../../src/dashboard/data/module-query"; +import { authRouter } from "../../src/worker/auth/routes"; +import { eventSubChannelIdForNotification, eventSubRouter } from "../../src/worker/eventsub"; +import { panelRouter } from "../../src/worker/panel/routes"; +import { moduleRouter } from "../../src/worker/panel/module-routes"; +import { platformRouter } from "../../src/worker/platform/routes"; +import { notifyCommittedResources, panelResourceChangedMessage, PANEL_RESOURCE_NOTIFIER_COVERAGE, readCompletePanelResourceRevisions, readPanelResourceRevisions } from "../../src/worker/panel-resources"; +import { TestD1Database } from "./test-d1"; + +const writeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const routeIsCovered = (path: string, pattern: string): boolean => { + const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern; + return path.startsWith(prefix); +}; + +const addChannel = async (database: TestD1Database, channelId: string): Promise => { + await database.prepare( + `INSERT INTO channels (channel_id, login, display_name, created_at, updated_at) + VALUES (?, ?, ?, '2026-10-10T00:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).bind(channelId, channelId, channelId).run(); +}; + +interface PanelWriteRegressionCase { + name: string; + keys: (channelId: string) => readonly (readonly unknown[])[]; + write: (database: TestD1Database, channelId: string) => Promise; +} + +const panelWriteRegressions: readonly PanelWriteRegressionCase[] = [ + { + name: "channel overview, settings, system and channel list", + keys: (id) => [queryKeys.channels(), queryKeys.channel(id, "overview"), queryKeys.channel(id, "settings"), dashboardDataKeys.system(id)], + write: async (database, id) => { await database.prepare("UPDATE channels SET display_name = 'Changed' WHERE channel_id = ?").bind(id).run(); }, + }, + { + name: "members and their channel summaries", + keys: (id) => [dashboardDataKeys.members(id), queryKeys.channel(id, "overview"), queryKeys.channels()], + write: async (database, id) => { + await database.prepare( + `INSERT INTO channel_members (channel_id, user_id, role, created_at, updated_at) + VALUES (?, 'member-1', 'operator', '2026-10-10T00:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).bind(id).run(); + }, + }, + { + name: "events and module data", + keys: (id) => [queryKeys.channel(id, "events", { module: null }), moduleQueryKey(id, "chat_voting", "panel")], + write: async (database, id) => { + await database.prepare( + `INSERT INTO event_log (event_id, channel_id, created_at, module_id, code, detail_json) + VALUES ('event-write', ?, '2026-10-10T00:00:00.000Z', 'chat_voting', 'chat_voting.started', '{}')`, + ).bind(id).run(); + }, + }, + { + name: "audit log", + keys: (id) => [dashboardDataKeys.audit(id, { person: null, area: null })], + write: async (database, id) => { + await database.prepare( + `INSERT INTO audit_log (audit_id, actor_user_id, created_at, channel_id, action, before_json, after_json) + VALUES ('audit-write', 'user-1', '2026-10-10T00:00:00.000Z', ?, 'channel.updated', '{}', '{}')`, + ).bind(id).run(); + }, + }, + { + name: "variables and dependent editors", + keys: (id) => [ + dashboardDataKeys.variables(id), + moduleQueryKey(id, "text_commands", "commands"), + moduleQueryKey(id, "text_commands", "template-variables"), + moduleQueryKey(id, "chat_voting", "settings"), + moduleQueryKey(id, "text_library", "library"), + ], + write: async (database, id) => { + await database.prepare( + `INSERT INTO channel_variables (channel_id, name, created_at, updated_at) + VALUES (?, 'score', '2026-10-10T00:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).bind(id).run(); + }, + }, + { + name: "overlays and derived variable consumers", + keys: (id) => [ + dashboardDataKeys.overlays(id), dashboardDataKeys.overlay(id, "overlay-1"), + dashboardDataKeys.variables(id), moduleQueryKey(id, "text_commands", "commands"), + moduleQueryKey(id, "chat_voting", "settings"), moduleQueryKey(id, "text_library", "library"), + ], + write: async (database, id) => { + await database.prepare( + `INSERT INTO overlays (overlay_id, channel_id, name, created_at, updated_at) + VALUES ('overlay-1', ?, 'Gameplay', '2026-10-10T00:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).bind(id).run(); + }, + }, + { + name: "overlay access", + keys: (id) => [dashboardDataKeys.overlays(id), dashboardDataKeys.overlayAccesses(id, "overlay-1"), dashboardDataKeys.legacyOverlayTokens(id)], + write: async (database, id) => { + await database.prepare( + `INSERT INTO overlay_tokens (token_id, channel_id, token_hash, created_at) + VALUES ('token-1', ?, 'hash-1', '2026-10-10T00:00:00.000Z')`, + ).bind(id).run(); + }, + }, + { + name: "text library and library catalog", + keys: (id) => [ + moduleQueryKey(id, "text_library", "library"), moduleQueryKey(id, "timers", "panel"), + moduleQueryKey(id, "faq", "panel"), moduleQueryKey(id, "text_commands", "template-variables"), + moduleQueryKey(id, "api_source", "sources"), + ], + write: async (database, id) => { + await database.prepare( + `INSERT INTO text_library_settings (channel_id, updated_at) + VALUES (?, '2026-10-10T00:00:00.000Z')`, + ).bind(id).run(); + }, + }, + { + name: "module settings and module registry", + keys: (id) => [ + queryKeys.channel(id, "modules"), queryKeys.channel(id, "overview"), queryKeys.channels(), + dashboardDataKeys.variables(id), moduleQueryKey(id, "chat_voting", "settings"), + moduleQueryKey(id, "text_commands", "commands"), + ], + write: async (database, id) => { + await database.prepare( + `INSERT INTO channel_modules (channel_id, module_id, enabled, settings) + VALUES (?, 'chat_voting', 1, '{}')`, + ).bind(id).run(); + }, + }, + { + name: "chat voting tallies", + keys: (id) => [moduleQueryKey(id, "chat_voting", "panel")], + write: async (database, id) => { + await database.prepare( + `INSERT INTO chat_votes (channel_id, poll_id, preset, option_count, labels_json, status, opened_at, closes_at, close_reason) + VALUES (?, 'poll-1', 'yes_no', 2, '["Yes","No"]', 'open', '2026-10-10T00:00:00.000Z', '2026-10-10T00:02:00.000Z', 'manual')`, + ).bind(id).run(); + }, + }, + { + name: "votekick state", + keys: (id) => [moduleQueryKey(id, "votekick", "panel")], + write: async (database, id) => { + await database.prepare( + `INSERT INTO votekicks (channel_id, votekick_id, status, threshold, started_at, expires_at) + VALUES (?, 'kick-1', 'running', 2, '2026-10-10T00:00:00.000Z', '2026-10-10T00:02:00.000Z')`, + ).bind(id).run(); + }, + }, + { + name: "BELABOX samples", + keys: (id) => [ + moduleQueryKey(id, "belabox", "status"), moduleQueryKey(id, "belabox", "streams"), + moduleQueryKey(id, "belabox", "history-live"), + ], + write: async (database, id) => { + await database.prepare( + `INSERT INTO belabox_status (channel_id, sampled_at, sample_json, error_code, polling, stream_id, belabox_stream_id, fetch_phase_json, recent_json) + VALUES (?, '2026-10-10T00:00:00.000Z', '{}', NULL, 1, 'stream-1', 'belabox-1', '{}', '[]')`, + ).bind(id).run(); + }, + }, + { + name: "ad schedule", + keys: (id) => [moduleQueryKey(id, "ads", "schedule")], + write: async (database, id) => { + await database.prepare( + `INSERT INTO ads_countdown_state (channel_id, next_ad_at, duration, snooze_count, updated_at) + VALUES (?, '2026-10-10T00:05:00.000Z', 60, 0, '2026-10-10T00:00:00.000Z')`, + ).bind(id).run(); + }, + }, + { + name: "shared weather cache", + keys: (id) => [moduleQueryKey(id, "text_library", "library")], + write: async (database) => { + await database.prepare( + `INSERT INTO weather_cache (provider, location_key, latitude, longitude, payload_json, expires_at, updated_at) + VALUES ('met_norway', 'test-location', 52.5, 13.4, '{}', '2026-10-10T01:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).run(); + }, + }, + { + name: "shared API cache", + keys: (id) => [moduleQueryKey(id, "api_source", "sources")], + write: async (database) => { + await database.prepare( + `INSERT INTO api_source_cache (url_hash, payload_json, expires_at, updated_at) + VALUES ('${"a".repeat(64)}', '{}', '2026-10-10T01:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).run(); + }, + }, +]; + +describe("panel resource revisions", () => { + it("keeps every registered mutating route and module alarm under a notifier seam", () => { + const root = resolve(import.meta.dirname, "../.."); + const routers = [panelRouter, moduleRouter, platformRouter, authRouter, eventSubRouter]; + const mutations = routers.flatMap((router) => router.routes) + .filter(({ method }) => writeMethods.has(method)); + expect(mutations.length).toBeGreaterThan(0); + const routePatterns = [ + PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes, + PANEL_RESOURCE_NOTIFIER_COVERAGE.platformRoutes, + PANEL_RESOURCE_NOTIFIER_COVERAGE.authRoutes, + PANEL_RESOURCE_NOTIFIER_COVERAGE.eventSubRoute, + ]; + const uncovered = mutations.filter(({ path }) => !routePatterns.some((pattern) => routeIsCovered(path, pattern))); + expect(uncovered).toEqual([]); + + const moduleMutations = MODULES.flatMap((module) => module.routes?.routes ?? []) + .filter(({ method }) => writeMethods.has(method)); + const moduleAlarms = MODULES.flatMap((module) => module.alarms ?? []); + const maintenanceJobs = MODULES.filter((module) => module.scheduledMaintenance !== undefined); + expect(moduleMutations.length).toBeGreaterThan(0); + expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.moduleRoutes).toBe("channelRoutes"); + const moduleRouterSource = readFileSync(resolve(root, "src/worker/panel/module-routes.ts"), "utf8"); + expect(moduleRouterSource).toContain("moduleRouter.route(`/api/channels/:channelId/modules/${module.id}`, module.routes)"); + const authRouteSource = readFileSync(resolve(root, "src/worker/auth/routes.ts"), "utf8"); + expect(authRouteSource).toMatch(/authRouter\.get\("\/api\/overlay\/bootstrap"[\s\S]*?finally \{[\s\S]*?notifyCommittedResources\(context\.env, record\.channelId\)/u); + expect(moduleAlarms.length).toBeGreaterThan(0); + expect(maintenanceJobs.length).toBeGreaterThan(0); + expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelAlarm).toBe("ChannelObject.alarm.finally"); + expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.scheduledMaintenance).toBe("scheduled.finally"); + expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.eventSubHandler).toBe("eventSubRouter notification/revocation finally"); + + const channelObject = readFileSync(resolve(root, "src/worker/durable/ChannelObject.ts"), "utf8"); + const alarm = /override async alarm\(\): Promise\s+\{([\s\S]*?)\n\s+\}\n\n\s+override webSocketMessage/u.exec(channelObject)?.[1] ?? ""; + expect(alarm).toMatch(/finally \{\s*await this\.notifyCommittedPanelResources\(\);/u); + expect(alarm).toContain("this.alarmHandlers(MODULES"); + const alarmStart = channelObject.indexOf("public async runModuleAlarm("); + const alarmEnd = channelObject.indexOf("private ballotAccess(", alarmStart); + const directAlarm = alarmStart < 0 || alarmEnd < 0 ? "" : channelObject.slice(alarmStart, alarmEnd); + expect(directAlarm).toMatch(/finally \{\s*await this\.notifyCommittedPanelResources\(\);/u); + const alarmStorageStart = channelObject.indexOf("storage: {", channelObject.indexOf("private moduleAlarmContext(")); + const alarmStorageEnd = channelObject.indexOf("schedule:", alarmStorageStart); + const alarmStorage = alarmStorageStart < 0 || alarmStorageEnd < 0 + ? "" + : channelObject.slice(alarmStorageStart, alarmStorageEnd); + expect(alarmStorage).toContain("this.ctx.storage.transaction"); + expect(alarmStorage).toContain("bumpDurablePanelResources(transaction, [`module:${moduleId}:data`])"); + const scheduled = readFileSync(resolve(root, "src/worker/scheduled.ts"), "utf8"); + expect(scheduled).toContain(".finally(async () => {"); + expect(scheduled).toContain("await notifyCommittedResources(env);"); + const worker = readFileSync(resolve(root, "src/worker/index.ts"), "utf8"); + expect(worker).toContain("app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes"); + expect(worker).toContain("app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.platformRoutes"); + expect(worker).not.toMatch(/context\.res\.status !== 401/u); + const eventSubSource = readFileSync(resolve(root, "src/worker/eventsub.ts"), "utf8"); + expect(eventSubSource).toMatch(/finally \{\s*await notifyCommittedResources\(context\.env, revocation\.channelId\);/u); + expect(eventSubSource).toMatch(/finally \{\s*await notifyCommittedResources\(context\.env, notifierChannelId\);/u); + const authSource = readFileSync(resolve(root, "src/worker/auth/routes.ts"), "utf8"); + expect(authSource.match(/finally \{\s*await notifyCommittedResources\(env\);/gu) ?? []).toHaveLength(2); + }); + + it("uses the subscription registry to resolve EventSub notifier channels, including raid directions", () => { + expect(eventSubChannelIdForNotification({ + subscription: { type: "channel.raid", condition: { to_broadcaster_user_id: "incoming-channel" } }, + event: {}, + })).toBe("incoming-channel"); + expect(eventSubChannelIdForNotification({ + subscription: { type: "channel.raid", condition: { from_broadcaster_user_id: "outgoing-channel" } }, + event: {}, + })).toBe("outgoing-channel"); + expect(eventSubChannelIdForNotification({ + subscription: { type: "channel.chat.message", condition: { broadcaster_user_id: "trusted-channel" } }, + event: { broadcaster_user_id: "foreign-channel" }, + })).toBe("trusted-channel"); + }); + + it("advances dashboard resources with their D1 commits and isolates channels", async () => { + const database = new TestD1Database(); + try { + await addChannel(database, "revision-a"); + await addChannel(database, "revision-b"); + await database.prepare( + `INSERT INTO channel_variables (channel_id, name, created_at, updated_at) + VALUES ('revision-a', 'score', '2026-10-10T00:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).run(); + await database.prepare( + `INSERT INTO event_log (event_id, channel_id, created_at, module_id, code, detail_json) + VALUES ('event-a', 'revision-a', '2026-10-10T00:00:00.000Z', 'chat_voting', 'chat_voting.started', '{}')`, + ).run(); + + const first = await readPanelResourceRevisions(database as unknown as D1Database, "revision-a"); + const second = await readPanelResourceRevisions(database as unknown as D1Database, "revision-b"); + expect(first["channel.variables"]).toBe(1); + expect(first["channel.events"]).toBe(1); + expect(first["module:chat_voting:data"]).toBe(1); + expect(second["channel.variables"]).toBeUndefined(); + expect(second["channel.events"]).toBeUndefined(); + expect(second["module:chat_voting:data"]).toBeUndefined(); + + const eventRevision = first["channel.events"]; + await expect(database.batch([ + database.prepare( + `INSERT INTO event_log (event_id, channel_id, created_at, module_id, code, detail_json) + VALUES ('event-failed', 'revision-a', '2026-10-10T00:00:01.000Z', 'chat_voting', 'chat_voting.failed', '{}')`, + ), + database.prepare( + `INSERT INTO event_log (event_id, channel_id, created_at, module_id, code, detail_json) + VALUES ('event-failed', 'revision-a', '2026-10-10T00:00:02.000Z', 'chat_voting', 'chat_voting.duplicate', '{}')`, + ), + ])).rejects.toThrow(); + const afterRollback = await readPanelResourceRevisions(database as unknown as D1Database, "revision-a"); + expect(afterRollback["channel.events"]).toBe(eventRevision); + expect(afterRollback["module:chat_voting:data"]).toBe(1); + + const hint = panelResourceChangedMessage("revision-a", ["module:belabox:live"], { + "module:belabox:live": 2, + }); + expect(hint.payload).toEqual({ resources: ["module:belabox:live"], revisions: { "module:belabox:live": 2 } }); + expect(JSON.stringify(hint)).not.toMatch(/statsUrl|publisherKey|relay-key|https?:/iu); + } finally { + database.close(); + } + }); + + it("refreshes a second panel client after a committed D1 write and notifier hint", async () => { + const database = new TestD1Database(); + const writer = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const reader = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const channelId = "revision-two-client"; + const queryKey = dashboardDataKeys.variables(channelId); + let unsubscribe: (() => void) | undefined; + try { + await addChannel(database, channelId); + reader.setQueryData(queryKey, { variables: [] }); + let reads = 0; + const observer = new QueryObserver(reader, { + queryKey, + queryFn: () => { reads += 1; return Promise.resolve({ variables: [] }); }, + staleTime: Infinity, + }); + unsubscribe = observer.subscribe(() => undefined); + let published: Readonly> = {}; + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (id: string) => id, + get: () => ({ reconcilePanelResources: (revisions: Readonly>) => { + published = revisions; + return Promise.resolve(); + } }), + }, + } as unknown as Env; + + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + revisions: await readPanelResourceRevisions(database as unknown as D1Database, channelId), + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + await Promise.all([ + reconcileDashboardPanelResourceRevisions(writer, channelId), + reconcileDashboardPanelResourceRevisions(reader, channelId), + ]); + + await database.prepare( + `INSERT INTO channel_variables (channel_id, name, created_at, updated_at) + VALUES (?, 'score', '2026-10-10T00:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).bind(channelId).run(); + await notifyCommittedResources(env, channelId); + expect(published["channel.variables"]).toBe(1); + await reconcileDashboardRealtimeMessage(reader, panelResourceChangedMessage( + channelId, + ["channel.variables"], + published, + )); + + await vi.waitFor(() => { expect(reads).toBe(1); }); + } finally { + unsubscribe?.(); + writer.clear(); + reader.clear(); + vi.unstubAllGlobals(); + database.close(); + } + }); + + it.each(panelWriteRegressions)("$name refreshes each affected query on the second client", async (scenario) => { + const database = new TestD1Database(); + const channelId = "revision-two-client"; + const writer = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const reader = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryKeysForWrite = scenario.keys(channelId); + const refetchCounts = queryKeysForWrite.map(() => 0); + const unsubscribers: Array<() => void> = []; + let published: Readonly> = {}; + try { + await addChannel(database, channelId); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (id: string) => id, + get: () => ({ reconcilePanelResources: (revisions: Readonly>) => { + published = revisions; + return Promise.resolve(); + } }), + }, + } as unknown as Env; + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + revisions: await readPanelResourceRevisions(database as unknown as D1Database, channelId), + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + await Promise.all([ + reconcileDashboardPanelResourceRevisions(writer, channelId), + reconcileDashboardPanelResourceRevisions(reader, channelId), + ]); + queryKeysForWrite.forEach((queryKey, index) => { + writer.setQueryData(queryKey, { initial: true }); + reader.setQueryData(queryKey, { initial: true }); + const observer = new QueryObserver(reader, { + queryKey, + queryFn: () => { + refetchCounts[index] = (refetchCounts[index] ?? 0) + 1; + return Promise.resolve({ refreshed: true }); + }, + staleTime: Infinity, + }); + unsubscribers.push(observer.subscribe(() => undefined)); + }); + const beforeWrite = await readPanelResourceRevisions(database as unknown as D1Database, channelId); + + await scenario.write(database, channelId); + await notifyCommittedResources(env, channelId); + const changedResources = Object.keys(published).filter((resource) => + (published[resource] ?? 0) > (beforeWrite[resource] ?? 0)); + expect(changedResources.length).toBeGreaterThan(0); + await reconcileDashboardRealtimeMessage(reader, panelResourceChangedMessage(channelId, changedResources, published)); + + await vi.waitFor(() => { expect(refetchCounts).toEqual(Array.from({ length: queryKeysForWrite.length }, () => 1)); }); + expect(writer.getQueryCache().getAll().every((query) => !query.isActive())).toBe(true); + } finally { + unsubscribers.forEach((unsubscribe) => { unsubscribe(); }); + writer.clear(); + reader.clear(); + vi.unstubAllGlobals(); + database.close(); + } + }); + + it("publishes a committed revision when later route work fails", async () => { + const database = new TestD1Database(); + try { + await addChannel(database, "revision-failure"); + const reconcilePanelResources = vi.fn(() => Promise.resolve()); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => ({ reconcilePanelResources }), + }, + } as unknown as Env; + + await expect((async () => { + try { + await database.prepare( + `INSERT INTO channel_variables (channel_id, name, created_at, updated_at) + VALUES ('revision-failure', 'score', '2026-10-10T00:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).run(); + throw new Error("later route work failed"); + } finally { + await notifyCommittedResources(env, "revision-failure"); + } + })()).rejects.toThrow("later route work failed"); + + expect(reconcilePanelResources).toHaveBeenCalledWith(expect.objectContaining({ "channel.variables": 1 })); + } finally { + database.close(); + } + }); + + it("combines D1 and channel Durable Object revision counters", async () => { + const database = new TestD1Database(); + try { + await addChannel(database, "revision-combined"); + await database.prepare( + `INSERT INTO channel_variables (channel_id, name, created_at, updated_at) + VALUES ('revision-combined', 'score', '2026-10-10T00:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).run(); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => ({ getPanelResourceRevisions: () => Promise.resolve({ + "channel.variables": 2, + "module:ads:schedule": 3, + }) }), + }, + } as unknown as Env; + + await expect(readCompletePanelResourceRevisions(env, "revision-combined")).resolves.toMatchObject({ + "channel.variables": 3, + "module:ads:schedule": 3, + }); + } finally { + database.close(); + } + }); + + it("declares commit triggers for every dashboard-visible source table", () => { + const database = new TestD1Database(); + try { + const sources = database.sqlite.prepare( + "SELECT DISTINCT source_table FROM panel_resource_dependencies ORDER BY source_table", + ).all() as Array<{ source_table: string }>; + const triggers = database.sqlite.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'panel_rev_%'", + ).all() as Array<{ sql: string }>; + for (const { source_table: table } of sources) { + for (const operation of ["INSERT", "UPDATE", "DELETE"]) { + expect(triggers.some(({ sql }) => new RegExp(`AFTER ${operation} ON ${table}\\b`, "iu").test(sql)), + `${table} must revise resources after ${operation}`).toBe(true); + } + } + } finally { + database.close(); + } + }); + + it("registers every D1 table as a dashboard resource or an explicit non-panel write source", () => { + const database = new TestD1Database(); + try { + const dependencies = new Set((database.sqlite.prepare( + "SELECT DISTINCT source_table FROM panel_resource_dependencies", + ).all() as Array<{ source_table: string }>).map(({ source_table }) => source_table)); + const nonPanelTables = new Set([ + "api_source_quota", + "auth_sessions", + "bot_channel_status_check_locks", + "channel_variable_stream_resets", + "currency_rate_cache", + "eventsub_maintenance_locks", + "eventsub_messages", + "oauth_transactions", + "panel_global_resource_publications", + "panel_global_resource_revisions", + "panel_resource_dependencies", + "panel_resource_revisions", + "text_command_user_cooldowns", + "twitch_app_access_token", + ]); + const tables = database.sqlite.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ).all() as Array<{ name: string }>; + const unregistered = tables.map(({ name }) => name) + .filter((table) => !dependencies.has(table) && !nonPanelTables.has(table)); + expect(unregistered).toEqual([]); + } finally { + database.close(); + } + }); + + it("declares derived variable query dependencies for module and command changes", () => { + const database = new TestD1Database(); + try { + const resourcesFor = (table: string): string[] => (database.sqlite.prepare( + "SELECT resource FROM panel_resource_dependencies WHERE source_table = ? ORDER BY resource", + ).all(table) as Array<{ resource: string }>).map(({ resource }) => resource); + expect(resourcesFor("channel_modules")).toContain("channel.variables"); + expect(resourcesFor("text_commands")).toContain("channel.variables"); + expect(resourcesFor("channel_variables")).toContain("channel.library"); + expect(resourcesFor("overlays")).toContain("channel.variables"); + } finally { + database.close(); + } + }); +}); diff --git a/tests/unit/sql-contract.test.ts b/tests/unit/sql-contract.test.ts index 915dc20d..35d7bad4 100644 --- a/tests/unit/sql-contract.test.ts +++ b/tests/unit/sql-contract.test.ts @@ -191,9 +191,10 @@ describe("SQL contract", () => { const objects = database.prepare( "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY tbl_name, type DESC, name", ).all() as unknown as SchemaObject[]; - expect(objects.filter((object) => object.type === "table")).toHaveLength(50); - expect(objects.filter((object) => object.type === "index")).toHaveLength(42); - expect(objects).toHaveLength(93); + expect(objects.filter((object) => object.type === "table")).toHaveLength(54); + expect(objects.filter((object) => object.type === "index")).toHaveLength(43); + expect(objects.filter((object) => object.type === "trigger")).toHaveLength(121); + expect(objects).toHaveLength(218); const tableColumns = (table: string): Set => new Set( (database.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(({ name }) => name), diff --git a/tests/unit/votekick-routes.test.ts b/tests/unit/votekick-routes.test.ts index cf36e18f..b50cc353 100644 --- a/tests/unit/votekick-routes.test.ts +++ b/tests/unit/votekick-routes.test.ts @@ -192,13 +192,11 @@ describe("Votekick routes", () => { ); expect(response.status).toBe(200); - expect(publish).toHaveBeenCalledOnce(); - const publishedMessage = publish.mock.calls[0]?.[0]?.[0]; - expect(publishedMessage).toMatchObject({ - type: "modul.votekick.changed", - channelId: "channel-a", - payload: { part: "panel" }, - }); + expect(publish).not.toHaveBeenCalled(); + const revision = await database.prepare( + "SELECT revision FROM panel_resource_revisions WHERE channel_id = 'channel-a' AND resource = 'module:votekick:panel'", + ).first<{ revision: number }>(); + expect(revision?.revision).toBeGreaterThan(0); }); it("reconciles a running votekick through its alarm handler on panel access", async () => { diff --git a/tests/unit/wire-shapes.test.ts b/tests/unit/wire-shapes.test.ts index 28838cbb..eb302e6c 100644 --- a/tests/unit/wire-shapes.test.ts +++ b/tests/unit/wire-shapes.test.ts @@ -488,10 +488,10 @@ const requestForRealtime = async (userId: string): Promise => { const allRoles: Record = { broadcaster: true, manager: true, operator: true }; const allMessageTypes: Record = { "system.hello": true, + "panel.resources.changed": true, "event_log.new": true, "variables.changed": true, "overlay.changed": true, - "ads.schedule.updated": true, "stream.state.changed": true, "modul.text_library.blocks_updated": true, "modul.text_library.state_changed": true, @@ -852,15 +852,14 @@ describe("serialized contract shapes", () => { // and the assertion below freezes the spelling. expect(Object.keys(allRoles).sort((a, b) => a.localeCompare(b))).toEqual(["broadcaster", "manager", "operator"]); expect(Object.keys(allMessageTypes).sort((a, b) => a.localeCompare(b))).toEqual([ - "ads.schedule.updated", "event_log.new", "modul.text_library.blocks_updated", "modul.text_library.state_changed", - "overlay.changed", "stream.state.changed", "system.hello", "variables.changed", + "event_log.new", "modul.text_library.blocks_updated", "modul.text_library.state_changed", + "overlay.changed", "panel.resources.changed", "stream.state.changed", "system.hello", "variables.changed", ]); expect(Object.keys(allRecipientKinds).sort((a, b) => a.localeCompare(b))).toEqual(["overlay", "panel"]); expect(Object.keys(REALTIME_RECIPIENTS).sort((a, b) => a.localeCompare(b))).toEqual([...REALTIME_MESSAGE_TYPES].sort((a, b) => a.localeCompare(b))); expect(REALTIME_RECIPIENTS["event_log.new"]).toEqual(["panel"]); expect(REALTIME_RECIPIENTS["variables.changed"]).toEqual(["panel", "overlay"]); expect(REALTIME_RECIPIENTS["overlay.changed"]).toEqual(["panel", "overlay"]); - expect(REALTIME_RECIPIENTS["ads.schedule.updated"]).toEqual(["panel"]); expect(REALTIME_RECIPIENTS["stream.state.changed"]).toEqual(["panel"]); expect(Object.keys(allChatStatus).sort((a, b) => a.localeCompare(b))).toEqual(["broadcaster", "moderator", "subscriber", "viewer", "vip"]); expect(Object.keys(allActionKinds).sort((a, b) => a.localeCompare(b))).toEqual(["announcement", "ban", "chat", "overlay", "shoutout", "timeout"]); diff --git a/tests/worker/channel-object.test.ts b/tests/worker/channel-object.test.ts index 2985c8d2..372d5ec7 100644 --- a/tests/worker/channel-object.test.ts +++ b/tests/worker/channel-object.test.ts @@ -284,6 +284,7 @@ const objectFor = ( bind: vi.fn(() => prepared), all: vi.fn().mockResolvedValue({ results: [] }), first: vi.fn().mockResolvedValue({ token_id: "token-1", overlay_id: null }), + run: vi.fn().mockResolvedValue({ success: true }), }; const getWebSockets = vi.fn((tag?: string) => tag === undefined ? sockets @@ -1159,17 +1160,12 @@ describe("ChannelObject realtime path", () => { expect(panel.send.mock.calls).toHaveLength(0); }); - it("routes bounded module panel hints separately from module overlay messages", async () => { + it("keeps module overlay messages off panel sockets", async () => { const overlay = overlaySocketFor({ v: 1, kind: "overlay", channelId: "kanal-a", tokenId: "token-a", overlayId: "overlay-a", expiresAt: null, }); const panel = socketFor(validPrincipal()); const object = objectFor([overlay, panel]); - const panelMessages = [ - { type: "modul.chat_voting.changed", payload: { part: "panel" } }, - { type: "modul.belabox.changed", payload: { part: "live" } }, - { type: "modul.votekick.changed", payload: { part: "availability" } }, - ]; const overlayMessageTypes = [ "modul.chat_voting.opened", "modul.chat_voting.tally", @@ -1179,24 +1175,19 @@ describe("ChannelObject realtime path", () => { "modul.votekick.tally", "modul.text_library.blocks_updated", ]; - const messages = [ - ...panelMessages, - ...overlayMessageTypes.map((type) => ({ type, payload: { messageType: type } })), - ].map(({ type, payload }, index) => ({ + const messages = overlayMessageTypes.map((type, index) => ({ version: 1, id: `module-message-${String(index)}`, createdAt: "2026-10-09T08:00:00.000Z", channelId: "kanal-a", type, overlayIds: ["overlay-a"], - payload, + payload: { messageType: type }, } as unknown as RealtimeMessage)); await object.publish(messages); - expect(panel.send.mock.calls.map(([serialized]) => typeOfSerializedMessage(serialized))).toEqual( - panelMessages.map(({ type }) => type), - ); + expect(panel.send.mock.calls).toHaveLength(0); expect(overlay.send.mock.calls.map(([serialized]) => typeOfSerializedMessage(serialized))).toEqual([ ...overlayMessageTypes, ]); @@ -1241,7 +1232,7 @@ describe("ChannelObject realtime path", () => { expect(panel.send.mock.calls.map(([message]) => typeOfSerializedMessage(message))).toEqual(["overlay.changed"]); }); - it("coalesces ad-schedule refreshes and pushes panel-only updates while reconciling alarms", async () => { + it("coalesces ad-schedule refreshes and publishes a revision hint without a custom panel message", async () => { const panel = socketFor(validPrincipal()); const overlay = overlaySocketFor({ v: 1, kind: "overlay", channelId: "kanal-a", tokenId: "token-1", expiresAt: null }); const object = objectFor([panel, overlay]); @@ -1257,8 +1248,10 @@ describe("ChannelObject realtime path", () => { expect(first).toEqual(coalesced); expect(first.changed).toBe(true); expect(mocks.refreshAdPrewarningAlarm).toHaveBeenCalledTimes(1); - expect(panel.send.mock.calls).toHaveLength(1); - expect(typeOfSerializedMessage(panel.send.mock.calls[0]?.[0] ?? "")).toBe("ads.schedule.updated"); + expect(panel.send.mock.calls.map(([message]) => typeOfSerializedMessage(message))).toEqual(["panel.resources.changed"]); + expect(JSON.parse(panel.send.mock.calls[0]?.[0] ?? "null")).toMatchObject({ + payload: { resources: ["module:ads:schedule"] }, + }); expect(overlay.send.mock.calls).toHaveLength(0); mocks.getAdSchedule.mockResolvedValueOnce({ fetched: true, reason: null, detail: {}, schedule }); @@ -1266,8 +1259,9 @@ describe("ChannelObject realtime path", () => { expect(second.changed).toBe(false); expect(mocks.refreshAdPrewarningAlarm).toHaveBeenCalledTimes(2); expect(panel.send.mock.calls).toHaveLength(2); - const updated = JSON.parse(panel.send.mock.calls[1]?.[0] ?? "{}") as { payload?: { asOf?: string } }; - expect(updated.payload?.asOf).toBe(second.cache?.asOf); + expect(JSON.parse(panel.send.mock.calls[1]?.[0] ?? "null")).toMatchObject({ + payload: { resources: ["module:ads:schedule"], revisions: { "module:ads:schedule": 2 } }, + }); expect(overlay.send.mock.calls).toHaveLength(0); }); @@ -1842,10 +1836,12 @@ describe("ChannelObject realtime path", () => { const object = objectFor([]); await expect(object.openBallot("chat_voting", "poll-a", 3, 20_000)).resolves.toEqual({ status: "opened" }); + expect(await object.getPanelResourceRevisions()).toMatchObject({ "module:chat_voting:panel": 1 }); await expect(object.openBallot("votekick", "kick-a", 2, 20_000)).resolves.toEqual({ status: "busy", moduleId: "chat_voting", }); + expect(await object.getPanelResourceRevisions()).toMatchObject({ "module:chat_voting:panel": 1 }); }); it("reports whether a shared ballot is open without requiring its module id", async () => { @@ -1901,6 +1897,7 @@ describe("ChannelObject realtime path", () => { await expect(object.openBallot("chat_voting", "poll-a", 3, 30_000)).rejects.toThrow("storage interrupted"); expect((storage.values.get("channel:alarm_schedule") as Record)[alarmKey]).toEqual(originalEntry); + expect(await object.getPanelResourceRevisions()).toMatchObject({ "module:chat_voting:panel": 1 }); expect(storage.setAlarm).not.toHaveBeenCalled(); expect(storage.deleteAlarm).not.toHaveBeenCalled(); }); @@ -1943,6 +1940,7 @@ describe("ChannelObject realtime path", () => { counts: [], revision: 0, }); + expect(await object.getPanelResourceRevisions()).toMatchObject({ "module:chat_voting:panel": 3 }); }); it("stores only hashed term voters, applies blocked filters, and approves terms for one ballot", async () => { @@ -2368,8 +2366,10 @@ describe("ChannelObject realtime path", () => { const first = await object.refreshAdScheduleForCountdown(); expect(first).toMatchObject({ reason: null, cache: { schedule } }); expect(mocks.getAdSchedule).toHaveBeenCalledOnce(); + expect(await object.getPanelResourceRevisions()).toMatchObject({ "module:ads:schedule": 1 }); await object.storeAdSchedule(schedule, "2026-09-25T11:00:00.000Z"); + expect(await object.getPanelResourceRevisions()).toMatchObject({ "module:ads:schedule": 2 }); await expect(object.refreshAdScheduleForCountdown()).resolves.toBeNull(); expect(mocks.getAdSchedule).toHaveBeenCalledOnce(); expect(storageOf(object).values.get("ads:countdown_refresh_attempt")).toBe(Date.now()); diff --git a/tests/worker/healthz.test.ts b/tests/worker/healthz.test.ts index f49a703c..5a97e758 100644 --- a/tests/worker/healthz.test.ts +++ b/tests/worker/healthz.test.ts @@ -151,7 +151,7 @@ describe("worker skeleton", () => { it("checks the schema by highest migration number, not by application order", async () => { const database = (env as unknown as { DB: D1Database }).DB; const status = async () => (await exports.default.fetch(new Request("http://localhost/healthz"))).status; - const sentinel: string = "0046_chat_voting_kind.sql"; + const sentinel: string = "0047_panel_resource_revisions.sql"; const original = (await database.prepare("SELECT name FROM d1_migrations WHERE name LIKE '0040_%' OR name = ?") .bind(sentinel).all<{ name: string }>()).results.map((row) => row.name); const reinsert = async (names: string[]): Promise => { @@ -162,7 +162,7 @@ describe("worker skeleton", () => { } }; try { - // 0040 applied last (highest id) but the highest number is still 0046. + // 0040 applied last (highest id) but the highest number is still 0047. await reinsert(original.filter((name) => name === sentinel).concat(original.filter((name) => name !== sentinel))); expect(await status()).toBe(200); From 33425c638c883589bc585f1d70286a1c2a9297e9 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 13:39:52 +0200 Subject: [PATCH 07/16] =?UTF-8?q?=F0=9F=94=A7=20chore(realtime):=20align?= =?UTF-8?q?=20with=20chat=20voting=20kind=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chat voting realtime coverage (#387) - Guard routes, chat commands, alarms, ballot writes, and word approvals with committed-resource invalidation. - Keep connected panel refreshes driven by server-confirmed state with polling disabled. - Test compatibility (#387) - Align fixtures and saved-duration coverage with the chat voting kind model and 0046/0047 schema. --- src/modules/chat_voting/panel/index.tsx | 20 ++------ tests/components/chat-voting-panel.test.tsx | 10 ++-- tests/unit/chat-voting-close.test.ts | 2 +- tests/unit/panel-resource-revisions.test.ts | 52 ++++++++++++++++++++- 4 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/modules/chat_voting/panel/index.tsx b/src/modules/chat_voting/panel/index.tsx index 9a840392..f46e090e 100644 --- a/src/modules/chat_voting/panel/index.tsx +++ b/src/modules/chat_voting/panel/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ReactElement } from "react"; +import { useCallback, useRef, useState, type ReactElement } from "react"; import { Button, Field, Led, LoadState, notify, NumberField, SegmentedControl, Select, Skeleton, Switch } from "../../../dashboard/ui"; import type { ModulePanelProperties } from "../../contract"; @@ -55,7 +55,6 @@ const createVoteDraft = (channelId: string, defaultDurationSeconds: number): Vot export const ChatVotingPanel = ({ channelId, language = "de", canOperate = true }: ModulePanelProperties): ReactElement => { const labels = chatVotingPanelTexts(language); - const [state, setState] = useState(null); const loadErrorNotified = useRef(false); const [busy, setBusy] = useState(false); const [draftState, setDraftState] = useState(null); @@ -76,29 +75,18 @@ export const ChatVotingPanel = ({ channelId, language = "de", canOperate = true }, { refetchInterval: realtimeStatus === "connected" ? false : 2_000, }); - - const applyState = useCallback((next: ChatVotingPanelState): void => { - setState(next); - setDraftState((current) => current?.channelId === channelId - ? current - : createVoteDraft(channelId, next.defaultDurationSeconds)); - loadErrorNotified.current = false; - }, [channelId]); - - useEffect(() => { - if (stateQuery.data !== undefined) applyState(stateQuery.data); - }, [applyState, stateQuery.data]); + const state = stateQuery.data ?? null; const refresh = useCallback(async (): Promise => { try { - applyState(await refetchModuleQueryData(queryClient, channelId, "chat_voting", "panel")); + await refetchModuleQueryData(queryClient, channelId, "chat_voting", "panel"); } catch { if (!loadErrorNotified.current) { loadErrorNotified.current = true; notify({ tone: "error", message: labels.loadError }); } } - }, [applyState, channelId, labels.loadError, queryClient]); + }, [channelId, labels.loadError, queryClient]); const draft = draftState?.channelId === channelId ? draftState diff --git a/tests/components/chat-voting-panel.test.tsx b/tests/components/chat-voting-panel.test.tsx index ccf71c4f..fb6ef568 100644 --- a/tests/components/chat-voting-panel.test.tsx +++ b/tests/components/chat-voting-panel.test.tsx @@ -180,7 +180,7 @@ describe("chat voting live panel", () => { expect(screen.getByRole("textbox", { name: "Label for option 2" })).toHaveValue("Burger"); }); - it("uses saved default labels and duration for the next vote", async () => { + it("uses the saved default duration for the next vote", async () => { setDashboardRealtimeStatus("fictional-channel", "connected"); let serverState = currentState(); let startPayload: unknown; @@ -190,10 +190,9 @@ describe("chat voting live panel", () => { )); const view = render(); - expect(await screen.findByRole("textbox", { name: "Label for option 1" })).toHaveAttribute("placeholder", "Yes"); + expect(await screen.findByRole("button", { name: "Start vote" })).toBeEnabled(); serverState = currentState({ defaultDurationSeconds: 300, - defaultLabels: { ...defaultLabels, yes_no: ["Approve", "Reject"] }, }); await act(async () => { @@ -204,9 +203,10 @@ describe("chat voting live panel", () => { }); }); - expect(await screen.findByRole("textbox", { name: "Label for option 1" })).toHaveAttribute("placeholder", "Approve"); + await waitFor(() => expect(view.queryClient.getQueryData(moduleQueryKey("fictional-channel", "chat_voting", "panel"))) + .toMatchObject({ defaultDurationSeconds: 300 })); fireEvent.click(screen.getByRole("button", { name: "Start vote" })); - await waitFor(() => expect(startPayload).toEqual({ preset: "yes_no", durationSeconds: 300, labels: ["Approve", "Reject"] })); + await waitFor(() => expect(startPayload).toEqual({ kind: "yes_no", labels: ["Yes", "No"], durationSeconds: 300 })); }); it("sends a trimmed question and enforces its 80-code-point limit", async () => { diff --git a/tests/unit/chat-voting-close.test.ts b/tests/unit/chat-voting-close.test.ts index e493fbe5..33bafd56 100644 --- a/tests/unit/chat-voting-close.test.ts +++ b/tests/unit/chat-voting-close.test.ts @@ -226,7 +226,7 @@ describe("chat voting close service", () => { const repository = await seedOpenVote(false); await database.prepare( `UPDATE chat_votes - SET preset = 'free_text', option_count = 0, labels_json = '[]', text_mode = 'whole_message', + SET kind = 'free_text', preset = 'free_text', option_count = 0, labels_json = '[]', text_mode = 'whole_message', term_filter_ready = 1 WHERE channel_id = 'fictional-channel' AND poll_id = 'fictional-poll'`, ).run(); diff --git a/tests/unit/panel-resource-revisions.test.ts b/tests/unit/panel-resource-revisions.test.ts index 002c1b57..5738f00d 100644 --- a/tests/unit/panel-resource-revisions.test.ts +++ b/tests/unit/panel-resource-revisions.test.ts @@ -144,8 +144,22 @@ const panelWriteRegressions: readonly PanelWriteRegressionCase[] = [ keys: (id) => [moduleQueryKey(id, "chat_voting", "panel")], write: async (database, id) => { await database.prepare( - `INSERT INTO chat_votes (channel_id, poll_id, preset, option_count, labels_json, status, opened_at, closes_at, close_reason) - VALUES (?, 'poll-1', 'yes_no', 2, '["Yes","No"]', 'open', '2026-10-10T00:00:00.000Z', '2026-10-10T00:02:00.000Z', 'manual')`, + `INSERT INTO chat_votes (channel_id, poll_id, kind, preset, legacy_written, option_count, labels_json, status, opened_at, closes_at, close_reason) + VALUES (?, 'poll-1', 'yes_no', 'yes_no', 0, 2, '["Yes","No"]', 'open', '2026-10-10T00:00:00.000Z', '2026-10-10T00:02:00.000Z', 'manual')`, + ).bind(id).run(); + }, + }, + { + name: "chat voting word approvals", + keys: (id) => [moduleQueryKey(id, "chat_voting", "panel")], + write: async (database, id) => { + await database.prepare( + `INSERT INTO chat_votes (channel_id, poll_id, kind, preset, legacy_written, option_count, labels_json, text_mode, term_filter_ready, status, opened_at, closes_at, close_reason) + VALUES (?, 'poll-words', 'free_text', 'free_text', 0, 0, '[]', 'first_word', 1, 'open', '2026-10-10T00:00:00.000Z', '2026-10-10T00:02:00.000Z', 'manual')`, + ).bind(id).run(); + await database.prepare( + `INSERT INTO chat_vote_term_approvals (channel_id, poll_id, term, approved_at, approved_by) + VALUES (?, 'poll-words', 'pizza', '2026-10-10T00:00:01.000Z', 'manager-1')`, ).bind(id).run(); }, }, @@ -222,9 +236,25 @@ describe("panel resource revisions", () => { const moduleMutations = MODULES.flatMap((module) => module.routes?.routes ?? []) .filter(({ method }) => writeMethods.has(method)); + const chatVoting = MODULES.find(({ id }) => id === "chat_voting"); + const chatVotingRoutes = chatVoting?.routes?.routes ?? []; + const chatVotingMutations = chatVotingRoutes.filter(({ method }) => writeMethods.has(method)); const moduleAlarms = MODULES.flatMap((module) => module.alarms ?? []); const maintenanceJobs = MODULES.filter((module) => module.scheduledMaintenance !== undefined); expect(moduleMutations.length).toBeGreaterThan(0); + expect(chatVoting).toBeDefined(); + expect(chatVotingMutations.map(({ method, path }) => `${method} ${path.slice(path.lastIndexOf("/"))}`)).toEqual( + expect.arrayContaining(["POST /start", "POST /approve-term", "POST /close"]), + ); + expect(chatVotingRoutes.some(({ method, path }) => method === "GET" && path.endsWith("/current"))).toBe(true); + for (const { path } of chatVotingRoutes) { + const mountedPath = path.startsWith("/api/channels/") + ? path + : `/api/channels/:channelId/modules/chat_voting${path}`; + expect(routeIsCovered(mountedPath, PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes)).toBe(true); + } + expect(chatVoting?.eventSubTypes).toContain("channel.chat.message"); + expect(chatVoting?.alarms?.length ?? 0).toBeGreaterThan(0); expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.moduleRoutes).toBe("channelRoutes"); const moduleRouterSource = readFileSync(resolve(root, "src/worker/panel/module-routes.ts"), "utf8"); expect(moduleRouterSource).toContain("moduleRouter.route(`/api/channels/:channelId/modules/${module.id}`, module.routes)"); @@ -257,7 +287,23 @@ describe("panel resource revisions", () => { const worker = readFileSync(resolve(root, "src/worker/index.ts"), "utf8"); expect(worker).toContain("app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes"); expect(worker).toContain("app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.platformRoutes"); + expect(worker).toMatch(/app\.use\(PANEL_RESOURCE_NOTIFIER_COVERAGE\.channelRoutes,[\s\S]*?finally \{\s*const channelId = context\.req\.param\("channelId"\);[\s\S]*?await notifyCommittedResources\(context\.env, channelId\);/u); expect(worker).not.toMatch(/context\.res\.status !== 401/u); + const ballotStorageStart = channelObject.indexOf("private ballotStorage("); + const ballotStorageEnd = channelObject.indexOf("private async notifyCommittedPanelResources(", ballotStorageStart); + const ballotStorage = ballotStorageStart < 0 || ballotStorageEnd < 0 ? "" : channelObject.slice(ballotStorageStart, ballotStorageEnd); + expect(ballotStorage).toContain("this.ballotPanelResources(match[1])"); + expect(ballotStorage).toMatch(/bumpDurablePanelResources\(underlying, \[\.\.\.changedResources\]\)/u); + const ballotCastStart = channelObject.indexOf("public async castBallot("); + const ballotTermCastStart = channelObject.indexOf("public async castBallotTerm(", ballotCastStart); + const ballotCast = ballotCastStart < 0 || ballotTermCastStart < 0 ? "" : channelObject.slice(ballotCastStart, ballotTermCastStart); + expect(ballotCast).toContain("choice, options"); + expect(ballotCast).toMatch(/result\.status === "counted" \|\| result\.status === "changed"\) \{\s*await this\.notifyCommittedPanelResources\(\);/u); + const ballotTermCastEnd = channelObject.indexOf("public async setBlockedTerms(", ballotTermCastStart); + const ballotTermCast = ballotTermCastStart < 0 || ballotTermCastEnd < 0 ? "" : channelObject.slice(ballotTermCastStart, ballotTermCastEnd); + expect(ballotTermCast).toContain("castStoredBallotTerm"); + expect(ballotTermCast).toContain('result.status === "overflow"'); + expect(ballotTermCast).toContain("await this.notifyCommittedPanelResources()"); const eventSubSource = readFileSync(resolve(root, "src/worker/eventsub.ts"), "utf8"); expect(eventSubSource).toMatch(/finally \{\s*await notifyCommittedResources\(context\.env, revocation\.channelId\);/u); expect(eventSubSource).toMatch(/finally \{\s*await notifyCommittedResources\(context\.env, notifierChannelId\);/u); @@ -570,6 +616,8 @@ describe("panel resource revisions", () => { expect(resourcesFor("text_commands")).toContain("channel.variables"); expect(resourcesFor("channel_variables")).toContain("channel.library"); expect(resourcesFor("overlays")).toContain("channel.variables"); + expect(resourcesFor("chat_votes")).toContain("module:chat_voting:panel"); + expect(resourcesFor("chat_vote_term_approvals")).toContain("module:chat_voting:panel"); } finally { database.close(); } From 79beef5a78faec8837eb277cd993ba0f76ac7fd3 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 15:14:56 +0200 Subject: [PATCH 08/16] =?UTF-8?q?=F0=9F=90=9B=20fix(realtime):=20close=20c?= =?UTF-8?q?overage=20and=20authorization=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #387 Add commit-coupled resource revisions, per-channel outbox delivery, and write dependency coverage across D1 and Durable Object commits. - #387 Reconcile query revisions on socket connect, hints, and focus; keep refresh polling as an offline fallback. - #387 Recheck panel authorization at send and handshake time, and await socket closure after membership or session revocation. - #387 Add route and alarm registry guards, migration and lost-hint regressions, two-client coverage, and repeated navigation E2E checks. --- ...0048_committed_panel_resource_delivery.sql | 255 ++++++++++++++ src/dashboard/data/client.ts | 1 + src/dashboard/data/realtime.ts | 46 +-- src/dashboard/main.tsx | 9 - src/modules/api_source/adapters/d1.ts | 10 +- src/modules/api_source/adapters/fetch-json.ts | 2 +- src/worker/app-routes.ts | 40 +++ src/worker/auth/routes.ts | 4 +- src/worker/config.ts | 4 +- src/worker/durable/ChannelObject.ts | 95 ++++- src/worker/eventsub.ts | 10 +- src/worker/index.ts | 156 +-------- src/worker/login-maintenance.ts | 8 +- src/worker/panel-resources.ts | 115 +++--- src/worker/panel/member-routes.ts | 4 +- src/worker/platform/routes.ts | 3 + src/worker/realtime.ts | 43 ++- src/worker/worker-app.ts | 116 ++++++ tests/components/events-page.test.tsx | 2 +- tests/e2e/host-stable-layout.spec.ts | 27 +- tests/e2e/realtime-navigation.spec.ts | 196 +++-------- tests/unit/auth-routes.test.ts | 44 +++ tests/unit/dashboard-query-data.test.tsx | 33 +- tests/unit/dashboard-realtime.test.ts | 70 +++- tests/unit/login-maintenance.test.ts | 36 +- tests/unit/member-routes.test.ts | 27 ++ tests/unit/panel-resource-revisions.test.ts | 331 +++++++++++++++++- tests/unit/platform-routes.test.ts | 4 + tests/unit/realtime.test.ts | 75 +++- tests/unit/sql-contract.test.ts | 8 +- tests/worker/channel-object.test.ts | 101 +++++- tests/worker/healthz.test.ts | 4 +- 32 files changed, 1394 insertions(+), 485 deletions(-) create mode 100644 migrations/0048_committed_panel_resource_delivery.sql create mode 100644 src/worker/app-routes.ts create mode 100644 src/worker/worker-app.ts diff --git a/migrations/0048_committed_panel_resource_delivery.sql b/migrations/0048_committed_panel_resource_delivery.sql new file mode 100644 index 00000000..04055f9d --- /dev/null +++ b/migrations/0048_committed_panel_resource_delivery.sql @@ -0,0 +1,255 @@ +-- The D1 triggers in 0047 already advance resource revisions with the write +-- transaction. Keep a durable per-channel publication queue beside them so +-- notifiers only visit channels with committed, unpublished panel changes. +CREATE TABLE panel_resource_pending ( + channel_id TEXT NOT NULL, + resource TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + PRIMARY KEY (channel_id, resource) +); + +CREATE INDEX panel_resource_pending_channel_idx ON panel_resource_pending(channel_id); + +CREATE TRIGGER panel_resource_pending_insert AFTER INSERT ON panel_resource_revisions BEGIN + INSERT INTO panel_resource_pending (channel_id, resource, revision) + VALUES (NEW.channel_id, NEW.resource, NEW.revision) + ON CONFLICT(channel_id, resource) DO UPDATE SET revision = MAX(revision, excluded.revision); +END; + +CREATE TRIGGER panel_resource_pending_update AFTER UPDATE ON panel_resource_revisions BEGIN + INSERT INTO panel_resource_pending (channel_id, resource, revision) + VALUES (NEW.channel_id, NEW.resource, NEW.revision) + ON CONFLICT(channel_id, resource) DO UPDATE SET revision = MAX(revision, excluded.revision); +END; + +-- A global channel-list revision is expanded to channel-local work only when +-- a write actually changes that shared list. The notifier drains these rows +-- instead of enumerating the installation on every request. +CREATE TRIGGER panel_rev_global_channels_update +AFTER UPDATE OF revision ON panel_global_resource_revisions +WHEN NEW.resource = 'channels' AND NEW.revision > OLD.revision BEGIN + INSERT INTO panel_resource_revisions (channel_id, resource, revision) + SELECT channel_id, 'channels', 1 FROM channels WHERE 1 + ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +-- A channel delete cascades through its source rows. Their dependency triggers +-- may enqueue revisions during that cascade, so clean the deleted channel's +-- revision and outbox rows after the cascaded writes finish. +DROP TRIGGER panel_rev_channels_delete; +CREATE TRIGGER panel_rev_channels_delete AFTER DELETE ON channels BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; + DELETE FROM panel_resource_revisions WHERE channel_id = OLD.channel_id; + DELETE FROM panel_resource_pending WHERE channel_id = OLD.channel_id; +END; + +UPDATE panel_global_resource_revisions + SET revision = revision + 1 + WHERE resource = 'channels'; + +-- Existing cache hashes cannot be reversed to recover their owning channel. +-- New API-cache writes store that owner explicitly; old cache rows expire in +-- at most the existing five-minute cache window and need no synthetic fanout. +ALTER TABLE api_source_cache ADD COLUMN channel_id TEXT REFERENCES channels(channel_id) ON DELETE CASCADE; + +DELETE FROM panel_resource_dependencies WHERE source_table IN ('api_source_cache', 'weather_cache'); +INSERT OR IGNORE INTO panel_resource_dependencies (source_table, resource) VALUES + ('api_source_cache', 'channel.library'), + ('weather_cache', 'channel.library'); + +DELETE FROM panel_resource_dependencies WHERE source_table = 'channels' AND resource = 'channel.library'; +INSERT INTO panel_resource_dependencies (source_table, resource) VALUES ('channels', 'channel.library'); + +INSERT OR IGNORE INTO panel_resource_dependencies (source_table, resource) VALUES + ('channel_modules', 'channel.system'), + ('channel_modules', 'channel.library'), + ('channel_modules', 'module:*:data'), + ('bot_identity', 'channel.overview'), + ('bot_identity', 'channel.system'), + ('bot_identity', 'channel.modules'), + ('bot_identity', 'module:*:settings'), + ('bot_identity_status', 'channel.overview'), + ('bot_identity_status', 'channel.system'), + ('bot_identity_status', 'channel.modules'), + ('bot_identity_status', 'module:*:settings'), + ('twitch_login_identity', 'channel.members'), + ('twitch_login_identity', 'channel.audit'), + ('twitch_login_identity', 'channel.modules'), + ('twitch_login_identity', 'module:*:settings'), + ('twitch_login_identity', 'module:ads:schedule'); + +-- Keep the legacy global cache counters and publication table through the +-- migration-before-code rollout window. Workers already serving traffic may +-- still read and update them; a later migration can remove them after rollout. + +DROP TRIGGER panel_rev_modules_insert; +DROP TRIGGER panel_rev_modules_update; +DROP TRIGGER panel_rev_modules_delete; +CREATE TRIGGER panel_rev_modules_insert AFTER INSERT ON channel_modules BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource NOT IN ('channels', 'module:*:settings', 'module:*:data') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, replace(resource, '*', NEW.module_id), 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource IN ('module:*:settings', 'module:*:data') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_modules_update AFTER UPDATE ON channel_modules BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource NOT IN ('channels', 'module:*:settings', 'module:*:data') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, replace(resource, '*', NEW.module_id), 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource IN ('module:*:settings', 'module:*:data') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_modules_delete AFTER DELETE ON channel_modules BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource NOT IN ('channels', 'module:*:settings', 'module:*:data') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, replace(resource, '*', OLD.module_id), 1 FROM panel_resource_dependencies WHERE source_table = 'channel_modules' AND resource IN ('module:*:settings', 'module:*:data') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +-- Bot identity is global. Its committed row changes affect every channel's +-- connection, module permission and channel-list state, so revisions are +-- expanded here in the same D1 transaction. +DROP TRIGGER panel_rev_bot_identity_insert; +DROP TRIGGER panel_rev_bot_identity_update; +DROP TRIGGER panel_rev_bot_identity_delete; +DROP TRIGGER panel_rev_bot_identity_status_insert; +DROP TRIGGER panel_rev_bot_identity_status_update; +DROP TRIGGER panel_rev_bot_identity_status_delete; +CREATE TRIGGER panel_rev_bot_identity_insert AFTER INSERT ON bot_identity BEGIN + INSERT INTO panel_resource_revisions SELECT channel.channel_id, dependency.resource, 1 FROM channels AS channel JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'bot_identity' WHERE dependency.resource NOT IN ('channels', 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel.channel_id, 'module:' || module.module_id || ':settings', 1 FROM channels AS channel JOIN channel_modules AS module ON module.channel_id = channel.channel_id WHERE EXISTS (SELECT 1 FROM panel_resource_dependencies WHERE source_table = 'bot_identity' AND resource = 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_identity_update AFTER UPDATE ON bot_identity BEGIN + INSERT INTO panel_resource_revisions SELECT channel.channel_id, dependency.resource, 1 FROM channels AS channel JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'bot_identity' WHERE dependency.resource NOT IN ('channels', 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel.channel_id, 'module:' || module.module_id || ':settings', 1 FROM channels AS channel JOIN channel_modules AS module ON module.channel_id = channel.channel_id WHERE EXISTS (SELECT 1 FROM panel_resource_dependencies WHERE source_table = 'bot_identity' AND resource = 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_identity_delete AFTER DELETE ON bot_identity BEGIN + INSERT INTO panel_resource_revisions SELECT channel.channel_id, dependency.resource, 1 FROM channels AS channel JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'bot_identity' WHERE dependency.resource NOT IN ('channels', 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel.channel_id, 'module:' || module.module_id || ':settings', 1 FROM channels AS channel JOIN channel_modules AS module ON module.channel_id = channel.channel_id WHERE EXISTS (SELECT 1 FROM panel_resource_dependencies WHERE source_table = 'bot_identity' AND resource = 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_identity_status_insert AFTER INSERT ON bot_identity_status BEGIN + INSERT INTO panel_resource_revisions SELECT channel.channel_id, dependency.resource, 1 FROM channels AS channel JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'bot_identity_status' WHERE dependency.resource NOT IN ('channels', 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel.channel_id, 'module:' || module.module_id || ':settings', 1 FROM channels AS channel JOIN channel_modules AS module ON module.channel_id = channel.channel_id WHERE EXISTS (SELECT 1 FROM panel_resource_dependencies WHERE source_table = 'bot_identity_status' AND resource = 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_identity_status_update AFTER UPDATE ON bot_identity_status BEGIN + INSERT INTO panel_resource_revisions SELECT channel.channel_id, dependency.resource, 1 FROM channels AS channel JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'bot_identity_status' WHERE dependency.resource NOT IN ('channels', 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel.channel_id, 'module:' || module.module_id || ':settings', 1 FROM channels AS channel JOIN channel_modules AS module ON module.channel_id = channel.channel_id WHERE EXISTS (SELECT 1 FROM panel_resource_dependencies WHERE source_table = 'bot_identity_status' AND resource = 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_bot_identity_status_delete AFTER DELETE ON bot_identity_status BEGIN + INSERT INTO panel_resource_revisions SELECT channel.channel_id, dependency.resource, 1 FROM channels AS channel JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'bot_identity_status' WHERE dependency.resource NOT IN ('channels', 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel.channel_id, 'module:' || module.module_id || ':settings', 1 FROM channels AS channel JOIN channel_modules AS module ON module.channel_id = channel.channel_id WHERE EXISTS (SELECT 1 FROM panel_resource_dependencies WHERE source_table = 'bot_identity_status' AND resource = 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +-- A login identity updates the member row in every current channel, the owner +-- channel's permission-dependent module settings and ads query, and audit +-- filters that still contain the actor after membership removal. +DROP TRIGGER panel_rev_login_identity_insert; +DROP TRIGGER panel_rev_login_identity_update; +DROP TRIGGER panel_rev_login_identity_delete; +CREATE TRIGGER panel_rev_login_identity_insert AFTER INSERT ON twitch_login_identity BEGIN + INSERT INTO panel_resource_revisions SELECT DISTINCT channel.channel_id, dependency.resource, 1 FROM channels AS channel JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'twitch_login_identity' LEFT JOIN channel_members AS member ON member.channel_id = channel.channel_id WHERE (channel.channel_id = NEW.user_id OR member.user_id = NEW.user_id) AND dependency.resource NOT IN ('channels', 'channel.modules', 'module:*:settings', 'module:ads:schedule') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.user_id, dependency.resource, 1 FROM panel_resource_dependencies AS dependency WHERE dependency.source_table = 'twitch_login_identity' AND dependency.resource IN ('channel.modules', 'module:ads:schedule') AND EXISTS (SELECT 1 FROM channels WHERE channel_id = NEW.user_id) ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.user_id, 'module:' || module.module_id || ':settings', 1 FROM channel_modules AS module WHERE module.channel_id = NEW.user_id AND EXISTS (SELECT 1 FROM panel_resource_dependencies WHERE source_table = 'twitch_login_identity' AND resource = 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT DISTINCT audit.channel_id, 'channel.audit', 1 FROM audit_log AS audit WHERE audit.actor_user_id = NEW.user_id ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_login_identity_update AFTER UPDATE ON twitch_login_identity BEGIN + INSERT INTO panel_resource_revisions SELECT DISTINCT channel.channel_id, dependency.resource, 1 FROM channels AS channel JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'twitch_login_identity' LEFT JOIN channel_members AS member ON member.channel_id = channel.channel_id WHERE (channel.channel_id = NEW.user_id OR member.user_id = NEW.user_id) AND dependency.resource NOT IN ('channels', 'channel.modules', 'module:*:settings', 'module:ads:schedule') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.user_id, dependency.resource, 1 FROM panel_resource_dependencies AS dependency WHERE dependency.source_table = 'twitch_login_identity' AND dependency.resource IN ('channel.modules', 'module:ads:schedule') AND EXISTS (SELECT 1 FROM channels WHERE channel_id = NEW.user_id) ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT NEW.user_id, 'module:' || module.module_id || ':settings', 1 FROM channel_modules AS module WHERE module.channel_id = NEW.user_id AND EXISTS (SELECT 1 FROM panel_resource_dependencies WHERE source_table = 'twitch_login_identity' AND resource = 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT DISTINCT audit.channel_id, 'channel.audit', 1 FROM audit_log AS audit WHERE audit.actor_user_id = NEW.user_id ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; +CREATE TRIGGER panel_rev_login_identity_delete AFTER DELETE ON twitch_login_identity BEGIN + INSERT INTO panel_resource_revisions SELECT DISTINCT channel.channel_id, dependency.resource, 1 FROM channels AS channel JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'twitch_login_identity' LEFT JOIN channel_members AS member ON member.channel_id = channel.channel_id WHERE (channel.channel_id = OLD.user_id OR member.user_id = OLD.user_id) AND dependency.resource NOT IN ('channels', 'channel.modules', 'module:*:settings', 'module:ads:schedule') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT OLD.user_id, dependency.resource, 1 FROM panel_resource_dependencies AS dependency WHERE dependency.source_table = 'twitch_login_identity' AND dependency.resource IN ('channel.modules', 'module:ads:schedule') AND EXISTS (SELECT 1 FROM channels WHERE channel_id = OLD.user_id) ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT OLD.user_id, 'module:' || module.module_id || ':settings', 1 FROM channel_modules AS module WHERE module.channel_id = OLD.user_id AND EXISTS (SELECT 1 FROM panel_resource_dependencies WHERE source_table = 'twitch_login_identity' AND resource = 'module:*:settings') ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT DISTINCT audit.channel_id, 'channel.audit', 1 FROM audit_log AS audit WHERE audit.actor_user_id = OLD.user_id ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'channels'; +END; + +DROP TRIGGER panel_rev_api_source_cache_insert; +DROP TRIGGER panel_rev_api_source_cache_update; +DROP TRIGGER panel_rev_api_source_cache_delete; +CREATE TRIGGER panel_rev_api_source_cache_insert AFTER INSERT ON api_source_cache BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'api_source_cache' AND NEW.channel_id IS NOT NULL ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + -- Old workers do not store cache ownership. Fan out the library revision so + -- new clients can recover a missed hint after those writes are committed. + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.library', 1 FROM channels WHERE NEW.channel_id IS NULL ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'api.cache' AND NEW.channel_id IS NULL; +END; +CREATE TRIGGER panel_rev_api_source_cache_update AFTER UPDATE ON api_source_cache BEGIN + INSERT INTO panel_resource_revisions SELECT NEW.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'api_source_cache' AND NEW.channel_id IS NOT NULL ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.library', 1 FROM channels WHERE NEW.channel_id IS NULL ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'api.cache' AND (OLD.channel_id IS NULL OR NEW.channel_id IS NULL); +END; +CREATE TRIGGER panel_rev_api_source_cache_delete AFTER DELETE ON api_source_cache BEGIN + INSERT INTO panel_resource_revisions SELECT OLD.channel_id, resource, 1 FROM panel_resource_dependencies WHERE source_table = 'api_source_cache' AND OLD.channel_id IS NOT NULL ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions SELECT channel_id, 'channel.library', 1 FROM channels WHERE OLD.channel_id IS NULL ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'api.cache' AND OLD.channel_id IS NULL; +END; + +DROP TRIGGER panel_rev_weather_cache_insert; +DROP TRIGGER panel_rev_weather_cache_update; +DROP TRIGGER panel_rev_weather_cache_delete; +CREATE TRIGGER panel_rev_weather_cache_insert AFTER INSERT ON weather_cache BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'weather.cache'; + INSERT INTO panel_resource_revisions + SELECT channel.channel_id, dependency.resource, 1 + FROM channels AS channel + LEFT JOIN weather_settings AS setting ON setting.channel_id = channel.channel_id + JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'weather_cache' + WHERE channel.location_latitude IS NOT NULL + AND channel.location_longitude IS NOT NULL + AND round(channel.location_latitude, 4) = NEW.latitude + AND round(channel.location_longitude, 4) = NEW.longitude + AND COALESCE(setting.provider, 'met_norway') = NEW.provider + ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_weather_cache_update AFTER UPDATE ON weather_cache BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'weather.cache'; + INSERT INTO panel_resource_revisions + SELECT channel.channel_id, dependency.resource, 1 + FROM channels AS channel + LEFT JOIN weather_settings AS setting ON setting.channel_id = channel.channel_id + JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'weather_cache' + WHERE channel.location_latitude IS NOT NULL + AND channel.location_longitude IS NOT NULL + AND round(channel.location_latitude, 4) = NEW.latitude + AND round(channel.location_longitude, 4) = NEW.longitude + AND COALESCE(setting.provider, 'met_norway') = NEW.provider + ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; + INSERT INTO panel_resource_revisions + SELECT channel.channel_id, dependency.resource, 1 + FROM channels AS channel + LEFT JOIN weather_settings AS setting ON setting.channel_id = channel.channel_id + JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'weather_cache' + WHERE channel.location_latitude IS NOT NULL + AND channel.location_longitude IS NOT NULL + AND round(channel.location_latitude, 4) = OLD.latitude + AND round(channel.location_longitude, 4) = OLD.longitude + AND COALESCE(setting.provider, 'met_norway') = OLD.provider + ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; +CREATE TRIGGER panel_rev_weather_cache_delete AFTER DELETE ON weather_cache BEGIN + UPDATE panel_global_resource_revisions SET revision = revision + 1 WHERE resource = 'weather.cache'; + INSERT INTO panel_resource_revisions + SELECT channel.channel_id, dependency.resource, 1 + FROM channels AS channel + LEFT JOIN weather_settings AS setting ON setting.channel_id = channel.channel_id + JOIN panel_resource_dependencies AS dependency ON dependency.source_table = 'weather_cache' + WHERE channel.location_latitude IS NOT NULL + AND channel.location_longitude IS NOT NULL + AND round(channel.location_latitude, 4) = OLD.latitude + AND round(channel.location_longitude, 4) = OLD.longitude + AND COALESCE(setting.provider, 'met_norway') = OLD.provider + ON CONFLICT(channel_id, resource) DO UPDATE SET revision = revision + 1; +END; + +-- Deliver pre-migration revisions once; reconnect and focus checks remain the +-- fallback if publication fails after the durable revision commit. +INSERT INTO panel_resource_pending (channel_id, resource, revision) +SELECT channel_id, resource, revision FROM panel_resource_revisions +WHERE 1 +ON CONFLICT(channel_id, resource) DO UPDATE SET revision = MAX(revision, excluded.revision); diff --git a/src/dashboard/data/client.ts b/src/dashboard/data/client.ts index bc1167c3..2a842f75 100644 --- a/src/dashboard/data/client.ts +++ b/src/dashboard/data/client.ts @@ -44,6 +44,7 @@ export const createDashboardQueryClient = (onAuthenticationRequired?: () => void queryFn: requestChannelResource, staleTime: 30_000, gcTime: 600_000, + refetchOnWindowFocus: false, placeholderData: keepPreviousData, retry: (failureCount, error) => !(error instanceof PanelApiError && error.status === 401) && failureCount < 1, diff --git a/src/dashboard/data/realtime.ts b/src/dashboard/data/realtime.ts index 0ff8aa58..0f00e67c 100644 --- a/src/dashboard/data/realtime.ts +++ b/src/dashboard/data/realtime.ts @@ -7,6 +7,7 @@ import { refreshQuery } from "./refresh"; import { dispatchDashboardAuthenticationRequired } from "./events"; const REALTIME_REFRESH_INTERVAL_MS = 1_000; +const PANEL_REVISION_READ_INTERVAL_MS = 250; interface ScheduledRealtimeRefresh { lastStartedAt: number; @@ -17,8 +18,9 @@ interface ScheduledRealtimeRefresh { const scheduledRefreshes = new WeakMap>(); const panelRevisionSnapshots = new WeakMap>>>(); interface PanelRevisionFetch { + lastStartedAt: number; dirty: boolean; - promise: Promise; + promise: Promise | null; } const panelRevisionFetches = new WeakMap>(); @@ -140,18 +142,11 @@ const invalidatePanelResource = (queryClient: QueryClient, channelId: string, re invalidate(queryClient, queryKeys.channel(channelId, "overlay-accesses"), scheduledQueryKeys); invalidate(queryClient, dashboardDataKeys.legacyOverlayTokens(channelId), scheduledQueryKeys); return; - case "weather.cache": - invalidate(queryClient, queryKeys.module(channelId, "text_library", "library"), scheduledQueryKeys); - return; - case "api.cache": - invalidate(queryClient, queryKeys.module(channelId, "api_source", "sources"), scheduledQueryKeys); - return; case "channel.library": invalidate(queryClient, queryKeys.module(channelId, "text_library", "library"), scheduledQueryKeys); invalidate(queryClient, queryKeys.module(channelId, "timers", "panel"), scheduledQueryKeys); invalidate(queryClient, queryKeys.module(channelId, "faq", "panel"), scheduledQueryKeys); invalidate(queryClient, queryKeys.module(channelId, "text_commands", "template-variables"), scheduledQueryKeys); - invalidate(queryClient, queryKeys.module(channelId, "api_source", "sources"), scheduledQueryKeys); return; default: break; @@ -177,7 +172,7 @@ const validPanelRevisionVector = (value: unknown): value is Readonly resource.length <= 128 && Number.isSafeInteger(revision) && Number(revision) >= 0); -/** Fetches and compares the channel vector; duplicate hints share an in-flight read. */ +/** Reads at most once per short interval; hints retain one trailing channel reconciliation. */ export const reconcileDashboardPanelResourceRevisions = ( queryClient: QueryClient, channelId: string, @@ -192,15 +187,23 @@ export const reconcileDashboardPanelResourceRevisions = ( fetches = new Map(); panelRevisionFetches.set(queryClient, fetches); } - const inFlight = fetches.get(channelId); - if (inFlight !== undefined) { - inFlight.dirty = true; - return inFlight.promise; + let state = fetches.get(channelId); + if (state === undefined) { + state = { lastStartedAt: Number.NEGATIVE_INFINITY, dirty: false, promise: null }; + fetches.set(channelId, state); + } + if (state.promise !== null) { + state.dirty = true; + return state.promise; } - const state: PanelRevisionFetch = { dirty: false, promise: Promise.resolve() }; + const scheduled = state; const request = (async (): Promise => { do { - state.dirty = false; + const wait = Math.max(0, PANEL_REVISION_READ_INTERVAL_MS - (Date.now() - scheduled.lastStartedAt)); + if (wait > 0) await new Promise((resolve) => { setTimeout(resolve, wait); }); + // All hints received before this read are represented by its authoritative vector. + scheduled.dirty = false; + scheduled.lastStartedAt = Date.now(); try { const response = await fetch(`/api/channels/${encodeURIComponent(channelId)}/revisions`, { credentials: "same-origin", @@ -224,12 +227,11 @@ export const reconcileDashboardPanelResourceRevisions = ( } catch { // The durable vector is compared again on the next hint, reconnect, or focus. } - } while (revisionFetchWasDirtied(state)); + } while (revisionFetchWasDirtied(scheduled)); })().finally(() => { - if (fetches.get(channelId) === state) fetches.delete(channelId); + scheduled.promise = null; }); - state.promise = request; - fetches.set(channelId, state); + scheduled.promise = request; return request; }; @@ -264,7 +266,11 @@ const scheduleRealtimeRefresh = (queryClient: QueryClient, queryKey: readonly un }, wait); }; -/** A realtime message is a wake-up; persisted resource revisions decide what to invalidate. */ +/** + * A realtime message is a wake-up; persisted resource revisions decide what to invalidate. + * `modul.*` messages target overlays only. Chat voting, votekick and BELABOX panels + * refresh from D1-backed resource revisions, so their intervals only run offline. + */ export const reconcileDashboardRealtimeMessage = ( queryClient: QueryClient, message: RealtimeMessage, diff --git a/src/dashboard/main.tsx b/src/dashboard/main.tsx index 74c543fe..81cd72cd 100644 --- a/src/dashboard/main.tsx +++ b/src/dashboard/main.tsx @@ -1379,15 +1379,6 @@ export const DashboardApp = (): ReactElement => { }, [queryClient, reloadChannels, reloadModules, reloadOverview, selectedChannel?.modules, systemChannelId]); const refreshChannelStateRef = useRef(refreshChannelState); useLayoutEffect(() => { refreshChannelStateRef.current = refreshChannelState; }, [refreshChannelState]); - useEffect(() => { - const refresh = (): void => { - if (document.visibilityState === "visible") void refreshChannelStateRef.current().catch(() => undefined); - }; - document.addEventListener("visibilitychange", refresh); - return () => { - document.removeEventListener("visibilitychange", refresh); - }; - }, []); const routeUsesOverview = route.kind === "module" || (route.kind === "channel" && route.section === "overview"); // Keep the freshest token snapshot per channel. The channels query covers all diff --git a/src/modules/api_source/adapters/d1.ts b/src/modules/api_source/adapters/d1.ts index 11eba09b..fe03f343 100644 --- a/src/modules/api_source/adapters/d1.ts +++ b/src/modules/api_source/adapters/d1.ts @@ -50,17 +50,19 @@ export const readCachedApiPayload = async ( export const storeCachedApiPayload = async ( db: D1Database, + channelId: string, urlHash: string, payload: unknown, expiresAt: number, now: number, ): Promise => { await db.prepare( - `INSERT INTO api_source_cache (url_hash, payload_json, expires_at, updated_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(url_hash) DO UPDATE SET payload_json = excluded.payload_json, + `INSERT INTO api_source_cache (url_hash, channel_id, payload_json, expires_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(url_hash) DO UPDATE SET channel_id = excluded.channel_id, + payload_json = excluded.payload_json, expires_at = excluded.expires_at, updated_at = excluded.updated_at`, - ).bind(urlHash, JSON.stringify(payload), new Date(expiresAt).toISOString(), new Date(now).toISOString()).run(); + ).bind(urlHash, channelId, JSON.stringify(payload), new Date(expiresAt).toISOString(), new Date(now).toISOString()).run(); }; export const claimApiSourceQuota = async ( diff --git a/src/modules/api_source/adapters/fetch-json.ts b/src/modules/api_source/adapters/fetch-json.ts index a65e1fec..86a0b2fa 100644 --- a/src/modules/api_source/adapters/fetch-json.ts +++ b/src/modules/api_source/adapters/fetch-json.ts @@ -110,6 +110,6 @@ export const fetchCachedApiSourceJson = async ( // The outbound adapter deliberately has no way to set authorization, cookies, // arbitrary headers, a method, or a request body. const result = await requestJson(db, channelId, parsedUrl.href, ownOrigin, now, externalFetchBudget, fetcher); - if (result.expiresAt !== null) await storeCachedApiPayload(db, urlHash, result.payload, result.expiresAt, now); + if (result.expiresAt !== null) await storeCachedApiPayload(db, channelId, urlHash, result.payload, result.expiresAt, now); return result.payload; }; diff --git a/src/worker/app-routes.ts b/src/worker/app-routes.ts new file mode 100644 index 00000000..badddece --- /dev/null +++ b/src/worker/app-routes.ts @@ -0,0 +1,40 @@ +import { Hono } from "hono"; + +import { authRouter } from "./auth/routes"; +import { platformRouter } from "./platform/routes"; +import { eventSubRouter } from "./eventsub"; +import { panelRouter } from "./panel/routes"; +import { realtimeRouter } from "./realtime"; +import { notifyCommittedResources, PANEL_RESOURCE_NOTIFIER_COVERAGE } from "./panel-resources"; +import { serverTimingMiddleware } from "./server-timing"; + +export const app = new Hono<{ Bindings: Env }>(); +const mutatingMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const authorizedContextHas = (context: { var: object }, key: string): boolean => + Reflect.get(context.var, key) !== undefined; + +app.use("/api/*", serverTimingMiddleware); +app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes, async (context, next) => { + try { + await next(); + } finally { + const channelId = context.req.param("channelId"); + if (channelId.length > 0 && authorizedContextHas(context, "session")) { + await notifyCommittedResources(context.env, channelId); + } + } +}); +app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.platformRoutes, async (context, next) => { + try { + await next(); + } finally { + if (mutatingMethods.has(context.req.method.toUpperCase()) && authorizedContextHas(context, "actor")) { + await notifyCommittedResources(context.env); + } + } +}); +app.route("/", authRouter); +app.route("/", platformRouter); +app.route("/", panelRouter); +app.route("/", eventSubRouter); +app.route("/", realtimeRouter); diff --git a/src/worker/auth/routes.ts b/src/worker/auth/routes.ts index 7d26b4a4..103f0566 100644 --- a/src/worker/auth/routes.ts +++ b/src/worker/auth/routes.ts @@ -565,6 +565,7 @@ authRouter.get("/auth/twitch/callback", async (context) => { createdAt: current?.createdAt ?? now, updatedAt: now, }, "connected", null, now); + await notifyCommittedResources(context.env); const maintenance = maintainAfterBotAuthorization(context.env, now); try { context.executionCtx.waitUntil(maintenance); @@ -633,6 +634,7 @@ authRouter.get("/auth/twitch/callback", async (context) => { createdAt: current?.createdAt ?? now, updatedAt: now, }); + await notifyCommittedResources(context.env); const sessionExpiresAt = new Date(Date.parse(now) + SESSION_COOKIE_MAX_AGE_SECONDS * 1000).toISOString(); const sessionId = randomId(); @@ -684,7 +686,7 @@ authRouter.post("/auth/logout", async (context) => { } const now = nowIso(); await revokeSession(context.env.DB, session.sessionId, now, "logout"); - void revokeRealtimeSessionForUser(context.env.DB, context.env.CHANNEL, session.userId, session.sessionId); + await revokeRealtimeSessionForUser(context.env.DB, context.env.CHANNEL, session.userId, session.sessionId); context.header("Set-Cookie", clearSessionCookie()); context.header("Set-Cookie", serializeCsrfCookie("", 0), { append: true }); return context.body(null, 204); diff --git a/src/worker/config.ts b/src/worker/config.ts index 0e3bfb68..586bca21 100644 --- a/src/worker/config.ts +++ b/src/worker/config.ts @@ -19,8 +19,8 @@ export const REQUIRED_SECRET_NAMES = [ // update it and /healthz reports a freshly set-up database as broken (503) // even though everything is fine. `tests/unit/schema-baseline.test.ts` // therefore pins it to the last file in `migrations/`. -export const LATEST_SCHEMA_MIGRATION = "0047_panel_resource_revisions.sql"; -export const LATEST_SCHEMA_TABLE = "panel_resource_revisions"; +export const LATEST_SCHEMA_MIGRATION = "0048_committed_panel_resource_delivery.sql"; +export const LATEST_SCHEMA_TABLE = "panel_resource_pending"; const REQUIRED_BINDING_NAMES = ["DB", "CHANNEL", "ASSETS", "CF_VERSION_METADATA"] as const; diff --git a/src/worker/durable/ChannelObject.ts b/src/worker/durable/ChannelObject.ts index a2a5110c..94cf3261 100644 --- a/src/worker/durable/ChannelObject.ts +++ b/src/worker/durable/ChannelObject.ts @@ -974,7 +974,11 @@ export class ChannelObject extends DurableObject { } public async setTwitchRateLimitRetryAfter(retryAfter: number): Promise { - await this.ctx.storage.put(TWITCH_RETRY_AFTER_KEY, retryAfter); + await this.ctx.storage.transaction(async (transaction) => { + await transaction.put(TWITCH_RETRY_AFTER_KEY, retryAfter); + await bumpDurablePanelResources(transaction, ["module:ads:schedule"]); + }); + await this.notifyCommittedPanelResources(); } private async hasAdCountdownOverlay(channelId: string): Promise { @@ -2074,6 +2078,29 @@ export class ChannelObject extends DurableObject { ).bind(channelId, tokenId, nowIso).first(); } + private async panelPrincipalIsCurrent( + principal: Extract, + channelId: string, + now: number, + ): Promise { + if (isExpired(principal, now)) return false; + const row = await this.env.DB.prepare( + `SELECT session.session_id, session.user_id, member.role + FROM auth_sessions AS session + JOIN twitch_login_identity AS identity ON identity.user_id = session.user_id + JOIN channel_members AS member + ON member.channel_id = ? AND member.user_id = session.user_id + WHERE session.session_id = ? + AND session.user_id = ? + AND member.role = ? + AND session.revoked_at IS NULL + AND session.expires_at > ? + AND identity.status <> 'revoked'`, + ).bind(channelId, principal.sessionId, principal.userId, principal.role, new Date(now).toISOString()) + .first(); + return row?.session_id === principal.sessionId && row.user_id === principal.userId && row.role === principal.role; + } + private async allowOverlayHandshake(tokenId: string, now: number): Promise { const key = `overlay_handshakes:${tokenId}`; return this.ctx.storage.transaction(async (transaction) => { @@ -2253,6 +2280,19 @@ export class ChannelObject extends DurableObject { closeSocket(pair[1], OVERLAY_ACCESS_BOUND_CLOSE_CODE, OVERLAY_ACCESS_BOUND_CLOSE_REASON); return new Response("Overlay token is now bound.", { status: 403 }); } + if (principal.kind === "panel") { + try { + // Outer route authorization may have gone stale while the request was + // queued for this object. This D1 check is the final await before the + // synchronous accept, so revocation prevents even the hello message. + if (!await this.panelPrincipalIsCurrent(principal, ownChannelId, Date.now())) { + return new Response("Panel authorization revoked.", { status: 403 }); + } + } catch (error: unknown) { + console.error("Realtime panel handshake authorization could not be checked.", error); + return new Response(null, { status: 503, headers: { "Retry-After": "1" } }); + } + } this.ctx.acceptWebSocket(pair[1], tagsFor(principal)); pair[1].serializeAttachment(principal); await this.scheduleSecurityAlarm(); @@ -2279,7 +2319,7 @@ export class ChannelObject extends DurableObject { } /** Distributes only within its own channel and only to principals of the requested kind. */ - public publish( + public async publish( messages: readonly RealtimeMessage[], ): Promise { if (messages.length === 0) return Promise.resolve(); @@ -2325,6 +2365,52 @@ export class ChannelObject extends DurableObject { ? [...new Set((envelope.overlayIds ?? []).flatMap((overlayId) => overlaySocketsById.get(overlayId) ?? []))] : ordinarySockets, })); + const panelRecipients = [...new Set(serialized.flatMap(({ sockets }) => sockets) + .filter((webSocket) => readAttachment(webSocket)?.kind === "panel"))]; + const authorizedPanels = new Set(); + const unavailablePanels = new Set(); + const panelSessions = new Map(); + for (const webSocket of panelRecipients) { + const principal = readAttachment(webSocket); + if (principal?.kind !== "panel") continue; + const sockets = panelSessions.get(principal.sessionId) ?? []; + sockets.push(webSocket); + panelSessions.set(principal.sessionId, sockets); + } + for (const sessionIds of chunksOf([...panelSessions.keys()], D1_IDS_PER_QUERY)) { + const placeholders = sessionIds.map(() => "?").join(", "); + try { + const result = await this.env.DB.prepare( + `SELECT session.session_id, session.user_id, member.role + FROM auth_sessions AS session + JOIN twitch_login_identity AS identity ON identity.user_id = session.user_id + JOIN channel_members AS member + ON member.channel_id = ? AND member.user_id = session.user_id + WHERE session.session_id IN (${placeholders}) + AND session.revoked_at IS NULL + AND session.expires_at > ? + AND identity.status <> 'revoked'`, + ).bind(ownChannelId, ...sessionIds, new Date(now).toISOString()).all(); + const validSessions = new Set(result.results.map((row) => + `${row.session_id}:${row.user_id}:${String(row.role)}`)); + for (const sessionId of sessionIds) { + for (const webSocket of panelSessions.get(sessionId) ?? []) { + const principal = readAttachment(webSocket); + if (principal?.kind === "panel" && validSessions.has( + `${principal.sessionId}:${principal.userId}:${principal.role}`, + )) authorizedPanels.add(webSocket); + } + } + } catch (error: unknown) { + console.error("Realtime panel authorization check failed.", error); + for (const sessionId of sessionIds) { + for (const webSocket of panelSessions.get(sessionId) ?? []) { + unavailablePanels.add(webSocket); + closeSocket(webSocket, SOCKET_TRANSIENT_CODE, "authorization check unavailable"); + } + } + } + } for (const message of serialized) { const envelope = message.envelope; for (const webSocket of message.sockets) { @@ -2337,6 +2423,11 @@ export class ChannelObject extends DurableObject { closeSocket(webSocket, SOCKET_EXPIRED_CODE, "authorization expired"); continue; } + if (principal.kind === "panel" && unavailablePanels.has(webSocket)) continue; + if (principal.kind === "panel" && !authorizedPanels.has(webSocket)) { + closeSocket(webSocket, SOCKET_REVOKED_CODE, "authorization revoked"); + continue; + } if (envelope.type === "variables.changed" && principal.kind === "overlay" && principal.overlayId === null && this.recentlyBoundOverlayTokenIds.has(principal.tokenId)) { closeSocket(webSocket, OVERLAY_ACCESS_BOUND_CLOSE_CODE, OVERLAY_ACCESS_BOUND_CLOSE_REASON); diff --git a/src/worker/eventsub.ts b/src/worker/eventsub.ts index 49efffc5..1da0549a 100644 --- a/src/worker/eventsub.ts +++ b/src/worker/eventsub.ts @@ -7,6 +7,7 @@ import { dispatchEventSubNotification } from "./dispatch"; import { refreshAdPrewarning, isAdPrewarningTrigger } from "./ad-prewarning"; import { eventSubDefinitionForCondition } from "./eventsub-subscriptions"; import { notifyCommittedResources } from "./panel-resources"; +import { revokeRealtimeUserFromAllChannels } from "./realtime"; import type { EventSubSubscriptionType } from "../contracts/values"; import { hasEventSubMessage, @@ -294,9 +295,16 @@ eventSubRouter.post("/api/twitch/eventsub", async (context) => { authorizationConfirmedRevoked, ); if (!isNew) return response(null, 204); + if (authorizationConfirmedRevoked && revocation.authorizationIdentity?.kind === "login") { + await revokeRealtimeUserFromAllChannels( + context.env.DB, + context.env.CHANNEL, + revocation.authorizationIdentity.userId, + ); + } return response(null, 204); } finally { - await notifyCommittedResources(context.env, revocation.channelId); + await notifyCommittedResources(context.env, notifierChannelId); } } diff --git a/src/worker/index.ts b/src/worker/index.ts index 908c3b77..8d34738e 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,160 +1,10 @@ -import { Hono } from "hono"; - -import { authRouter } from "./auth/routes"; -import { platformRouter } from "./platform/routes"; -import { getHealthStatus } from "./config"; -import { eventSubRouter } from "./eventsub"; -import { panelRouter } from "./panel/routes"; -import { realtimeRouter } from "./realtime"; +import { app, workerFetch } from "./worker-app"; import { scheduled } from "./scheduled"; -import { serverTimingMiddleware } from "./server-timing"; -import { notifyCommittedResources, PANEL_RESOURCE_NOTIFIER_COVERAGE } from "./panel-resources"; export { ChannelObject } from "./durable/ChannelObject"; - -const app = new Hono<{ Bindings: Env }>(); -const mutatingMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); - -const overlayContentSecurityPolicy = (publicOrigin: string): string => { - const publicUrl = new URL(publicOrigin); - const websocketScheme = publicUrl.protocol === "https:" ? "wss:" : "ws:"; - const websocketOrigin = `${websocketScheme}//${publicUrl.host}`; - return [ - "default-src 'none'", - "base-uri 'none'", - "form-action 'none'", - `script-src ${publicUrl.origin}`, - `style-src ${publicUrl.origin} 'unsafe-inline'`, - `img-src ${publicUrl.origin}`, - `font-src ${publicUrl.origin}`, - `connect-src ${publicUrl.origin} ${websocketOrigin}`, - ].join("; "); -}; - -const addOverlayContentSecurityPolicy = (response: Response, publicOrigin: string): Response => { - const headers = new Headers(response.headers); - headers.set("Content-Security-Policy", overlayContentSecurityPolicy(publicOrigin)); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -}; - -const overlayDocument = async ( - request: Request, - assets: Env["ASSETS"], - publicOrigin: string, -): Promise => { - const response = await assets.fetch(request); - return addOverlayContentSecurityPolicy(response, publicOrigin); -}; - -const isTokenAuthenticatedOverlayApi = (pathname: string): boolean => - pathname === "/api/overlay/bootstrap" || /^\/api\/overlay\/variables\/[^/]+$/u.test(pathname); - -const addWildcardCors = (response: Response, extraHeaders?: HeadersInit): Response => { - const headers = new Headers(response.headers); - headers.set("Access-Control-Allow-Origin", "*"); - if (extraHeaders !== undefined) { - new Headers(extraHeaders).forEach((value, name) => { - headers.set(name, value); - }); - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -}; - -const overlayApiPreflight = (): Response => addWildcardCors( - new Response(null, { status: 204 }), - { - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Authorization", - "Access-Control-Max-Age": "600", - }, -); - -app.use("/api/*", serverTimingMiddleware); -app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes, async (context, next) => { - try { - await next(); - } finally { - const channelId = context.req.param("channelId"); - if (channelId.length > 0) { - await notifyCommittedResources(context.env, channelId); - } - } -}); -app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.platformRoutes, async (context, next) => { - try { - await next(); - } finally { - if (mutatingMethods.has(context.req.method.toUpperCase())) { - await notifyCommittedResources(context.env); - } - } -}); -app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.authRoutes, async (context, next) => { - try { - await next(); - } finally { - await notifyCommittedResources(context.env); - } -}); -app.route("/", authRouter); -app.route("/", platformRouter); -app.route("/", panelRouter); -app.route("/", eventSubRouter); -app.route("/", realtimeRouter); - -app.get("/healthz", async (context) => { - const health = await getHealthStatus(context.env); - return context.json( - { - status: health.status, - missingBindings: health.missingBindings, - }, - health.statusCode, - ); -}); - -// Events reach modules via the EventSub inbound handler and `dispatch.ts`. -// Module routes are mounted channel-scoped from the registry in -// `panel/module-routes.ts` under `/api/channels/:channelId/modules/`. - -/** - * An unmatched API path must not fall through to the assets. The asset handler - * runs with `not_found_handling: single-page-application`, so it answers with - * `index.html` and status 200 -- the caller then parses HTML as JSON and sees - * "Unexpected token '<'" instead of a missing route. Every renamed or mistyped - * endpoint turns into that riddle. - */ -app.all("/api/*", (context) => context.json({ error: "unknown_api_route" }, 404)); - -app.get("/overlay", (context) => overlayDocument( - context.req.raw, - context.env.ASSETS, - context.env.PUBLIC_ORIGIN, -)); -app.get("/overlay.html", (context) => overlayDocument( - context.req.raw, - context.env.ASSETS, - context.env.PUBLIC_ORIGIN, -)); - -app.all("*", (context) => context.env.ASSETS.fetch(context.req.raw)); +export { app }; export default { - fetch: async (request, env, executionContext) => { - const pathname = new URL(request.url).pathname; - const overlayApi = isTokenAuthenticatedOverlayApi(pathname); - if (overlayApi && request.method === "OPTIONS") return overlayApiPreflight(); - - const response = await app.fetch(request, env, executionContext); - return overlayApi ? addWildcardCors(response) : response; - }, + fetch: workerFetch, scheduled, } satisfies ExportedHandler; diff --git a/src/worker/login-maintenance.ts b/src/worker/login-maintenance.ts index 2c29dcd2..57617687 100644 --- a/src/worker/login-maintenance.ts +++ b/src/worker/login-maintenance.ts @@ -16,6 +16,8 @@ import { validateBotToken, logMaintenanceError, } from "./bot-maintenance"; +import { revokeRealtimeUserFromAllChannels } from "./realtime"; +import { notifyCommittedResources } from "./panel-resources"; const markLoginRevoked = async ( env: Env, @@ -23,7 +25,7 @@ const markLoginRevoked = async ( now: string, reason: string, ): Promise => { - await revokeLoginIdentityAndSessionsForUser( + const revoked = await revokeLoginIdentityAndSessionsForUser( env.DB, identity.userId, identity.accessTokenCiphertext, @@ -31,6 +33,10 @@ const markLoginRevoked = async ( reason, now, ); + if (revoked) { + await revokeRealtimeUserFromAllChannels(env.DB, env.CHANNEL, identity.userId); + await notifyCommittedResources(env); + } }; const rotateLoginTokensWithRetry = async ( diff --git a/src/worker/panel-resources.ts b/src/worker/panel-resources.ts index 418fd683..c43e320c 100644 --- a/src/worker/panel-resources.ts +++ b/src/worker/panel-resources.ts @@ -13,7 +13,31 @@ export const PANEL_RESOURCE_NOTIFIER_COVERAGE = { channelRoutes: "/api/channels/:channelId/*", moduleRoutes: "channelRoutes", platformRoutes: "/api/platform/*", - authRoutes: "/auth/*", + explicitlyCommittedRoutes: [ + "GET /auth/twitch/callback", + "GET /api/overlay/bootstrap", + "GET /api/overlay/variables/:name", + ], + nonPanelGetRoutes: [ + "/healthz", + "/overlay", + "/overlay.html", + "/api/channels", + "/api/csrf", + "/api/platform", + "/api/platform/users", + "/api/platform/channels", + "/api/platform/channels/:channelId/members", + "/api/platform/audit", + "/auth/login", + "/auth/channels/:channelId/channel-bot", + "/auth/channels/:channelId/broadcaster-scopes/:moduleId", + "/auth/bot/login", + "/ws/channels/:channelId", + "/ws/overlay", + ], + nonPanelWriteRoutes: ["POST /auth/logout"], + nonPanelAllRoutes: ["ALL /api/*", "ALL /*"], eventSubRoute: "/api/twitch/eventsub", eventSubHandler: "eventSubRouter notification/revocation finally", channelAlarm: "ChannelObject.alarm.finally", @@ -26,35 +50,31 @@ const reconcileChannelResources = async ( ): Promise => { const revisions = await readPanelResourceRevisions(env.DB, channelId); await env.CHANNEL.get(env.CHANNEL.idFromName(channelId)).reconcilePanelResources(revisions); + await clearDeliveredPanelResources(env.DB, channelId, revisions); return revisions; }; -const publishPendingSharedResourceChanges = async ( - env: Pick, - alreadyReconciled = new Map(), +const clearDeliveredPanelResources = async ( + database: D1Database, + channelId: string, + revisions: PanelResourceRevisionVector, ): Promise => { - const pending = await env.DB.prepare( - `SELECT global.resource, global.revision - FROM panel_global_resource_revisions AS global - LEFT JOIN panel_global_resource_publications AS published USING (resource) - WHERE global.resource IN ('channels', 'weather.cache', 'api.cache') - AND global.revision > COALESCE(published.revision, 0)`, - ).all<{ resource: string; revision: number }>(); - if (pending.results.length === 0) return; - const channels = await env.DB.prepare("SELECT channel_id FROM channels").all<{ channel_id: string }>(); - await Promise.all(channels.results - .filter(({ channel_id }) => { - const vector = alreadyReconciled.get(channel_id); - return vector === undefined || pending.results.some(({ resource, revision }) => (vector[resource] ?? 0) < revision); - }) - .map(({ channel_id }) => reconcileChannelResources(env, channel_id))); - await env.DB.batch(pending.results.map(({ resource, revision }) => env.DB.prepare( - `INSERT INTO panel_global_resource_publications (resource, revision) VALUES (?, ?) - ON CONFLICT(resource) DO UPDATE SET revision = MAX(revision, excluded.revision)`, - ).bind(resource, revision))); + const entries = Object.entries(revisions); + if (entries.length === 0) return; + await database.batch(entries.map(([resource, revision]) => database.prepare( + `DELETE FROM panel_resource_pending + WHERE channel_id = ? AND resource = ? AND revision <= ?`, + ).bind(channelId, resource, revision))); +}; + +const pendingChannelIds = async (database: D1Database): Promise => { + const result = await database.prepare( + "SELECT DISTINCT channel_id FROM panel_resource_pending ORDER BY channel_id", + ).all<{ channel_id: string }>(); + return result.results.map(({ channel_id }) => channel_id); }; -/** Reads the durable D1 vector, including cross-channel query resources. */ +/** Reads the durable D1 vector for one channel. */ export const readPanelResourceRevisions = async ( database: D1Database, channelId: string, @@ -62,10 +82,7 @@ export const readPanelResourceRevisions = async ( const result = await database.prepare( `SELECT resource, revision FROM panel_resource_revisions - WHERE channel_id = ? - UNION ALL - SELECT resource, revision - FROM panel_global_resource_revisions`, + WHERE channel_id = ?`, ).bind(channelId).all(); return Object.fromEntries(result.results.map(({ resource, revision }) => [resource, revision])); }; @@ -89,12 +106,8 @@ export const readCompletePanelResourceRevisions = async ( /** * D1 triggers and Durable Object transactions each persist revisions beside * their own committed data. The panel vector combines both counter domains. - */ -/** - * The one panel resource notifier. D1 triggers advance revisions in the same - * commit as D1 writes; `resources` is for DO-only commits. Publication is a - * best-effort wake-up because the durable vector repairs a lost hint on the - * next hint, reconnect, or focus comparison. + * D1's pending table is the commit-coupled channel queue, so a request only + * visits channels with committed, unpublished changes. */ export const notifyCommittedResources = async ( env: Pick, @@ -103,36 +116,28 @@ export const notifyCommittedResources = async ( ): Promise => { if (channelId === undefined) { try { - const channels = await env.DB.prepare("SELECT channel_id FROM channels").all<{ channel_id: string }>(); - const reconciled = new Map(); - await Promise.all(channels.results.map(async ({ channel_id }) => { - reconciled.set(channel_id, await reconcileChannelResources(env, channel_id)); - })); - await publishPendingSharedResourceChanges(env, reconciled); + await Promise.all((await pendingChannelIds(env.DB)).map((pendingChannelId) => + reconcileChannelResources(env, pendingChannelId))); } catch (error: unknown) { - console.warn("Cross-channel panel resources could not be published.", error); + console.warn("Committed panel resources could not be published.", error); } return; } - const reconciled = new Map(); try { - const exists = await env.DB.prepare("SELECT 1 AS present FROM channels WHERE channel_id = ?") - .bind(channelId).first<{ present: number }>(); - if (exists !== null) { - const revisions = reconcileLocal === undefined - ? await reconcileChannelResources(env, channelId) - : await readPanelResourceRevisions(env.DB, channelId); - if (reconcileLocal !== undefined) await reconcileLocal(revisions); - reconciled.set(channelId, revisions); + const pending = await env.DB.prepare( + "SELECT 1 AS present FROM panel_resource_pending WHERE channel_id = ? LIMIT 1", + ).bind(channelId).first<{ present: number }>(); + if (pending === null && reconcileLocal === undefined) return; + const revisions = await readPanelResourceRevisions(env.DB, channelId); + if (reconcileLocal === undefined) { + await env.CHANNEL.get(env.CHANNEL.idFromName(channelId)).reconcilePanelResources(revisions); + } else { + await reconcileLocal(revisions); } + await clearDeliveredPanelResources(env.DB, channelId, revisions); } catch (error: unknown) { console.warn("Committed panel resources could not be published.", error); } - try { - await publishPendingSharedResourceChanges(env, reconciled); - } catch (error: unknown) { - console.warn("Shared panel resources could not be published.", error); - } }; export const panelResourceChangedMessage = ( diff --git a/src/worker/panel/member-routes.ts b/src/worker/panel/member-routes.ts index d4e0cd46..50a5c6eb 100644 --- a/src/worker/panel/member-routes.ts +++ b/src/worker/panel/member-routes.ts @@ -235,7 +235,7 @@ memberRouter.patch("/api/channels/:channelId/members/:userId", async (context) = actorGuard(requiredActorRoles(member.role, existing.role)), ); if (!changed) return context.json({ error: "member_changed_concurrently" }, 409); - void revokeRealtimeUser(context.env.CHANNEL, channelId, userId); + await revokeRealtimeUser(context.env.CHANNEL, channelId, userId); return context.json({ member: memberResponse(member) }); }); @@ -262,6 +262,6 @@ memberRouter.delete("/api/channels/:channelId/members/:userId", async (context) actorGuard(requiredActorRoles(undefined, existing.role)), ); if (!changed) return context.json({ error: "member_changed_concurrently" }, 409); - void revokeRealtimeUser(context.env.CHANNEL, channelId, userId); + await revokeRealtimeUser(context.env.CHANNEL, channelId, userId); return context.body(null, 204); }); diff --git a/src/worker/platform/routes.ts b/src/worker/platform/routes.ts index 91ad9cb5..3ca7af20 100644 --- a/src/worker/platform/routes.ts +++ b/src/worker/platform/routes.ts @@ -27,6 +27,7 @@ import { } from "./repository"; import { maintainEventSubSubscriptions } from "../eventsub-subscriptions"; import { CHANNEL_ROLES, PLATFORM_ASSIGNABLE_ROLES, type ChannelRole } from "../../contracts/values"; +import { revokeRealtimeUser } from "../realtime"; interface PlatformEnvironment { Bindings: Env; @@ -276,6 +277,7 @@ platformRouter.patch("/api/platform/channels/:channelId/members/:userId", async timestamp, ); if (!changed) return mutationFailed(context); + await revokeRealtimeUser(context.env.CHANNEL, channelId, userId); return context.json({ member: memberResponse({ ...existing, role, updatedAt: timestamp }), }); @@ -295,6 +297,7 @@ platformRouter.delete("/api/platform/channels/:channelId/members/:userId", async nowIso(), ); if (!removed) return mutationFailed(context); + await revokeRealtimeUser(context.env.CHANNEL, channelId, userId); return context.body(null, 204); }); diff --git a/src/worker/realtime.ts b/src/worker/realtime.ts index 586e80ca..6f496411 100644 --- a/src/worker/realtime.ts +++ b/src/worker/realtime.ts @@ -147,6 +147,18 @@ const channelObject = (namespace: Env["CHANNEL"] | undefined, channelId: string) return namespace.get(namespace.idFromName(channelId)); }; +const requiredChannelObject = (namespace: Env["CHANNEL"] | undefined, channelId: string) => { + const object = channelObject(namespace, channelId); + if (object === null) throw new Error("Channel namespace is required to revoke realtime access."); + return object; +}; + +const awaitAllRevocations = async (revocations: readonly Promise[]): Promise => { + const results = await Promise.allSettled(revocations); + const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (failure !== undefined) throw failure.reason; +}; + export const publishRealtimeMessages = async ( namespace: Env["CHANNEL"] | undefined, messages: readonly RealtimeMessage[], @@ -281,12 +293,17 @@ export const revokeRealtimeUser = async ( channelId: string, userId: string, ): Promise => { - try { - const object = channelObject(namespace, channelId); - if (object !== null) await object.revokeUser(userId); - } catch (error: unknown) { - console.warn("Realtime revocation for user failed.", error); - } + await requiredChannelObject(namespace, channelId).revokeUser(userId); +}; + +export const revokeRealtimeUserFromAllChannels = async ( + db: D1Database, + namespace: Env["CHANNEL"] | undefined, + userId: string, +): Promise => { + if (namespace === undefined) throw new Error("Channel namespace is required to revoke realtime access."); + const channelIds = await listChannelIdsForUser(db, userId); + await awaitAllRevocations(channelIds.map((channelId) => revokeRealtimeUser(namespace, channelId, userId))); }; export const revokeRealtimeToken = async ( @@ -325,14 +342,8 @@ export const revokeRealtimeSessionForUser = async ( userId: string, sessionId: string, ): Promise => { - try { - if (namespace === undefined) return; - const channelIds = await listChannelIdsForUser(db, userId); - await Promise.all(channelIds.map(async (channelId) => { - const object = channelObject(namespace, channelId); - if (object !== null) await object.revokeSession(sessionId); - })); - } catch (error: unknown) { - console.warn("Realtime revocation for session failed.", error); - } + if (namespace === undefined) throw new Error("Channel namespace is required to revoke realtime access."); + const channelIds = await listChannelIdsForUser(db, userId); + await awaitAllRevocations(channelIds.map((channelId) => + requiredChannelObject(namespace, channelId).revokeSession(sessionId))); }; diff --git a/src/worker/worker-app.ts b/src/worker/worker-app.ts new file mode 100644 index 00000000..686cc7de --- /dev/null +++ b/src/worker/worker-app.ts @@ -0,0 +1,116 @@ +import { getHealthStatus } from "./config"; +import { app } from "./app-routes"; + +const overlayContentSecurityPolicy = (publicOrigin: string): string => { + const publicUrl = new URL(publicOrigin); + const websocketScheme = publicUrl.protocol === "https:" ? "wss:" : "ws:"; + const websocketOrigin = `${websocketScheme}//${publicUrl.host}`; + return [ + "default-src 'none'", + "base-uri 'none'", + "form-action 'none'", + `script-src ${publicUrl.origin}`, + `style-src ${publicUrl.origin} 'unsafe-inline'`, + `img-src ${publicUrl.origin}`, + `font-src ${publicUrl.origin}`, + `connect-src ${publicUrl.origin} ${websocketOrigin}`, + ].join("; "); +}; + +const addOverlayContentSecurityPolicy = (response: Response, publicOrigin: string): Response => { + const headers = new Headers(response.headers); + headers.set("Content-Security-Policy", overlayContentSecurityPolicy(publicOrigin)); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +const overlayDocument = async ( + request: Request, + assets: Env["ASSETS"], + publicOrigin: string, +): Promise => { + const response = await assets.fetch(request); + return addOverlayContentSecurityPolicy(response, publicOrigin); +}; + +const isTokenAuthenticatedOverlayApi = (pathname: string): boolean => + pathname === "/api/overlay/bootstrap" || /^\/api\/overlay\/variables\/[^/]+$/u.test(pathname); + +const addWildcardCors = (response: Response, extraHeaders?: HeadersInit): Response => { + const headers = new Headers(response.headers); + headers.set("Access-Control-Allow-Origin", "*"); + if (extraHeaders !== undefined) { + new Headers(extraHeaders).forEach((value, name) => { + headers.set(name, value); + }); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +const overlayApiPreflight = (): Response => addWildcardCors( + new Response(null, { status: 204 }), + { + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Authorization", + "Access-Control-Max-Age": "600", + }, +); + +app.get("/healthz", async (context) => { + const health = await getHealthStatus(context.env); + return context.json( + { + status: health.status, + missingBindings: health.missingBindings, + }, + health.statusCode, + ); +}); + +// Events reach modules via the EventSub inbound handler and `dispatch.ts`. +// Module routes are mounted channel-scoped from the registry in +// `panel/module-routes.ts` under `/api/channels/:channelId/modules/`. + +/** + * An unmatched API path must not fall through to the assets. The asset handler + * runs with `not_found_handling: single-page-application`, so it answers with + * `index.html` and status 200 -- the caller then parses HTML as JSON and sees + * "Unexpected token '<'" instead of a missing route. Every renamed or mistyped + * endpoint turns into that riddle. + */ +app.all("/api/*", (context) => context.json({ error: "unknown_api_route" }, 404)); + +app.get("/overlay", (context) => overlayDocument( + context.req.raw, + context.env.ASSETS, + context.env.PUBLIC_ORIGIN, +)); +app.get("/overlay.html", (context) => overlayDocument( + context.req.raw, + context.env.ASSETS, + context.env.PUBLIC_ORIGIN, +)); + +app.all("*", (context) => context.env.ASSETS.fetch(context.req.raw)); + +export { app }; + +export const workerFetch = async ( + request: Request, + env: Env, + executionContext: ExecutionContext, +): Promise => { + const pathname = new URL(request.url).pathname; + const overlayApi = isTokenAuthenticatedOverlayApi(pathname); + if (overlayApi && request.method === "OPTIONS") return overlayApiPreflight(); + + const response = await app.fetch(request, env, executionContext); + return overlayApi ? addWildcardCors(response) : response; +}; diff --git a/tests/components/events-page.test.tsx b/tests/components/events-page.test.tsx index fa7a898d..58587d8c 100644 --- a/tests/components/events-page.test.tsx +++ b/tests/components/events-page.test.tsx @@ -1123,7 +1123,7 @@ describe("EventsPage failure cause icon", () => { type: "event_log.new", payload: { entries: [{ eventId: newRaid.eventId, createdAt: newRaid.createdAt, moduleId: newRaid.moduleId, code: newRaid.code, actorUserId: null }] }, })); - await new Promise((resolve) => setTimeout(resolve, 300)); + await new Promise((resolve) => setTimeout(resolve, 500)); expect(fetcher).toHaveBeenCalledTimes(1); fetcher.mockClear(); fireEvent.click(screen.getByRole("button", { name: "Toggle filter" })); diff --git a/tests/e2e/host-stable-layout.spec.ts b/tests/e2e/host-stable-layout.spec.ts index 7054e3a5..4335e61e 100644 --- a/tests/e2e/host-stable-layout.spec.ts +++ b/tests/e2e/host-stable-layout.spec.ts @@ -958,7 +958,7 @@ test("stream manager waits for module data and keeps its immediate-action strip } }); -test("background channel refresh keeps populated overview cards through loading and failure", async ({ page }) => { +test("window visibility changes do not broadly refresh populated overview cards", async ({ page }) => { const secondChannel = { ...channel, channelId: "stable-host-layout-second", @@ -968,32 +968,21 @@ test("background channel refresh keeps populated overview cards through loading for (const width of [1280, 390]) { await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); await installChannelMocks(page, {}, { channelList: [channel, secondChannel] }); + let channelListRequests = 0; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/channels") channelListRequests += 1; + }); await page.goto("/"); const grid = page.locator(".module-grid"); await expect(grid.locator(":scope > .module-tile")).toHaveCount(2); const before = await measureDocumentBox(page, ".module-grid"); - - let releaseRefresh!: () => void; - let markRefreshStarted!: () => void; - const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; }); - const refreshStarted = new Promise((resolve) => { markRefreshStarted = resolve; }); - await page.route("**/api/channels", async (route) => { - markRefreshStarted(); - await refreshGate; - await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "channels_unavailable" }) }); - }); - + const initialChannelListRequests = channelListRequests; await page.evaluate(() => { document.dispatchEvent(new Event("visibilitychange")); }); - await refreshStarted; - await expect(page.locator(".ui-load-state")).toHaveAttribute("data-status", "success"); - await expect(grid.locator(":scope > .module-tile")).toHaveCount(2); - expect(await measureDocumentBox(page, ".module-grid")).toEqual(before); - - releaseRefresh(); - await expect(page.locator(".ui-toast--error")).toBeVisible(); + await page.waitForTimeout(300); await expect(page.locator(".ui-load-state")).toHaveAttribute("data-status", "success"); await expect(grid.locator(":scope > .module-tile")).toHaveCount(2); expect(await measureDocumentBox(page, ".module-grid")).toEqual(before); + expect(channelListRequests).toBe(initialChannelListRequests); await page.unrouteAll(); } }); diff --git a/tests/e2e/realtime-navigation.spec.ts b/tests/e2e/realtime-navigation.spec.ts index 2da875e1..1a6552a9 100644 --- a/tests/e2e/realtime-navigation.spec.ts +++ b/tests/e2e/realtime-navigation.spec.ts @@ -1,6 +1,6 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; -const channelId = "realtime-navigation"; +const channelId = "realtime-navigation-e2e"; const channel = { channelId, login: "realtime-navigation", @@ -9,170 +9,72 @@ const channel = { role: "manager", broadcasterConnection: "connected", channelBotConsent: "granted", - bot: { status: "connected", reason: null, updatedAt: "2026-10-09T08:00:00.000Z" }, - moderator: { isModerator: true, checkedAt: "2026-10-09T08:00:00.000Z", reason: null }, - chatSubscription: { status: "enabled", subscriptionId: "realtime-subscription", reason: null, updatedAt: "2026-10-09T08:00:00.000Z" }, + bot: { status: "connected", reason: null, updatedAt: "2026-10-10T00:00:00.000Z" }, + broadcasterPermissions: { missingScopes: [] }, + moderator: { isModerator: true, checkedAt: "2026-10-10T00:00:00.000Z", reason: null }, + chatSubscription: { status: "enabled", subscriptionId: "realtime-sub", reason: null, updatedAt: "2026-10-10T00:00:00.000Z" }, + chatSubscriptionNeeded: false, + modules: [], tokens: { - botExpiresAt: "2099-10-09T08:00:00.000Z", + botExpiresAt: "2099-09-20T08:00:00.000Z", loginStatus: "connected", loginReason: null, - loginExpiresAt: "2099-10-09T08:00:00.000Z", - }, - streamState: "offline", - streamStartedAt: null, - controls: { - mute: { active: false, until: null, mode: null }, - pause: { active: false, until: null, mode: null }, + loginExpiresAt: "2099-09-20T08:00:00.000Z", }, lastError: null, - modules: [ - { id: "text_commands", enabled: true, settings: "{}" }, - { id: "chat_voting", enabled: true, settings: "{}" }, - ], }; -const installSocketCounter = async (page: Page): Promise => { - await page.addInitScript(() => { - type TrackedSocket = EventTarget & { url: string; readyState: number }; - const browser = window as Window & { __realtimePanelAutoOpen?: boolean }; - browser.__realtimePanelAutoOpen = true; - const sockets: TrackedSocket[] = []; - class MockWebSocket extends EventTarget { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - readonly url: string; - readyState = MockWebSocket.CONNECTING; - - constructor(url: string | URL) { - super(); - this.url = String(url); - sockets.push(this); - queueMicrotask(() => { - if (browser.__realtimePanelAutoOpen === true && this.readyState === MockWebSocket.CONNECTING) { - this.readyState = MockWebSocket.OPEN; - this.dispatchEvent(new Event("open")); - } - }); - } +test.use({ locale: "en-US" }); - close(): void { - this.readyState = MockWebSocket.CLOSED; - this.dispatchEvent(new CloseEvent("close", { code: 1000 })); - } - - send(): void {} - } - Object.defineProperty(window, "__realtimePanelSockets", { value: sockets, configurable: true }); - Object.defineProperty(window, "WebSocket", { value: MockWebSocket, writable: true, configurable: true }); +test("keeps one channel socket open while dashboard navigation changes pages", async ({ page }) => { + let opened = 0; + let closed = 0; + page.on("websocket", (socket) => { + opened += 1; + socket.on("close", () => { closed += 1; }); + }); + await page.routeWebSocket(`**/ws/channels/${channelId}`, (socket) => { + socket.onMessage(() => undefined); }); -}; - -const installApiMocks = async (page: Page): Promise<{ chatVotingReads: () => number; revisionReads: () => number }> => { - let chatVotingReads = 0; - let revisionReads = 0; await page.route("**/api/**", async (route) => { const pathname = new URL(route.request().url()).pathname; if (pathname === "/api/channels") { - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ channels: [channel], bot: channel.bot }) }); - return; - } - if (pathname === `/api/channels/${channelId}/overview`) { - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...channel, activeModules: [ - { moduleId: "text_commands", settings: "{}" }, { moduleId: "chat_voting", settings: "{}" }, - ] }) }); - return; - } - if (pathname === `/api/channels/${channelId}/settings`) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ channels: [channel], viewerUserId: "viewer-e2e", bot: channel.bot }), + }); + } else if (pathname === `/api/channels/${channelId}/overview`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...channel, activeModules: [] }) }); + } else if (pathname === `/api/channels/${channelId}/modules`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ modules: [] }) }); + } else if (pathname === `/api/channels/${channelId}/settings`) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ timeZone: "UTC", revision: 1, location: null, locationRevision: 0 }) }); - return; - } - if (pathname === `/api/channels/${channelId}/modules`) { - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ modules: [ - { id: "text_commands", enabled: true, settings: "{}" }, { id: "chat_voting", enabled: true, settings: "{}" }, - ] }) }); - return; - } - if (pathname === `/api/channels/${channelId}/revisions`) { - revisionReads += 1; - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ revisions: {} }) }); - return; - } - if (pathname === `/api/channels/${channelId}/modules/chat_voting/current`) { - chatVotingReads += 1; - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ - vote: null, counts: null, revision: 0, terms: null, moreTerms: null, hasOpenBallot: false, - defaultDurationSeconds: 120, - defaultLabels: { yes_no: ["Yes", "No"], digit_01: ["No", "Yes"], digit_12: ["1", "2"], scale_5: ["1", "2", "3", "4", "5"], options_n: Array.from({ length: 9 }, (_unused, index) => `Option ${String(index + 1)}`), free_text: [] }, - }) }); - return; - } - if (pathname === `/api/channels/${channelId}/modules/text_commands/commands`) { - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ commands: [], variables: [] }) }); - return; - } - if (pathname === `/api/channels/${channelId}/template-variables`) { - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ variables: [] }) }); - return; - } - if (pathname === `/api/channels/${channelId}/events`) { + } else if (pathname === `/api/channels/${channelId}/events`) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ entries: [], nextCursor: null }) }); - return; - } - if (pathname === `/api/channels/${channelId}/variables`) { - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ variables: [], count: 0, maximum: 25 }) }); - return; + } else if (pathname === `/api/channels/${channelId}/members`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ members: [], broadcasterCount: 0, viewerUserId: "viewer-e2e", nextCursor: null }) }); + } else if (pathname === `/api/channels/${channelId}/revisions`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ revisions: {} }) }); + } else { + await route.fulfill({ status: 404, contentType: "application/json", body: "{}" }); } - await route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); }); - return { chatVotingReads: () => chatVotingReads, revisionReads: () => revisionReads }; -}; -const panelSocketPaths = async (page: Page): Promise => page.evaluate(() => { - const sockets = (window as Window & { __realtimePanelSockets?: Array<{ url: string }> }).__realtimePanelSockets ?? []; - return sockets - .map(({ url }) => new URL(url, window.location.href).pathname) - .filter((pathname) => pathname.startsWith("/ws/channels/")); -}); - -test("keeps one channel socket while navigating between dashboard views", async ({ page }) => { - await installSocketCounter(page); - const apiReads = await installApiMocks(page); await page.goto(`/channels/${channelId}`); await expect(page.getByRole("heading", { name: channel.displayName, level: 1 })).toBeVisible(); - await expect.poll(() => panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); - - await page.locator('a[data-nav-page-id="events"]').click(); - await expect(page).toHaveURL(`/channels/${channelId}/events`); - await expect(page.getByRole("heading", { name: "Events", level: 1 })).toBeVisible(); - expect(await panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); - - await page.locator('a[data-nav-page-id="variables"]').click(); - await expect(page).toHaveURL(`/channels/${channelId}/variables`); - await expect(page.getByRole("heading", { name: "Channel variables", level: 1 })).toBeVisible(); - expect(await panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); + await expect.poll(() => opened).toBe(1); - await page.locator(`a[href="/channels/${channelId}/modules/text_commands"]`).click(); - await expect(page).toHaveURL(`/channels/${channelId}/modules/text_commands`); - await expect(page.locator("h1")).toBeVisible(); - expect(await panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); + const sidebar = page.getByRole("navigation", { name: "Main navigation" }); + await sidebar.getByRole("button", { name: /Search or run action/u }).click(); + const spotlight = page.getByRole("dialog"); + await spotlight.getByRole("textbox").fill("Members"); + await spotlight.locator('[data-spotlight-item-id="page:members"]').click(); + await expect(page).toHaveURL(`/channels/${channelId}/members`); + await expect(page.getByRole("heading", { name: "Members", level: 1 })).toBeVisible(); - await page.locator(`a[href="/channels/${channelId}/modules/chat_voting"]`).click(); - await expect(page).toHaveURL(`/channels/${channelId}/modules/chat_voting`); - await expect(page.getByRole("heading", { name: "Chat voting", level: 1 })).toBeVisible(); - await expect.poll(() => apiReads.chatVotingReads()).toBeGreaterThan(0); - await expect.poll(() => apiReads.revisionReads()).toBe(1); - const connectedReads = apiReads.chatVotingReads(); - expect(connectedReads).toBeGreaterThan(0); - await page.waitForTimeout(2_200); - expect(apiReads.chatVotingReads()).toBe(connectedReads); - expect(await panelSocketPaths(page)).toEqual([`/ws/channels/${channelId}`]); - - await page.evaluate(() => { - (window as Window & { __realtimePanelAutoOpen?: boolean }).__realtimePanelAutoOpen = false; - const sockets = (window as Window & { __realtimePanelSockets?: Array<{ close: () => void }> }).__realtimePanelSockets ?? []; - sockets[0]?.close(); - }); - await expect.poll(() => apiReads.chatVotingReads(), { timeout: 5_000 }).toBeGreaterThan(connectedReads); + await page.goBack(); + await expect(page).toHaveURL(`/channels/${channelId}`); + await expect(page.getByRole("heading", { name: channel.displayName, level: 1 })).toBeVisible(); + await expect.poll(() => ({ opened, closed })).toEqual({ opened: 1, closed: 0 }); }); diff --git a/tests/unit/auth-routes.test.ts b/tests/unit/auth-routes.test.ts index 6641b674..a9f772e7 100644 --- a/tests/unit/auth-routes.test.ts +++ b/tests/unit/auth-routes.test.ts @@ -79,6 +79,10 @@ const makeEnvironment = ( statements.map((batchStatement) => batchStatement.run()), )), } as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => ({ revokeSession: () => Promise.resolve() }), + }, TWITCH_CLIENT_ID: "client-id", TWITCH_CLIENT_SECRET: "client-secret", TWITCH_BOT_LOGIN: "brobot", @@ -695,6 +699,46 @@ describe("auth routes", () => { expect(response.headers.get("set-cookie")).toContain("Max-Age=0"); }); + it("does not report logout complete when affected panel sockets cannot be closed", async () => { + const { environment, statement } = makeEnvironment({ + session_id: "session-1", + user_id: "user-1", + login: "tester", + expires_at: "2099-09-19T00:00:00.000Z", + created_at: "2099-09-18T00:00:00.000Z", + updated_at: "2099-09-18T00:00:00.000Z", + revoked_at: null, + revocation_reason: null, + }); + const revokeSession = vi.fn(() => Promise.reject(new Error("channel unavailable"))); + environment.CHANNEL = { + idFromName: (channelId: string) => channelId, + get: () => ({ revokeSession }), + } as unknown as Env["CHANNEL"]; + statement.all.mockResolvedValueOnce({ results: [{ channel_id: "channel-a" }] }); + const sessionCookie = await createSessionCookie( + { sessionId: "session-1" }, + environment.SESSION_COOKIE_KEYS, + environment.SESSION_ENCRYPTION_KEYS, + ); + const csrfToken = await createCsrfToken("session-1", environment.SESSION_COOKIE_KEYS, freshTimestamp()); + + const response = await authRouter.fetch( + new Request("https://brobot.example/auth/logout", { + method: "POST", + headers: { + Cookie: `__Host-brobot_session=${sessionCookie}; __Host-brobot_csrf=${csrfToken}`, + "X-CSRF-Token": csrfToken, + }, + }), + environment, + ); + + expect(response.status).toBe(500); + expect(revokeSession).toHaveBeenCalledWith("session-1"); + expect(statement.run).toHaveBeenCalled(); + }); + it.each([ ["nicht parsebarer", "kein-datum"], ["tatsächlich abgelaufener", "2000-01-01T00:00:00.000Z"], diff --git a/tests/unit/dashboard-query-data.test.tsx b/tests/unit/dashboard-query-data.test.tsx index 9446542a..8e227401 100644 --- a/tests/unit/dashboard-query-data.test.tsx +++ b/tests/unit/dashboard-query-data.test.tsx @@ -20,6 +20,7 @@ const channelId = "channel-a"; afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); setDashboardRealtimeStatus(channelId, "offline"); }); @@ -152,23 +153,31 @@ describe("dashboard query data layer", () => { const view = renderWithQuery(, {}, { gcTime: 600_000, staleTime: 600_000 }); expect(await screen.findByText("1")).toBeInTheDocument(); + vi.useFakeTimers(); let revision = 0; vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions: { "module:chat_voting:panel": revision } }), { status: 200, headers: { "Content-Type": "application/json" }, })))); await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); revision = 1; - await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + const firstHint = reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + await vi.advanceTimersByTimeAsync(1_000); + await firstHint; await refreshStarted; revision = 2; - await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + const secondHint = reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); revision = 3; - await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + const thirdHint = reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + await vi.advanceTimersByTimeAsync(1_000); + await Promise.all([secondHint, thirdHint]); expect(readCount).toBe(2); - releaseRefresh?.(); - await waitFor(() => expect(readCount).toBe(3), { timeout: 2_000 }); - await waitFor(() => expect(view.queryClient.getQueryData(moduleQueryKey(channelId, "chat_voting", "panel"))).toBe(3), { timeout: 2_000 }); + await act(async () => { + releaseRefresh?.(); + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(readCount).toBe(4); + expect(view.queryClient.getQueryData(moduleQueryKey(channelId, "chat_voting", "panel"))).toBe(4); view.unmount(); }); @@ -184,20 +193,24 @@ describe("dashboard query data layer", () => { const view = renderWithQuery(, {}, { gcTime: 600_000, staleTime: 600_000 }); expect(await screen.findByText("1")).toBeInTheDocument(); + vi.useFakeTimers(); let revision = 0; vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions: { "module:chat_voting:panel": revision } }), { status: 200, headers: { "Content-Type": "application/json" }, })))); await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + const reconciliations: Promise[] = []; for (let index = 0; index < 20; index += 1) { - await new Promise((resolve) => { setTimeout(resolve, 50); }); revision += 1; - await reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + reconciliations.push(reconcileDashboardPanelResourceRevisions(view.queryClient, channelId)); + await vi.advanceTimersByTimeAsync(50); } - await new Promise((resolve) => { setTimeout(resolve, 300); }); + await vi.advanceTimersByTimeAsync(1_000); + await Promise.all(reconciliations); + await act(async () => { await vi.advanceTimersByTimeAsync(1_000); }); - // One immediate refresh plus one trailing refresh for the sustained hint burst. + // The one-second query throttle bounds the sustained burst to one trailing refresh. expect(readCount).toBe(3); expect(view.queryClient.getQueryData(moduleQueryKey(channelId, "chat_voting", "panel"))).toBe(readCount); view.unmount(); diff --git a/tests/unit/dashboard-realtime.test.ts b/tests/unit/dashboard-realtime.test.ts index 2bf61fb1..04f1fc05 100644 --- a/tests/unit/dashboard-realtime.test.ts +++ b/tests/unit/dashboard-realtime.test.ts @@ -85,10 +85,11 @@ describe("dashboard realtime messages", () => { })))); await reconcileDashboardPanelResourceRevisions(queryClient, channelId); + const reconciliations: Promise[] = []; for (let index = 0; index < 20; index += 1) { revisions["channel.events"] = (revisions["channel.events"] ?? 0) + 1; revisions["module:chat_voting:data"] = (revisions["module:chat_voting:data"] ?? 0) + 1; - await reconcileDashboardRealtimeMessage(queryClient, moduleHint("event_log.new", { + reconciliations.push(reconcileDashboardRealtimeMessage(queryClient, moduleHint("event_log.new", { entries: [{ eventId: `event-${String(index)}`, createdAt: "2026-10-09T08:00:00.000Z", @@ -96,10 +97,11 @@ describe("dashboard realtime messages", () => { code: "chat_voting.vote_recorded", actorUserId: null, }], - })); + }))); await vi.advanceTimersByTimeAsync(50); } await vi.advanceTimersByTimeAsync(250); + await Promise.all(reconciliations); expect(reads.panel).toBeLessThanOrEqual(2); expect(reads.events).toBeLessThanOrEqual(2); @@ -235,6 +237,7 @@ describe("dashboard realtime messages", () => { }); it("rechecks the revision vector when another hint arrives during an in-flight read", async () => { + vi.useFakeTimers(); const revisionChannel = "revision-overlap"; const reader = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const panel = moduleQueryKey(revisionChannel, "chat_voting", "panel"); @@ -267,6 +270,7 @@ describe("dashboard realtime messages", () => { status: 200, headers: { "Content-Type": "application/json" }, })); + await vi.advanceTimersByTimeAsync(1_000); await Promise.all([first, second]); expect(revisionReads).toBe(2); await vi.waitFor(() => { expect(panelReads).toBe(1); }); @@ -274,13 +278,67 @@ describe("dashboard realtime messages", () => { reader.clear(); }); + it("throttles sequential hints beyond request latency and observes the last committed revision", async () => { + vi.useFakeTimers(); + const revisionChannel = "revision-sustained-hints"; + const reader = new QueryClient(); + const events = queryKeys.channel(revisionChannel, "events"); + addQuery(reader, events); + let committedRevision = 0; + const observedRevisions: number[] = []; + const requestStarts: number[] = []; + const fetchRevisions = vi.fn(() => { + requestStarts.push(Date.now()); + const revision = committedRevision; + observedRevisions.push(revision); + return new Promise((resolve) => { + setTimeout(() => { + resolve(new Response(JSON.stringify({ revisions: { "channel.events": revision } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + }, 5); + }); + }); + vi.stubGlobal("fetch", fetchRevisions); + const reconciliations: Promise[] = []; + for (let index = 0; index < 100; index += 1) { + committedRevision += 1; + reconciliations.push(reconcileDashboardPanelResourceRevisions(reader, revisionChannel)); + // Each hint arrives after the endpoint would have answered its predecessor. + await vi.advanceTimersByTimeAsync(10); + } + expect(fetchRevisions).toHaveBeenCalledTimes(5); + expect(requestStarts.slice(1).every((startedAt, index) => + startedAt - (requestStarts[index] ?? 0) >= 250)).toBe(true); + await vi.advanceTimersByTimeAsync(10); + await Promise.all(reconciliations); + expect(observedRevisions[0]).toBe(1); + expect(observedRevisions.at(-1)).toBe(100); + expect(reader.getQueryState(events)?.isInvalidated).toBe(true); + + // A new burst in the next window keeps exactly one trailing read after it ends. + committedRevision = 101; + const next = reconcileDashboardPanelResourceRevisions(reader, revisionChannel); + committedRevision = 102; + const trailing = reconcileDashboardPanelResourceRevisions(reader, revisionChannel); + await vi.advanceTimersByTimeAsync(240); + expect(fetchRevisions).toHaveBeenCalledTimes(6); + await vi.advanceTimersByTimeAsync(5); + await Promise.all([next, trailing]); + expect(observedRevisions.at(-1)).toBe(102); + await vi.advanceTimersByTimeAsync(5_000); + expect(fetchRevisions).toHaveBeenCalledTimes(6); + reader.clear(); + }); + it("refreshes overlapping resource mappings once per query", async () => { const revisionChannel = "revision-overlapping-resources"; const reader = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const affectedKeys = [ queryKeys.channel(revisionChannel, "overview"), dashboardDataKeys.overlays(revisionChannel), - moduleQueryKey(revisionChannel, "api_source", "sources"), + moduleQueryKey(revisionChannel, "text_library", "library"), ] as const; const refetches = affectedKeys.map(() => 0); const unsubscribers = affectedKeys.map((queryKey, index) => { @@ -307,7 +365,6 @@ describe("dashboard realtime messages", () => { "channel.overlays": 1, "channel.overlay-accesses": 1, "channel.library": 1, - "api.cache": 1, }; await reconcileDashboardPanelResourceRevisions(reader, revisionChannel); await vi.waitFor(() => { expect(refetches).toEqual([1, 1, 1]); }); @@ -330,8 +387,6 @@ describe("dashboard realtime messages", () => { ["channel.overlays", "overlays"], ["channel.overlay-accesses", "overlay-access-list"], ["channel.library", "library-catalog"], - ["weather.cache", "weather-library"], - ["api.cache", "api-source-cache"], ["module:chat_voting:panel", "chat-voting"], ["module:votekick:panel", "votekick"], ["module:belabox:live", "belabox"], @@ -366,10 +421,7 @@ describe("dashboard realtime messages", () => { moduleQueryKey(revisionChannel, "timers", "panel"), moduleQueryKey(revisionChannel, "faq", "panel"), moduleQueryKey(revisionChannel, "text_commands", "template-variables"), - moduleQueryKey(revisionChannel, "api_source", "sources"), ], - "weather-library": [moduleQueryKey(revisionChannel, "text_library", "library")], - "api-source-cache": [moduleQueryKey(revisionChannel, "api_source", "sources")], "chat-voting": [moduleQueryKey(revisionChannel, "chat_voting", "panel")], votekick: [moduleQueryKey(revisionChannel, "votekick", "panel")], belabox: [moduleQueryKey(revisionChannel, "belabox", "streams")], diff --git a/tests/unit/login-maintenance.test.ts b/tests/unit/login-maintenance.test.ts index 7cc5feb9..c5e66fb7 100644 --- a/tests/unit/login-maintenance.test.ts +++ b/tests/unit/login-maintenance.test.ts @@ -23,6 +23,8 @@ const makeEnvironment = async ( failTokenWriteAfterCommitOnce?: boolean; beforeTokenWrite?: () => Promise; onSuccessfulTokenWrite?: () => void; + memberChannelIds?: readonly string[]; + revokeUser?: (userId: string) => Promise; } = {}, ) => { const keys = parseKeyRing(keyRingSerialized); @@ -58,7 +60,11 @@ const makeEnvironment = async ( return statement; }), all: vi.fn().mockImplementation(() => - sql.includes("FROM twitch_login_identity") ? { results: [row] } : { results: [] }), + sql.includes("FROM twitch_login_identity") + ? { results: [row] } + : sql.includes("FROM channel_members") + ? { results: (options.memberChannelIds ?? []).map((channel_id) => ({ channel_id })) } + : { results: [] }), run: vi.fn().mockImplementation(async () => { if (sql.includes("UPDATE twitch_login_identity") && sql.includes("SET token_scopes_json")) { const [scopesJson, updatedAt, userId] = values; @@ -168,6 +174,10 @@ const makeEnvironment = async ( return { environment: { DB: { prepare, batch } as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => ({ revokeUser: options.revokeUser ?? (() => Promise.resolve()) }), + }, TWITCH_CLIENT_ID: "client-id", TWITCH_CLIENT_SECRET: "client-secret", SESSION_ENCRYPTION_KEYS: keyRingSerialized, @@ -404,6 +414,30 @@ describe("Login token maintenance", () => { }]); }); + it("surfaces a failed socket close after invalid_grant commits the identity revocation", async () => { + const revokeUser = vi.fn(() => Promise.reject(new Error("channel close failed"))); + const { environment, read } = await makeEnvironment("2026-09-18T00:59:59.000Z", { + memberChannelIds: ["channel-a"], + revokeUser, + }); + const fetcher = vi.fn() + .mockResolvedValueOnce(new Response( + JSON.stringify({ user_id: "user-1", login: "tester", expires_in: 3599 }), + { status: 200 }, + )) + .mockResolvedValueOnce(new Response( + JSON.stringify({ error: "invalid_grant", message: "Invalid OAuth token" }), + { status: 400 }, + )); + vi.stubGlobal("fetch", fetcher); + + await expect(maintainLoginIdentities(environment, "2026-09-18T00:00:00.000Z")) + .rejects.toThrow("channel close failed"); + + expect(read().status).toBe("revoked"); + expect(revokeUser).toHaveBeenCalledWith("user-1"); + }); + it("refreshes exactly once even after a 401 when expiry is still far off", async () => { const { environment, read, getInitialAccessTokenCiphertext, getInitialRefreshTokenCiphertext } = await makeEnvironment("2026-09-18T02:00:01.000Z"); const fetcher = vi.fn() diff --git a/tests/unit/member-routes.test.ts b/tests/unit/member-routes.test.ts index a927c569..94d0fa11 100644 --- a/tests/unit/member-routes.test.ts +++ b/tests/unit/member-routes.test.ts @@ -73,6 +73,10 @@ const insertBotIdentity = async (database: TestD1Database): Promise => { const environmentFor = (database: TestD1Database): Env => ({ DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => ({ revokeUser: () => Promise.resolve() }), + }, TWITCH_CLIENT_ID: "client-id", TWITCH_CLIENT_SECRET: "client-secret", TWITCH_BOT_LOGIN: "brobot", @@ -272,6 +276,29 @@ describe("Member management", () => { expect(removeResponse.status).toBe(204); }); + it("does not report a member removal complete until the socket revocation succeeds", async () => { + await setupChannel(database); + await insertMember(database, "kanal-a", "user-2", "operator"); + const failingEnvironment = { + ...environment, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => ({ revokeUser: () => Promise.reject(new Error("channel unavailable")) }), + }, + } as unknown as Env; + + const response = await panelRouter.fetch( + await requestFor("user-1", "/api/channels/kanal-a/members/user-2", "DELETE"), + failingEnvironment, + ); + const member = await database.prepare( + "SELECT user_id FROM channel_members WHERE channel_id = ? AND user_id = ?", + ).bind("kanal-a", "user-2").first(); + + expect(response.status).toBe(500); + expect(member).toBeNull(); + }); + it("returns the member list with unresolvable names even on a Helix error", async () => { await setupChannel(database); await insertMember(database, "kanal-a", "user-2", "operator"); diff --git a/tests/unit/panel-resource-revisions.test.ts b/tests/unit/panel-resource-revisions.test.ts index 5738f00d..343ecf57 100644 --- a/tests/unit/panel-resource-revisions.test.ts +++ b/tests/unit/panel-resource-revisions.test.ts @@ -10,17 +10,44 @@ import { reconcileDashboardRealtimeMessage, reconcileDashboardPanelResourceRevis import { moduleQueryKey } from "../../src/dashboard/data/module-query"; import { authRouter } from "../../src/worker/auth/routes"; import { eventSubChannelIdForNotification, eventSubRouter } from "../../src/worker/eventsub"; +import { realtimeRouter } from "../../src/worker/realtime"; import { panelRouter } from "../../src/worker/panel/routes"; import { moduleRouter } from "../../src/worker/panel/module-routes"; import { platformRouter } from "../../src/worker/platform/routes"; +import { app } from "../../src/worker/worker-app"; import { notifyCommittedResources, panelResourceChangedMessage, PANEL_RESOURCE_NOTIFIER_COVERAGE, readCompletePanelResourceRevisions, readPanelResourceRevisions } from "../../src/worker/panel-resources"; import { TestD1Database } from "./test-d1"; const writeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const nonPanelAllRoutes: ReadonlySet = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.nonPanelAllRoutes); +const writingGetRoutes = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.explicitlyCommittedRoutes + .map((route) => route.slice("GET ".length))); const routeIsCovered = (path: string, pattern: string): boolean => { - const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern; - return path.startsWith(prefix); + const pathParts = path.split("/").filter(Boolean); + const patternParts = pattern.split("/").filter(Boolean); + for (let index = 0; index < patternParts.length; index += 1) { + const expected = patternParts[index]; + if (expected === "*") return true; + const actual = pathParts[index]; + if (actual === undefined || (expected !== undefined && !expected.startsWith(":") && expected !== actual)) return false; + } + return pathParts.length === patternParts.length; }; +const routeNeedsNotifier = ( + route: { method: string; path: string }, + knownWritingGets: ReadonlySet = writingGetRoutes, +): boolean => writeMethods.has(route.method) || (route.method === "GET" && knownWritingGets.has(route.path)) || + (route.method === "ALL" && !nonPanelAllRoutes.has(`${route.method} ${route.path}`)); +const routeHasNotifier = ( + route: { method: string; path: string }, + patterns: readonly string[], + explicitRoutes: ReadonlySet, + knownWritingGets: ReadonlySet = writingGetRoutes, + nonPanelWrites: ReadonlySet = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.nonPanelWriteRoutes), +): boolean => !routeNeedsNotifier(route, knownWritingGets) || explicitRoutes.has(`${route.method} ${route.path}`) || + nonPanelWrites.has(`${route.method} ${route.path}`) || + nonPanelAllRoutes.has(`${route.method} ${route.path}`) || + patterns.some((pattern) => routeIsCovered(route.path, pattern)); const addChannel = async (database: TestD1Database, channelId: string): Promise => { await database.prepare( @@ -41,6 +68,15 @@ const panelWriteRegressions: readonly PanelWriteRegressionCase[] = [ keys: (id) => [queryKeys.channels(), queryKeys.channel(id, "overview"), queryKeys.channel(id, "settings"), dashboardDataKeys.system(id)], write: async (database, id) => { await database.prepare("UPDATE channels SET display_name = 'Changed' WHERE channel_id = ?").bind(id).run(); }, }, + { + name: "channel timezone and location library values", + keys: (id) => [moduleQueryKey(id, "text_library", "library")], + write: async (database, id) => { + await database.prepare( + "UPDATE channels SET location_name = 'Berlin', location_latitude = 52.5, location_longitude = 13.4, location_time_zone = 'Europe/Berlin' WHERE channel_id = ?", + ).bind(id).run(); + }, + }, { name: "members and their channel summaries", keys: (id) => [dashboardDataKeys.members(id), queryKeys.channel(id, "overview"), queryKeys.channels()], @@ -116,7 +152,6 @@ const panelWriteRegressions: readonly PanelWriteRegressionCase[] = [ keys: (id) => [ moduleQueryKey(id, "text_library", "library"), moduleQueryKey(id, "timers", "panel"), moduleQueryKey(id, "faq", "panel"), moduleQueryKey(id, "text_commands", "template-variables"), - moduleQueryKey(id, "api_source", "sources"), ], write: async (database, id) => { await database.prepare( @@ -129,6 +164,7 @@ const panelWriteRegressions: readonly PanelWriteRegressionCase[] = [ name: "module settings and module registry", keys: (id) => [ queryKeys.channel(id, "modules"), queryKeys.channel(id, "overview"), queryKeys.channels(), + dashboardDataKeys.system(id), moduleQueryKey(id, "text_library", "library"), dashboardDataKeys.variables(id), moduleQueryKey(id, "chat_voting", "settings"), moduleQueryKey(id, "text_commands", "commands"), ], @@ -199,7 +235,10 @@ const panelWriteRegressions: readonly PanelWriteRegressionCase[] = [ { name: "shared weather cache", keys: (id) => [moduleQueryKey(id, "text_library", "library")], - write: async (database) => { + write: async (database, id) => { + await database.prepare( + "UPDATE channels SET location_latitude = 52.5, location_longitude = 13.4 WHERE channel_id = ?", + ).bind(id).run(); await database.prepare( `INSERT INTO weather_cache (provider, location_key, latitude, longitude, payload_json, expires_at, updated_at) VALUES ('met_norway', 'test-location', 52.5, 13.4, '{}', '2026-10-10T01:00:00.000Z', '2026-10-10T00:00:00.000Z')`, @@ -208,31 +247,70 @@ const panelWriteRegressions: readonly PanelWriteRegressionCase[] = [ }, { name: "shared API cache", - keys: (id) => [moduleQueryKey(id, "api_source", "sources")], - write: async (database) => { + keys: (id) => [moduleQueryKey(id, "text_library", "library")], + write: async (database, id) => { await database.prepare( - `INSERT INTO api_source_cache (url_hash, payload_json, expires_at, updated_at) - VALUES ('${"a".repeat(64)}', '{}', '2026-10-10T01:00:00.000Z', '2026-10-10T00:00:00.000Z')`, - ).run(); + `INSERT INTO api_source_cache (url_hash, channel_id, payload_json, expires_at, updated_at) + VALUES ('${"a".repeat(64)}', ?, '{}', '2026-10-10T01:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).bind(id).run(); }, }, ]; describe("panel resource revisions", () => { - it("keeps every registered mutating route and module alarm under a notifier seam", () => { + it("guards every assembled mutating route and module alarm with a notifier seam", () => { const root = resolve(import.meta.dirname, "../.."); - const routers = [panelRouter, moduleRouter, platformRouter, authRouter, eventSubRouter]; - const mutations = routers.flatMap((router) => router.routes) - .filter(({ method }) => writeMethods.has(method)); + const routers = [panelRouter, moduleRouter, platformRouter, authRouter, eventSubRouter, realtimeRouter]; + const assembledRoutes = app.routes; + const mutations = assembledRoutes.filter((route) => routeNeedsNotifier(route)); expect(mutations.length).toBeGreaterThan(0); const routePatterns = [ PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes, PANEL_RESOURCE_NOTIFIER_COVERAGE.platformRoutes, - PANEL_RESOURCE_NOTIFIER_COVERAGE.authRoutes, PANEL_RESOURCE_NOTIFIER_COVERAGE.eventSubRoute, ]; - const uncovered = mutations.filter(({ path }) => !routePatterns.some((pattern) => routeIsCovered(path, pattern))); + const explicitRoutes = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.explicitlyCommittedRoutes); + const nonPanelGetRoutes = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.nonPanelGetRoutes); + const nonPanelWrites = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.nonPanelWriteRoutes); + expect(new Set([...writingGetRoutes].map((path) => `GET ${path}`))).toEqual(explicitRoutes); + const uncovered = mutations.filter((route) => !routeHasNotifier(route, routePatterns, explicitRoutes, writingGetRoutes, nonPanelWrites)); expect(uncovered).toEqual([]); + const unaccountedGets = assembledRoutes.filter(({ method, path }) => method === "GET" && + !routeIsCovered(path, PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes) && + !writingGetRoutes.has(path) && !nonPanelGetRoutes.has(path)); + expect(unaccountedGets).toEqual([]); + for (const router of routers) { + for (const route of router.routes) { + expect(assembledRoutes.some(({ method, path }) => method === route.method && path === route.path), + `${route.method} ${route.path} must be mounted in the root app`).toBe(true); + } + } + expect(assembledRoutes).toEqual(expect.arrayContaining([ + expect.objectContaining({ method: "GET", path: "/healthz" }), + expect.objectContaining({ method: "GET", path: "/overlay" }), + expect.objectContaining({ method: "GET", path: "/overlay.html" }), + expect.objectContaining({ method: "ALL", path: "/api/*" }), + expect.objectContaining({ method: "ALL", path: "/*" }), + ])); + expect(routeHasNotifier( + { method: "POST", path: "/api/unwrapped-write" }, routePatterns, explicitRoutes, + )).toBe(false); + expect(routeHasNotifier( + { method: "POST", path: "/unwrapped-root-write" }, routePatterns, explicitRoutes, + )).toBe(false); + expect(routeHasNotifier( + { method: "POST", path: "/api/twitch/eventsub-rogue" }, routePatterns, explicitRoutes, + )).toBe(false); + expect(routeHasNotifier( + { method: "ALL", path: "/api/realtime/unwrapped-write" }, routePatterns, explicitRoutes, + )).toBe(false); + const unwrappedWritingGet = { method: "GET", path: "/api/realtime/unwrapped-write" }; + expect(routeHasNotifier( + unwrappedWritingGet, + routePatterns, + explicitRoutes, + new Set([...writingGetRoutes, unwrappedWritingGet.path]), + )).toBe(false); const moduleMutations = MODULES.flatMap((module) => module.routes?.routes ?? []) .filter(({ method }) => writeMethods.has(method)); @@ -284,11 +362,16 @@ describe("panel resource revisions", () => { const scheduled = readFileSync(resolve(root, "src/worker/scheduled.ts"), "utf8"); expect(scheduled).toContain(".finally(async () => {"); expect(scheduled).toContain("await notifyCommittedResources(env);"); - const worker = readFileSync(resolve(root, "src/worker/index.ts"), "utf8"); + const worker = readFileSync(resolve(root, "src/worker/app-routes.ts"), "utf8"); expect(worker).toContain("app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes"); expect(worker).toContain("app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.platformRoutes"); - expect(worker).toMatch(/app\.use\(PANEL_RESOURCE_NOTIFIER_COVERAGE\.channelRoutes,[\s\S]*?finally \{\s*const channelId = context\.req\.param\("channelId"\);[\s\S]*?await notifyCommittedResources\(context\.env, channelId\);/u); + expect(worker).toMatch(/finally \{\s*const channelId = context\.req\.param\("channelId"\);\s*if \(channelId\.length > 0 && authorizedContextHas\(context, "session"\)\) \{\s*await notifyCommittedResources\(context\.env, channelId\);/u); + expect(worker).not.toContain("PANEL_RESOURCE_NOTIFIER_COVERAGE.authRoutes"); expect(worker).not.toMatch(/context\.res\.status !== 401/u); + const rootWorker = readFileSync(resolve(root, "src/worker/worker-app.ts"), "utf8"); + expect(rootWorker).toContain('app.get("/healthz"'); + expect(rootWorker).toContain('app.all("/api/*"'); + expect(rootWorker).toContain('app.all("*"'); const ballotStorageStart = channelObject.indexOf("private ballotStorage("); const ballotStorageEnd = channelObject.indexOf("private async notifyCommittedPanelResources(", ballotStorageStart); const ballotStorage = ballotStorageStart < 0 || ballotStorageEnd < 0 ? "" : channelObject.slice(ballotStorageStart, ballotStorageEnd); @@ -305,10 +388,38 @@ describe("panel resource revisions", () => { expect(ballotTermCast).toContain('result.status === "overflow"'); expect(ballotTermCast).toContain("await this.notifyCommittedPanelResources()"); const eventSubSource = readFileSync(resolve(root, "src/worker/eventsub.ts"), "utf8"); - expect(eventSubSource).toMatch(/finally \{\s*await notifyCommittedResources\(context\.env, revocation\.channelId\);/u); expect(eventSubSource).toMatch(/finally \{\s*await notifyCommittedResources\(context\.env, notifierChannelId\);/u); + expect(eventSubSource).toMatch(/revokeRealtimeUserFromAllChannels\([\s\S]*?revocation\.authorizationIdentity\.userId/u); const authSource = readFileSync(resolve(root, "src/worker/auth/routes.ts"), "utf8"); expect(authSource.match(/finally \{\s*await notifyCommittedResources\(env\);/gu) ?? []).toHaveLength(2); + expect(authSource).toMatch(/await upsertBotIdentityAndStatus\([\s\S]*?await notifyCommittedResources\(context\.env\);/u); + expect(authSource).toMatch(/await upsertLoginIdentity\([\s\S]*?await notifyCommittedResources\(context\.env\);/u); + const memberRoutes = readFileSync(resolve(root, "src/worker/panel/member-routes.ts"), "utf8"); + const platformRoutes = readFileSync(resolve(root, "src/worker/platform/routes.ts"), "utf8"); + const loginMaintenance = readFileSync(resolve(root, "src/worker/login-maintenance.ts"), "utf8"); + expect(memberRoutes).toMatch(/if \(!changed\) return[\s\S]*?await revokeRealtimeUser\(context\.env\.CHANNEL, channelId, userId\);/u); + expect(platformRoutes).toMatch(/if \(!changed\) return[\s\S]*?await revokeRealtimeUser\(context\.env\.CHANNEL, channelId, userId\);/u); + expect(authSource).toMatch(/await revokeSession\(context\.env\.DB, session\.sessionId, now, "logout"\);\s*await revokeRealtimeSessionForUser/u); + expect(loginMaintenance).toMatch(/if \(revoked\) \{\s*await revokeRealtimeUserFromAllChannels/u); + }); + + it("does not enumerate channels for an unauthenticated OAuth callback", async () => { + const prepare = vi.fn(); + const idFromName = vi.fn((channelId: string) => channelId); + const env = { + DB: { prepare } as unknown as D1Database, + CHANNEL: { idFromName, get: vi.fn() }, + PUBLIC_ORIGIN: "https://brobot.example", + } as unknown as Env; + + const response = await app.fetch( + new Request("https://brobot.example/auth/twitch/callback"), + env, + ); + + expect(response.status).toBe(400); + expect(prepare).not.toHaveBeenCalled(); + expect(idFromName).not.toHaveBeenCalled(); }); it("uses the subscription registry to resolve EventSub notifier channels, including raid directions", () => { @@ -525,6 +636,187 @@ describe("panel resource revisions", () => { } }); + it("queues only the owner channel when an API cache commit changes library values", async () => { + const database = new TestD1Database(); + const visitedChannels: string[] = []; + try { + await addChannel(database, "api-cache-owner"); + await addChannel(database, "api-cache-other"); + await database.prepare("DELETE FROM panel_resource_pending").run(); + const ownerBefore = (await readPanelResourceRevisions(database as unknown as D1Database, "api-cache-owner"))["channel.library"] ?? 0; + const otherBefore = (await readPanelResourceRevisions(database as unknown as D1Database, "api-cache-other"))["channel.library"] ?? 0; + await database.prepare( + `INSERT INTO api_source_cache (url_hash, channel_id, payload_json, expires_at, updated_at) + VALUES (?, 'api-cache-owner', '{}', '2026-10-10T01:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).bind("a".repeat(64)).run(); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: (channelId: string) => ({ + reconcilePanelResources: () => { visitedChannels.push(channelId); return Promise.resolve(); }, + }), + }, + } as unknown as Env; + + await notifyCommittedResources(env); + + expect(visitedChannels).toEqual(["api-cache-owner"]); + expect((await readPanelResourceRevisions(database as unknown as D1Database, "api-cache-owner"))["channel.library"]) + .toBe(ownerBefore + 1); + expect((await readPanelResourceRevisions(database as unknown as D1Database, "api-cache-other"))["channel.library"]) + .toBe(otherBefore); + } finally { + database.close(); + } + }); + + it("keeps legacy cache revisions available during migration-before-code rollout", async () => { + const database = new TestD1Database(); + const visitedChannels: string[] = []; + try { + await addChannel(database, "legacy-cache-a"); + await addChannel(database, "legacy-cache-b"); + await database.prepare("DELETE FROM panel_resource_pending").run(); + const publicationTable = database.sqlite.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'panel_global_resource_publications'", + ).get() as { name: string } | undefined; + expect(publicationTable?.name).toBe("panel_global_resource_publications"); + const revision = (resource: string): number => { + const row = database.sqlite.prepare( + "SELECT revision FROM panel_global_resource_revisions WHERE resource = ?", + ).get(resource) as { revision: number }; + return row.revision; + }; + const apiBefore = revision("api.cache"); + const weatherBefore = revision("weather.cache"); + const libraryBefore = new Map([ + ["legacy-cache-a", (await readPanelResourceRevisions(database as unknown as D1Database, "legacy-cache-a"))["channel.library"] ?? 0], + ["legacy-cache-b", (await readPanelResourceRevisions(database as unknown as D1Database, "legacy-cache-b"))["channel.library"] ?? 0], + ]); + + await database.prepare( + `INSERT INTO api_source_cache (url_hash, payload_json, expires_at, updated_at) + VALUES (?, '{}', '2026-10-10T01:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).bind("b".repeat(64)).run(); + await database.prepare( + `INSERT INTO weather_cache + (provider, location_key, latitude, longitude, payload_json, expires_at, updated_at) + VALUES ('met_norway', 'legacy-location', 0, 0, '{}', '2026-10-10T01:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).run(); + + expect(revision("api.cache")).toBe(apiBefore + 1); + expect(revision("weather.cache")).toBe(weatherBefore + 1); + + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: (channelId: string) => ({ + reconcilePanelResources: () => { visitedChannels.push(channelId); return Promise.resolve(); }, + }), + }, + } as unknown as Env; + await notifyCommittedResources(env); + + expect(visitedChannels.sort((left, right) => left.localeCompare(right))) + .toEqual(["legacy-cache-a", "legacy-cache-b"]); + for (const channelId of visitedChannels) { + expect((await readPanelResourceRevisions(database as unknown as D1Database, channelId))["channel.library"]) + .toBe((libraryBefore.get(channelId) ?? 0) + 1); + } + + await database.prepare("DELETE FROM panel_resource_pending").run(); + const ownedBefore = (await readPanelResourceRevisions(database as unknown as D1Database, "legacy-cache-a"))["channel.library"] ?? 0; + const unrelatedBefore = (await readPanelResourceRevisions(database as unknown as D1Database, "legacy-cache-b"))["channel.library"] ?? 0; + await database.prepare("UPDATE api_source_cache SET channel_id = 'legacy-cache-a' WHERE url_hash = ?") + .bind("b".repeat(64)).run(); + + expect((await readPanelResourceRevisions(database as unknown as D1Database, "legacy-cache-a"))["channel.library"]) + .toBe(ownedBefore + 1); + expect((await readPanelResourceRevisions(database as unknown as D1Database, "legacy-cache-b"))["channel.library"]) + .toBe(unrelatedBefore); + expect((database.sqlite.prepare("SELECT DISTINCT channel_id FROM panel_resource_pending").all() as Array<{ channel_id: string }>) + .map(({ channel_id }) => channel_id)).toEqual(["legacy-cache-a"]); + } finally { + database.close(); + } + }); + + it("fans global bot status commits out to each affected channel", async () => { + const database = new TestD1Database(); + const visitedChannels: string[] = []; + try { + await addChannel(database, "bot-status-a"); + await addChannel(database, "bot-status-b"); + await database.prepare("DELETE FROM panel_resource_pending").run(); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: (channelId: string) => ({ + reconcilePanelResources: () => { visitedChannels.push(channelId); return Promise.resolve(); }, + }), + }, + } as unknown as Env; + + await database.prepare( + `INSERT INTO bot_identity_status (id, status, reason, updated_at) + VALUES (1, 'error', 'token_check_failed', '2026-10-10T00:00:00.000Z')`, + ).run(); + await notifyCommittedResources(env); + + expect(visitedChannels.sort((left, right) => left.localeCompare(right))).toEqual(["bot-status-a", "bot-status-b"]); + for (const channelId of ["bot-status-a", "bot-status-b"]) { + const revisions = await readPanelResourceRevisions(database as unknown as D1Database, channelId); + expect(revisions["channel.overview"]).toBeGreaterThan(0); + expect(revisions["channel.system"]).toBeGreaterThan(0); + expect(revisions["channel.modules"]).toBeGreaterThan(0); + } + } finally { + database.close(); + } + }); + + it("revises login identity dependencies in the owner and historical member channels", async () => { + const database = new TestD1Database(); + try { + await addChannel(database, "identity-owner"); + await addChannel(database, "identity-member-channel"); + await database.prepare( + `INSERT INTO channel_members (channel_id, user_id, role, created_at, updated_at) + VALUES ('identity-member-channel', 'identity-owner', 'operator', '2026-10-10T00:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).run(); + await database.prepare( + `INSERT INTO channel_modules (channel_id, module_id, enabled, settings) + VALUES ('identity-owner', 'chat_voting', 1, '{}')`, + ).run(); + await database.prepare( + `INSERT INTO audit_log (audit_id, actor_user_id, created_at, channel_id, action, before_json, after_json) + VALUES ('identity-audit', 'identity-owner', '2026-10-10T00:00:00.000Z', 'identity-member-channel', 'channel.updated', '{}', '{}')`, + ).run(); + await database.prepare( + `INSERT INTO twitch_login_identity + (user_id, login, scopes_json, access_token_ciphertext, refresh_token_ciphertext, expires_at, status, created_at, updated_at) + VALUES ('identity-owner', 'old-login', '[]', 'access', 'refresh', '2026-10-11T00:00:00.000Z', 'connected', '2026-10-10T00:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).run(); + const ownerBefore = await readPanelResourceRevisions(database as unknown as D1Database, "identity-owner"); + const memberBefore = await readPanelResourceRevisions(database as unknown as D1Database, "identity-member-channel"); + + await database.prepare("UPDATE twitch_login_identity SET login = 'new-login' WHERE user_id = 'identity-owner'").run(); + + const ownerAfter = await readPanelResourceRevisions(database as unknown as D1Database, "identity-owner"); + const memberAfter = await readPanelResourceRevisions(database as unknown as D1Database, "identity-member-channel"); + expect(ownerAfter["channel.modules"]).toBeGreaterThan(ownerBefore["channel.modules"] ?? 0); + expect(ownerAfter["module:chat_voting:settings"]).toBeGreaterThan(ownerBefore["module:chat_voting:settings"] ?? 0); + expect(ownerAfter["module:ads:schedule"]).toBeGreaterThan(ownerBefore["module:ads:schedule"] ?? 0); + expect(memberAfter["channel.members"]).toBeGreaterThan(memberBefore["channel.members"] ?? 0); + expect(memberAfter["channel.audit"]).toBeGreaterThan(memberBefore["channel.audit"] ?? 0); + } finally { + database.close(); + } + }); + it("combines D1 and channel Durable Object revision counters", async () => { const database = new TestD1Database(); try { @@ -588,9 +880,10 @@ describe("panel resource revisions", () => { "eventsub_maintenance_locks", "eventsub_messages", "oauth_transactions", - "panel_global_resource_publications", "panel_global_resource_revisions", + "panel_global_resource_publications", "panel_resource_dependencies", + "panel_resource_pending", "panel_resource_revisions", "text_command_user_cooldowns", "twitch_app_access_token", diff --git a/tests/unit/platform-routes.test.ts b/tests/unit/platform-routes.test.ts index 1288210d..0b302a1e 100644 --- a/tests/unit/platform-routes.test.ts +++ b/tests/unit/platform-routes.test.ts @@ -22,6 +22,10 @@ const platformId = "26876135"; const environmentFor = (database: TestD1Database, platform = [platformId]): Env => ({ DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => ({ revokeUser: () => Promise.resolve() }), + }, TWITCH_CLIENT_ID: "client-id", TWITCH_CLIENT_SECRET: "client-secret", TWITCH_BOT_LOGIN: "brobot", diff --git a/tests/unit/realtime.test.ts b/tests/unit/realtime.test.ts index a4e7562a..aea9c54d 100644 --- a/tests/unit/realtime.test.ts +++ b/tests/unit/realtime.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { publishStreamStateChanged } from "../../src/worker/realtime"; +import { + publishStreamStateChanged, + revokeRealtimeSessionForUser, + revokeRealtimeUser, + revokeRealtimeUserFromAllChannels, +} from "../../src/worker/realtime"; describe("realtime stream state publication", () => { it("publishes the panel update when overlay recipient selection fails", async () => { @@ -38,4 +43,72 @@ describe("realtime stream state publication", () => { }]); warn.mockRestore(); }); + + it("propagates a failed member socket revocation after the membership commit", async () => { + const revokeUser = vi.fn(() => Promise.reject(new Error("channel unavailable"))); + const namespace = { + idFromName: (channelId: string) => channelId, + get: () => ({ revokeUser }), + } as unknown as Env["CHANNEL"]; + + await expect(revokeRealtimeUser(namespace, "channel-a", "user-1")) + .rejects.toThrow("channel unavailable"); + expect(revokeUser).toHaveBeenCalledWith("user-1"); + }); + + it("waits for cross-channel logout closures before propagating a failure", async () => { + let releaseSecond: (() => void) | undefined; + const completedChannels: string[] = []; + const database = { + prepare: () => ({ + bind: () => ({ all: () => Promise.resolve({ results: [{ channel_id: "channel-a" }, { channel_id: "channel-b" }] }) }), + }), + } as unknown as D1Database; + const namespace = { + idFromName: (channelId: string) => channelId, + get: (channelId: string) => ({ + revokeSession: () => channelId === "channel-a" + ? Promise.reject(new Error("channel-a unavailable")) + : new Promise((resolve) => { releaseSecond = () => { completedChannels.push(channelId); resolve(); }; }), + }), + } as unknown as Env["CHANNEL"]; + + const revocation = revokeRealtimeSessionForUser(database, namespace, "user-1", "session-1"); + await vi.waitFor(() => { expect(releaseSecond).toBeDefined(); }); + let settled = false; + void revocation.catch(() => { settled = true; }); + await Promise.resolve(); + expect(settled).toBe(false); + releaseSecond?.(); + await expect(revocation).rejects.toThrow("channel-a unavailable"); + expect(completedChannels).toEqual(["channel-b"]); + }); + + it("propagates invalid-grant socket revocation failures", async () => { + const database = { + prepare: () => ({ + bind: () => ({ all: () => Promise.resolve({ results: [{ channel_id: "channel-a" }] }) }), + }), + } as unknown as D1Database; + const namespace = { + idFromName: (channelId: string) => channelId, + get: () => ({ revokeUser: () => Promise.reject(new Error("invalid-grant close failed")) }), + } as unknown as Env["CHANNEL"]; + + await expect(revokeRealtimeUserFromAllChannels(database, namespace, "user-1")) + .rejects.toThrow("invalid-grant close failed"); + }); + + it("propagates database failures while finding sockets to revoke", async () => { + const database = { + prepare: () => ({ bind: () => ({ all: () => Promise.reject(new Error("membership query failed")) }) }), + } as unknown as D1Database; + const namespace = { + idFromName: (channelId: string) => channelId, + get: () => ({ revokeUser: vi.fn() }), + } as unknown as Env["CHANNEL"]; + + await expect(revokeRealtimeUserFromAllChannels(database, namespace, "user-1")) + .rejects.toThrow("membership query failed"); + }); }); diff --git a/tests/unit/sql-contract.test.ts b/tests/unit/sql-contract.test.ts index 35d7bad4..68c33372 100644 --- a/tests/unit/sql-contract.test.ts +++ b/tests/unit/sql-contract.test.ts @@ -191,10 +191,10 @@ describe("SQL contract", () => { const objects = database.prepare( "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY tbl_name, type DESC, name", ).all() as unknown as SchemaObject[]; - expect(objects.filter((object) => object.type === "table")).toHaveLength(54); - expect(objects.filter((object) => object.type === "index")).toHaveLength(43); - expect(objects.filter((object) => object.type === "trigger")).toHaveLength(121); - expect(objects).toHaveLength(218); + expect(objects.filter((object) => object.type === "table")).toHaveLength(55); + expect(objects.filter((object) => object.type === "index")).toHaveLength(44); + expect(objects.filter((object) => object.type === "trigger")).toHaveLength(124); + expect(objects).toHaveLength(223); const tableColumns = (table: string): Set => new Set( (database.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(({ name }) => name), diff --git a/tests/worker/channel-object.test.ts b/tests/worker/channel-object.test.ts index 372d5ec7..4ceba3a1 100644 --- a/tests/worker/channel-object.test.ts +++ b/tests/worker/channel-object.test.ts @@ -280,10 +280,35 @@ const objectFor = ( platformAlarm = null; return Promise.resolve(); }); + let preparedQuery = ""; + let preparedBindings: unknown[] = []; const prepared = { - bind: vi.fn(() => prepared), - all: vi.fn().mockResolvedValue({ results: [] }), - first: vi.fn().mockResolvedValue({ token_id: "token-1", overlay_id: null }), + bind: vi.fn((...values: unknown[]) => { + preparedBindings = values; + return prepared; + }), + all: vi.fn(() => { + if (!/^\s*SELECT session\.session_id, session\.user_id, member\.role/iu.test(preparedQuery)) { + return Promise.resolve({ results: [] }); + } + const sessionIds = preparedBindings.slice(1, -1); + const channel = preparedBindings[0]; + return Promise.resolve({ + results: sockets.flatMap((socket) => { + const principal = socket.deserializeAttachment() as RealtimePrincipal | null; + return principal?.kind === "panel" && principal.channelId === channel && sessionIds.includes(principal.sessionId) + ? [{ session_id: principal.sessionId, user_id: principal.userId, role: principal.role }] + : []; + }), + }); + }), + first: vi.fn(() => Promise.resolve(/^\s*SELECT session\.session_id, session\.user_id, member\.role/iu.test(preparedQuery) + ? { + session_id: preparedBindings[1], + user_id: preparedBindings[2], + role: preparedBindings[3], + } + : { token_id: "token-1", overlay_id: null })), run: vi.fn().mockResolvedValue({ success: true }), }; const getWebSockets = vi.fn((tag?: string) => tag === undefined @@ -349,7 +374,10 @@ const objectFor = ( const object = Object.create(ChannelObject.prototype) as ChannelObject; (object as unknown as { ctx: DurableObjectState }).ctx = state; (object as unknown as { env: Env }).env = { - DB: database ?? ({ prepare: () => prepared } as unknown as D1Database), + DB: database ?? ({ + prepare: (query: string) => { preparedQuery = query; return prepared; }, + batch: vi.fn(() => Promise.resolve([])), + } as unknown as D1Database), TWITCH_CLIENT_ID: "client-id", } as Env; (object as unknown as { overlayStreamDetailsRefreshes: Map> }).overlayStreamDetailsRefreshes = new Map(); @@ -645,7 +673,27 @@ describe("ChannelObject realtime path", () => { tokenId: "token-1", overlayId: null, expiresAt: null, - })).then((response) => response.status)).resolves.toBe(403); + })).then((response) => response.status)).resolves.toBe(403); + }); + + it("rejects a panel handshake revoked after route authorization but before object acceptance", async () => { + const prepare = vi.fn(() => ({ + bind: vi.fn(() => ({ + first: vi.fn(() => Promise.resolve(null)), + })), + })); + const database = { prepare } as unknown as D1Database; + const object = objectFor([], database); + const pair = new WebSocketPair(); + vi.stubGlobal("WebSocketPair", function WebSocketPairDouble() { return pair; }); + const state = (object as unknown as { ctx: { acceptWebSocket: ReturnType } }).ctx; + + const response = await object.fetch(upgradeRequest(validPrincipal())); + + expect(response.status).toBe(403); + expect(state.acceptWebSocket).not.toHaveBeenCalled(); + expect(prepare).toHaveBeenCalledWith(expect.stringContaining("FROM auth_sessions AS session")); + expect(prepare).toHaveBeenCalledWith(expect.stringContaining("identity.status <> 'revoked'")); }); it("rejects a publish for a foreign channel", async () => { @@ -1265,6 +1313,49 @@ describe("ChannelObject realtime path", () => { expect(overlay.send.mock.calls).toHaveLength(0); }); + it("emits an ads revision when an uncached schedule read is rate limited", async () => { + const panel = socketFor(validPrincipal()); + const object = objectFor([panel]); + mocks.getAdSchedule.mockResolvedValueOnce({ + fetched: false, + reason: "rate_limited", + detail: { retryAfter: 30 }, + schedule: null, + }); + + await expect(object.refreshAdSchedule()).resolves.toMatchObject({ cache: null, reason: "rate_limited" }); + + expect(await object.getPanelResourceRevisions()).toEqual({ "module:ads:schedule": 1 }); + expect(panel.send.mock.calls.map(([message]) => typeOfSerializedMessage(message))).toContain("panel.resources.changed"); + expect(JSON.parse(panel.send.mock.calls.find(([message]) => + typeOfSerializedMessage(message) === "panel.resources.changed")?.[0] ?? "null")).toMatchObject({ + payload: { resources: ["module:ads:schedule"] }, + }); + }); + + it("closes panel sockets whose current D1 membership no longer authorizes delivery", async () => { + const panel = socketFor(validPrincipal()); + const database = { + prepare: () => ({ + bind: () => ({ all: () => Promise.resolve({ results: [] }) }), + }), + } as unknown as D1Database; + const object = objectFor([panel], database); + const hint: RealtimeEnvelope<"panel.resources.changed"> = { + version: 1, + id: "revoked-member-hint", + createdAt: "2026-10-10T00:00:00.000Z", + channelId: "kanal-a", + type: "panel.resources.changed", + payload: { resources: ["channel.events"], revisions: { "channel.events": 1 } }, + }; + + await object.publish([hint]); + + expect(panel.close.mock.calls).toEqual([[4003, "authorization revoked"]]); + expect(panel.send.mock.calls).toHaveLength(0); + }); + it("does not count chat messages while the channel is offline", async () => { let streamState = "offline"; const statement = { diff --git a/tests/worker/healthz.test.ts b/tests/worker/healthz.test.ts index 5a97e758..612b50ba 100644 --- a/tests/worker/healthz.test.ts +++ b/tests/worker/healthz.test.ts @@ -151,7 +151,7 @@ describe("worker skeleton", () => { it("checks the schema by highest migration number, not by application order", async () => { const database = (env as unknown as { DB: D1Database }).DB; const status = async () => (await exports.default.fetch(new Request("http://localhost/healthz"))).status; - const sentinel: string = "0047_panel_resource_revisions.sql"; + const sentinel: string = "0048_committed_panel_resource_delivery.sql"; const original = (await database.prepare("SELECT name FROM d1_migrations WHERE name LIKE '0040_%' OR name = ?") .bind(sentinel).all<{ name: string }>()).results.map((row) => row.name); const reinsert = async (names: string[]): Promise => { @@ -162,7 +162,7 @@ describe("worker skeleton", () => { } }; try { - // 0040 applied last (highest id) but the highest number is still 0047. + // 0040 applied last (highest id) but the highest number is still 0048. await reinsert(original.filter((name) => name === sentinel).concat(original.filter((name) => name !== sentinel))); expect(await status()).toBe(200); From 11614d72827e5f7f77190630193b9502e3114035 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 16:03:37 +0200 Subject: [PATCH 09/16] =?UTF-8?q?=F0=9F=90=9B=20fix(realtime):=20close=20c?= =?UTF-8?q?overage=20and=20authorization=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Authorization: #387 version queued revocations and recheck socket membership and sessions before close. - Delivery: #387 retry failed socket closures and reconcile every affected committed channel. - Coverage: #387 guard runtime alarm handlers and add regressions for cross-client freshness and route dependencies. --- .../0049_pending_realtime_revocations.sql | 7 ++ ...0050_pending_realtime_revocation_index.sql | 2 + src/worker/config.ts | 4 +- src/worker/db/eventsub-state.ts | 13 +++ src/worker/db/login-identity.ts | 60 +++++++++++- src/worker/durable/ChannelObject.ts | 74 ++++++++++----- src/worker/durable/module-alarm-registry.ts | 34 +++++++ src/worker/eventsub.ts | 20 +++- src/worker/login-maintenance.ts | 22 +++++ tests/unit/api-source.test.ts | 10 ++ tests/unit/bot-maintenance.test.ts | 6 +- tests/unit/eventsub.test.ts | 95 ++++++++++++++++++- tests/unit/login-maintenance.test.ts | 73 +++++++++++++- tests/unit/panel-resource-revisions.test.ts | 85 +++++++++++++++-- tests/unit/sql-contract.test.ts | 6 +- tests/worker/channel-object.test.ts | 60 +++++++++++- tests/worker/healthz.test.ts | 5 +- 17 files changed, 525 insertions(+), 51 deletions(-) create mode 100644 migrations/0049_pending_realtime_revocations.sql create mode 100644 migrations/0050_pending_realtime_revocation_index.sql create mode 100644 src/worker/durable/module-alarm-registry.ts diff --git a/migrations/0049_pending_realtime_revocations.sql b/migrations/0049_pending_realtime_revocations.sql new file mode 100644 index 00000000..b59d835e --- /dev/null +++ b/migrations/0049_pending_realtime_revocations.sql @@ -0,0 +1,7 @@ +-- Keep revoked login sockets on a durable retry queue until every current +-- channel Durable Object confirms that it closed the user's panel sockets. +CREATE TABLE pending_realtime_user_revocations ( + user_id TEXT PRIMARY KEY, + requested_at TEXT NOT NULL, + generation INTEGER NOT NULL DEFAULT 1 +); diff --git a/migrations/0050_pending_realtime_revocation_index.sql b/migrations/0050_pending_realtime_revocation_index.sql new file mode 100644 index 00000000..a506b665 --- /dev/null +++ b/migrations/0050_pending_realtime_revocation_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX pending_realtime_user_revocations_ordered_idx + ON pending_realtime_user_revocations (requested_at, user_id); diff --git a/src/worker/config.ts b/src/worker/config.ts index 586bca21..121f434f 100644 --- a/src/worker/config.ts +++ b/src/worker/config.ts @@ -19,8 +19,8 @@ export const REQUIRED_SECRET_NAMES = [ // update it and /healthz reports a freshly set-up database as broken (503) // even though everything is fine. `tests/unit/schema-baseline.test.ts` // therefore pins it to the last file in `migrations/`. -export const LATEST_SCHEMA_MIGRATION = "0048_committed_panel_resource_delivery.sql"; -export const LATEST_SCHEMA_TABLE = "panel_resource_pending"; +export const LATEST_SCHEMA_MIGRATION = "0050_pending_realtime_revocation_index.sql"; +export const LATEST_SCHEMA_TABLE = "pending_realtime_user_revocations"; const REQUIRED_BINDING_NAMES = ["DB", "CHANNEL", "ASSETS", "CF_VERSION_METADATA"] as const; diff --git a/src/worker/db/eventsub-state.ts b/src/worker/db/eventsub-state.ts index 51b41ed4..92c2789d 100644 --- a/src/worker/db/eventsub-state.ts +++ b/src/worker/db/eventsub-state.ts @@ -184,6 +184,19 @@ export const rememberEventSubMessageAndRevocation = async ( AND user_id = ? AND status = 'revoked'`, ).bind(identity.userId), + db.prepare( + `INSERT INTO pending_realtime_user_revocations (user_id, requested_at, generation) + SELECT user_id, updated_at, 1 + FROM twitch_login_identity + WHERE changes() = 1 + AND user_id = ? + AND status = 'revoked' + AND reason = ? + AND updated_at = ? + ON CONFLICT(user_id) DO UPDATE SET + requested_at = excluded.requested_at, + generation = pending_realtime_user_revocations.generation + 1`, + ).bind(identity.userId, revocation.reason, revocation.updatedAt), ] : []; const results = await db.batch([message, storedRevocation, storedState, ...identityEffects]); diff --git a/src/worker/db/login-identity.ts b/src/worker/db/login-identity.ts index 19c186f5..9a3d232c 100644 --- a/src/worker/db/login-identity.ts +++ b/src/worker/db/login-identity.ts @@ -314,10 +314,68 @@ export const revokeLoginIdentityAndSessionsForUser = async ( expectedAccessTokenCiphertext, expectedRefreshTokenCiphertext, ); - const results = await db.batch([identityRevocation, sessionRevocation]); + const queueSocketRevocation = db.prepare( + `INSERT INTO pending_realtime_user_revocations (user_id, requested_at, generation) + SELECT user_id, updated_at, 1 + FROM twitch_login_identity + WHERE user_id = ? AND status = 'revoked' AND reason = ? AND updated_at = ? + ON CONFLICT(user_id) DO UPDATE SET + requested_at = excluded.requested_at, + generation = pending_realtime_user_revocations.generation + 1`, + ).bind(userId, reason, revokedAt); + const results = await db.batch([identityRevocation, sessionRevocation, queueSocketRevocation]); return (results[0]?.meta.changes ?? 0) > 0; }; +export interface PendingRealtimeUserRevocation { + userId: string; + requestedAt: string; + generation: number; +} + +interface PendingRealtimeUserRevocationRow { + user_id: string; + requested_at: string; + generation: number; +} + +const mapPendingRealtimeUserRevocation = ( + row: PendingRealtimeUserRevocationRow, +): PendingRealtimeUserRevocation => ({ + userId: row.user_id, + requestedAt: row.requested_at, + generation: row.generation, +}); + +export const listPendingRealtimeUserRevocations = async ( + db: D1Database, +): Promise => { + const result = await db.prepare( + "SELECT user_id, requested_at, generation FROM pending_realtime_user_revocations ORDER BY requested_at, user_id", + ).all(); + return result.results.map(mapPendingRealtimeUserRevocation); +}; + +export const getPendingRealtimeUserRevocation = async ( + db: D1Database, + userId: string, +): Promise => { + const row = await db.prepare( + "SELECT user_id, requested_at, generation FROM pending_realtime_user_revocations WHERE user_id = ?", + ).bind(userId).first(); + return row === null ? null : mapPendingRealtimeUserRevocation(row); +}; + +export const clearPendingRealtimeUserRevocation = async ( + db: D1Database, + revocation: PendingRealtimeUserRevocation, +): Promise => { + await db.prepare( + `DELETE FROM pending_realtime_user_revocations + WHERE user_id = ? AND requested_at = ? AND generation = ?`, + ).bind(revocation.userId, revocation.requestedAt, revocation.generation).run(); +}; + export const rotateLoginTokensForUser = async ( db: D1Database, userId: string, diff --git a/src/worker/durable/ChannelObject.ts b/src/worker/durable/ChannelObject.ts index 94cf3261..44525205 100644 --- a/src/worker/durable/ChannelObject.ts +++ b/src/worker/durable/ChannelObject.ts @@ -36,6 +36,17 @@ import type { ModuleScheduleInputChangeReason, } from "../../modules/contract"; import { MODULES } from "../../modules/registry"; +import { + ACTIVE_CHATTER_EXPIRY_HANDLER, + AD_COUNTDOWN_REFRESH_HANDLER, + AD_PREWARNING_HANDLER, + CHANNEL_HOST_ALARM_HANDLER_KEYS, + MODULE_SCHEDULE_INPUTS_CHANGED_HANDLER, + SECURITY_RETRY_HANDLER, + SECURITY_ROUND_HANDLER, + missingAlarmHandlerKeys, + moduleAlarmHandlerEntries, +} from "./module-alarm-registry"; import { getAdSchedule } from "../../modules/ads/adapters/ad-schedule"; import { writeAdCountdownState } from "../../modules/ads/adapters/countdown-state"; import { adCountdownStateForSchedule, createAdCountdownOverlayAction } from "../../modules/ads/overlay/countdown-action"; @@ -113,7 +124,6 @@ const LEGACY_ACTIVE_CHATTER_STREAM_KEY = "chatter:stream"; const ACTIVE_CHATTER_TABLE = "active_chatters"; const ACTIVE_CHATTER_KEY_TABLE = "active_chatter_key"; const ACTIVE_CHATTER_EXPIRY_KEY = "host:active_chatters_expiry"; -const ACTIVE_CHATTER_EXPIRY_HANDLER = "channel.active_chatters_expiry"; const ACTIVE_CHATTER_RETENTION_MS = 60 * 60 * 1_000; const ACTIVE_CHATTER_KEY_ROTATION_MS = 24 * 60 * 60 * 1_000; const MODULE_ALARM_OWNER_REVISIONS_KEY = "module_alarm:owner_revisions"; @@ -164,11 +174,6 @@ const AD_PREWARNING_RETRY_DELAYS_MS = [5_000, 10_000] as const; // claimed. const AD_PREWARNING_CLAIM_RETENTION_MS = 24 * 60 * 60 * 1000; const ALARM_HANDLER_RECOVERY_DELAY_MS = 60_000; -const SECURITY_ROUND_HANDLER = "channel.security_round"; -const SECURITY_RETRY_HANDLER = "channel.security_retry"; -const AD_PREWARNING_HANDLER = "channel.ad_prewarning"; -const AD_COUNTDOWN_REFRESH_HANDLER = "channel.ad_countdown_refresh"; -const MODULE_SCHEDULE_INPUTS_CHANGED_HANDLER = "channel.module_schedule_inputs_changed"; const MODULE_SCHEDULE_INPUTS_CHANGED_KEY = "host:schedule_inputs_changed"; const LEGACY_ALARM_DEFINITIONS = [ { key: SECURITY_DEADLINE_KEY, handler: SECURITY_ROUND_HANDLER, suppressedBy: SECURITY_RETRY_KEY }, @@ -1942,25 +1947,32 @@ export class ChannelObject extends DurableObject { }, }], ]); - for (const module of modules) { - for (const registration of module.alarms ?? []) { - const handler = `module:${module.id}:${registration.key}`; - if (handlers.has(handler)) throw new Error(`Duplicate alarm handler ${handler}.`); - const modulePrefix = `module:${module.id}:`; - handlers.set(handler, { - ...(registration.retryDelaysMs === undefined ? {} : { retryDelaysMs: registration.retryDelaysMs }), - handle: async (key, entry) => { - const execute = () => registration.handle( - this.moduleAlarmContext(module.id, registration, externalFetchBudget), - key.startsWith(modulePrefix) ? key.slice(modulePrefix.length) : key, - entry.deadline, - entry.ownerRevision, - ); - await this.runModuleAlarmHandler(module.id, registration.key, execute); - return undefined; - }, - }); - } + for (const { moduleId, registration } of moduleAlarmHandlerEntries(modules)) { + const handler = `module:${moduleId}:${registration.key}`; + if (handlers.has(handler)) throw new Error(`Duplicate alarm handler ${handler}.`); + const modulePrefix = `module:${moduleId}:`; + handlers.set(handler, { + ...(registration.retryDelaysMs === undefined ? {} : { retryDelaysMs: registration.retryDelaysMs }), + handle: async (key, entry) => { + const execute = () => registration.handle( + this.moduleAlarmContext(moduleId, registration, externalFetchBudget), + key.startsWith(modulePrefix) ? key.slice(modulePrefix.length) : key, + entry.deadline, + entry.ownerRevision, + ); + await this.runModuleAlarmHandler(moduleId, registration.key, execute); + return undefined; + }, + }); + } + const scheduledHandlers = [ + ...CHANNEL_HOST_ALARM_HANDLER_KEYS, + ...moduleAlarmHandlerEntries(modules).map(({ moduleId, registration }) => + `module:${moduleId}:${registration.key}`), + ]; + const missingHandlers = missingAlarmHandlerKeys(scheduledHandlers, handlers.keys()); + if (missingHandlers.length > 0) { + throw new Error(`Alarm handlers are not registered: ${missingHandlers.join(", ")}.`); } return handlers; } @@ -2466,6 +2478,18 @@ export class ChannelObject extends DurableObject { public async revokeUser(userId: string): Promise { for (const webSocket of this.ctx.getWebSockets(`user:${userId}`)) { + const principal = readAttachment(webSocket); + if (principal?.kind === "panel" && principal.userId === userId) { + const channelId = this.ownChannelId(); + if (channelId !== null && principal.channelId === channelId) { + try { + if (await this.panelPrincipalIsCurrent(principal, channelId, Date.now())) continue; + } catch (error: unknown) { + console.error("Realtime panel revocation check failed.", error); + throw error; + } + } + } closeSocket(webSocket, SOCKET_REVOKED_CODE, "Channel access revoked"); } await this.stopSecurityAlarmIfIdle(); diff --git a/src/worker/durable/module-alarm-registry.ts b/src/worker/durable/module-alarm-registry.ts new file mode 100644 index 00000000..d60a26f1 --- /dev/null +++ b/src/worker/durable/module-alarm-registry.ts @@ -0,0 +1,34 @@ +import type { BotModule } from "../../modules/contract"; +import { BALLOT_EXPIRY_ALARM_HANDLER, BALLOT_HARD_DELETE_ALARM_HANDLER } from "./ballots"; + +export const ACTIVE_CHATTER_EXPIRY_HANDLER = "channel.active_chatters_expiry"; +export const SECURITY_ROUND_HANDLER = "channel.security_round"; +export const SECURITY_RETRY_HANDLER = "channel.security_retry"; +export const AD_PREWARNING_HANDLER = "channel.ad_prewarning"; +export const AD_COUNTDOWN_REFRESH_HANDLER = "channel.ad_countdown_refresh"; +export const MODULE_SCHEDULE_INPUTS_CHANGED_HANDLER = "channel.module_schedule_inputs_changed"; + +// Every host alarm scheduled by ChannelObject has a matching runtime handler. +// Keep this registry beside the module registry so coverage can compare the +// scheduler's complete declared set with the handlers built at runtime. +export const CHANNEL_HOST_ALARM_HANDLER_KEYS = [ + ACTIVE_CHATTER_EXPIRY_HANDLER, + BALLOT_EXPIRY_ALARM_HANDLER, + BALLOT_HARD_DELETE_ALARM_HANDLER, + SECURITY_ROUND_HANDLER, + SECURITY_RETRY_HANDLER, + AD_PREWARNING_HANDLER, + AD_COUNTDOWN_REFRESH_HANDLER, + MODULE_SCHEDULE_INPUTS_CHANGED_HANDLER, +] as const; + +export const missingAlarmHandlerKeys = ( + scheduled: readonly string[], + registered: Iterable, +): string[] => { + const registeredKeys = new Set(registered); + return [...new Set(scheduled)].filter((key) => !registeredKeys.has(key)); +}; + +export const moduleAlarmHandlerEntries = (modules: readonly BotModule[]) => modules.flatMap((module) => + (module.alarms ?? []).map((registration) => ({ moduleId: module.id, registration }))); diff --git a/src/worker/eventsub.ts b/src/worker/eventsub.ts index 1da0549a..0030b4b4 100644 --- a/src/worker/eventsub.ts +++ b/src/worker/eventsub.ts @@ -8,6 +8,7 @@ import { refreshAdPrewarning, isAdPrewarningTrigger } from "./ad-prewarning"; import { eventSubDefinitionForCondition } from "./eventsub-subscriptions"; import { notifyCommittedResources } from "./panel-resources"; import { revokeRealtimeUserFromAllChannels } from "./realtime"; +import { clearPendingRealtimeUserRevocation, getPendingRealtimeUserRevocation } from "./db/login-identity"; import type { EventSubSubscriptionType } from "../contracts/values"; import { hasEventSubMessage, @@ -285,7 +286,17 @@ eventSubRouter.post("/api/twitch/eventsub", async (context) => { if (messageType === "revocation" && revocation !== null && notifierChannelId !== null) { try { - if (await hasEventSubMessage(context.env.DB, messageId)) return response(null, 204); + if (await hasEventSubMessage(context.env.DB, messageId)) { + const identity = revocation.authorizationIdentity; + const pendingRevocation = identity?.kind === "login" + ? await getPendingRealtimeUserRevocation(context.env.DB, identity.userId) + : null; + if (identity?.kind === "login" && pendingRevocation !== null) { + await revokeRealtimeUserFromAllChannels(context.env.DB, context.env.CHANNEL, identity.userId); + await clearPendingRealtimeUserRevocation(context.env.DB, pendingRevocation); + } + return response(null, 204); + } const authorizationConfirmedRevoked = await confirmRevocationAuthorization(context.env, revocation, now); const isNew = await rememberEventSubMessageAndRevocation( context.env.DB, @@ -296,15 +307,20 @@ eventSubRouter.post("/api/twitch/eventsub", async (context) => { ); if (!isNew) return response(null, 204); if (authorizationConfirmedRevoked && revocation.authorizationIdentity?.kind === "login") { + const pendingRevocation = await getPendingRealtimeUserRevocation( + context.env.DB, + revocation.authorizationIdentity.userId, + ); await revokeRealtimeUserFromAllChannels( context.env.DB, context.env.CHANNEL, revocation.authorizationIdentity.userId, ); + if (pendingRevocation !== null) await clearPendingRealtimeUserRevocation(context.env.DB, pendingRevocation); } return response(null, 204); } finally { - await notifyCommittedResources(context.env, notifierChannelId); + await notifyCommittedResources(context.env); } } diff --git a/src/worker/login-maintenance.ts b/src/worker/login-maintenance.ts index 57617687..5f3ea8d7 100644 --- a/src/worker/login-maintenance.ts +++ b/src/worker/login-maintenance.ts @@ -1,6 +1,9 @@ import { + getPendingRealtimeUserRevocation, getLoginIdentity, listLoginIdentities, + listPendingRealtimeUserRevocations, + clearPendingRealtimeUserRevocation, revokeLoginIdentityAndSessionsForUser, rotateLoginTokensForUser, setLoginIdentityTokenScopes, @@ -34,11 +37,29 @@ const markLoginRevoked = async ( now, ); if (revoked) { + const pending = await getPendingRealtimeUserRevocation(env.DB, identity.userId); await revokeRealtimeUserFromAllChannels(env.DB, env.CHANNEL, identity.userId); + if (pending !== null) await clearPendingRealtimeUserRevocation(env.DB, pending); await notifyCommittedResources(env); } }; +const retryPendingRealtimeUserRevocations = async (env: Env): Promise => { + const pendingRevocations = await listPendingRealtimeUserRevocations(env.DB); + for (const revocation of pendingRevocations) { + try { + await revokeRealtimeUserFromAllChannels(env.DB, env.CHANNEL, revocation.userId); + await clearPendingRealtimeUserRevocation(env.DB, revocation); + } catch (error: unknown) { + logMaintenanceError({ + channelId: revocation.userId, + subscriptionType: "login-identity", + variant: "realtime-revocation", + }, error); + } + } +}; + const rotateLoginTokensWithRetry = async ( db: D1Database, userId: string, @@ -243,6 +264,7 @@ const maintainLoginIdentity = async (env: Env, identity: Awaited => { try { + await retryPendingRealtimeUserRevocations(env); const identities = await listLoginIdentities(env.DB, now); const concurrency = 4; let nextIndex = 0; diff --git a/tests/unit/api-source.test.ts b/tests/unit/api-source.test.ts index 5dfe94e5..3e8b8def 100644 --- a/tests/unit/api-source.test.ts +++ b/tests/unit/api-source.test.ts @@ -21,6 +21,7 @@ import { apiSourceRoutes } from "../../src/modules/api_source/routes"; import { textLibraryModule } from "../../src/modules/text_library"; import { createTemplateRenderer, type TemplateResolverSources } from "../../src/worker/template-resolver"; import { createTextBlockTemplateValueProvider } from "../../src/modules/text_library/adapters/template-expander"; +import { readPanelResourceRevisions } from "../../src/worker/panel-resources"; import { insertChannel } from "./fixtures"; import { TestD1Database } from "./test-d1"; @@ -114,10 +115,13 @@ describe("API source URL policy", () => { describe("API source outbound fetch", () => { it("uses only fixed headers, omits credentials, and caches successful JSON by channel and URL", async () => { const database = await createDatabase(); + await insertChannel(database, "api-source-cache-unrelated"); const payload = { value: 42 }; const fetcher = vi.fn().mockResolvedValue(jsonResponse(payload, { "Cache-Control": "max-age=60" })); const budget = sharedBudget(); const db = database as unknown as D1Database; + const ownerRevisionBefore = await readPanelResourceRevisions(db, CHANNEL_ID); + const unrelatedRevisionBefore = await readPanelResourceRevisions(db, "api-source-cache-unrelated"); const first = await fetchCachedApiSourceJson(db, CHANNEL_ID, "https://api.sample-provider.net/data", undefined, NOW, budget, fetcher); const second = await fetchCachedApiSourceJson(db, CHANNEL_ID, "https://api.sample-provider.net/data", undefined, NOW + 1_000, budget, fetcher); const [, request] = fetcher.mock.calls[0] ?? []; @@ -129,6 +133,12 @@ describe("API source outbound fetch", () => { expect([...new Headers(request?.headers).keys()].sort((a, b) => a.localeCompare(b))).toEqual(["accept", "user-agent"]); expect(request).toMatchObject({ method: "GET", redirect: "manual", credentials: "omit", referrerPolicy: "no-referrer" }); expect(budget.count()).toBe(1); + const cachedOwner = await db.prepare("SELECT channel_id FROM api_source_cache").first<{ channel_id: string }>(); + expect(cachedOwner?.channel_id).toBe(CHANNEL_ID); + expect((await readPanelResourceRevisions(db, CHANNEL_ID))["channel.library"]) + .toBeGreaterThan(ownerRevisionBefore["channel.library"] ?? 0); + expect((await readPanelResourceRevisions(db, "api-source-cache-unrelated"))["channel.library"]) + .toBe(unrelatedRevisionBefore["channel.library"]); }); it("keeps the same upstream URL isolated between channels", async () => { diff --git a/tests/unit/bot-maintenance.test.ts b/tests/unit/bot-maintenance.test.ts index 41649433..38509b45 100644 --- a/tests/unit/bot-maintenance.test.ts +++ b/tests/unit/bot-maintenance.test.ts @@ -104,7 +104,11 @@ const makeMaintenanceEnvironment = async ( ...identity, created_at: "2026-09-17T00:00:00.000Z", } : null), - all: vi.fn().mockResolvedValue({ results: [{ channel_id: "channel-1" }] }), + all: vi.fn().mockImplementation(() => sql.includes("pending_realtime_user_revocations") + ? { results: [] } + : sql.includes("FROM channel_members") + ? { results: [{ channel_id: "channel-1" }] } + : { results: [] }), run: vi.fn().mockImplementation(async () => { if (sql.includes("UPDATE bot_identity") && sql.includes("missing_scopes_json")) { const [missingScopes, expectedAccess, expectedRefresh] = values; diff --git a/tests/unit/eventsub.test.ts b/tests/unit/eventsub.test.ts index 0c83c6bc..2052933a 100644 --- a/tests/unit/eventsub.test.ts +++ b/tests/unit/eventsub.test.ts @@ -89,6 +89,15 @@ const botRevocationBody = (subscriptionId: string): string => JSON.stringify({ }, }); +const loginRevocationBody = (userId: string, subscriptionId: string): string => JSON.stringify({ + subscription: { + id: subscriptionId, + type: "channel.ad_break.begin", + status: "authorization_revoked", + condition: { broadcaster_user_id: userId }, + }, +}); + const insertBotIdentity = async (database: TestD1Database, userId = "777"): Promise => { const accessTokenCiphertext = await encryptJson({ token: "bot-access" }, parseKeyRing(encryptionKeys)); const refreshTokenCiphertext = await encryptJson({ token: "bot-refresh" }, parseKeyRing(encryptionKeys)); @@ -142,12 +151,13 @@ const failingBatchDatabase = (database: TestD1Database): D1Database => ({ describe("EventSub inbound", () => { let database: TestD1Database; const secret = secretRing(3); - const environment = () => ({ + const environment = (channel?: Env["CHANNEL"]) => ({ DB: database as unknown as D1Database, TWITCH_EVENTSUB_SECRET: secret, TWITCH_CLIENT_ID: "client-id", TWITCH_CLIENT_SECRET: "client-secret", TOKEN_ENCRYPTION_KEYS: encryptionKeys, + CHANNEL: channel, } as unknown as Env); beforeEach(() => { @@ -466,6 +476,89 @@ describe("EventSub inbound", () => { expect(row).toEqual(firstRow); }); + it("retries committed login socket revocation across affected channels without clearing a newer generation", async () => { + await insertChannel(database, "login-user-200"); + await insertChannel(database, "login-member-channel"); + await insertChannel(database, "login-audit-channel"); + await insertLoginIdentityAndSession(database, "login-user-200", ["channel:read:ads"]); + const identityKeys = parseKeyRing(encryptionKeys); + const accessCiphertext = await encryptJson({ token: "login-access" }, identityKeys); + const refreshCiphertext = await encryptJson({ token: "login-refresh" }, identityKeys); + await database.prepare( + "UPDATE twitch_login_identity SET access_token_ciphertext = ?, refresh_token_ciphertext = ? WHERE user_id = 'login-user-200'", + ).bind(accessCiphertext, refreshCiphertext).run(); + await database.prepare( + `INSERT INTO channel_members (channel_id, user_id, role, created_at, updated_at) + VALUES ('login-member-channel', 'login-user-200', 'operator', '2026-09-19T00:00:00.000Z', '2026-09-19T00:00:00.000Z')`, + ).run(); + await database.prepare( + `INSERT INTO audit_log (audit_id, actor_user_id, created_at, channel_id, action, before_json, after_json) + VALUES ('login-audit-entry', 'login-user-200', '2026-09-19T00:00:00.000Z', 'login-audit-channel', 'channel.updated', '{}', '{}')`, + ).run(); + await database.prepare("DELETE FROM panel_resource_pending").run(); + vi.stubGlobal("fetch", invalidTokenFetcher()); + const reconciledChannels: string[] = []; + let newerGenerationAdded = false; + const revokeUser = vi.fn() + .mockRejectedValueOnce(new Error("channel close failed")) + .mockImplementation(async () => { + if (newerGenerationAdded) return; + newerGenerationAdded = true; + await database.prepare( + `INSERT INTO pending_realtime_user_revocations (user_id, requested_at, generation) + VALUES ('login-user-200', '2026-09-19T10:00:01.000Z', 1) + ON CONFLICT(user_id) DO UPDATE SET + requested_at = excluded.requested_at, + generation = pending_realtime_user_revocations.generation + 1`, + ).run(); + }); + const channel = { + idFromName: (channelId: string) => channelId, + get: (channelId: string) => ({ + revokeUser, + reconcilePanelResources: () => { reconciledChannels.push(channelId); return Promise.resolve(); }, + }), + } as unknown as Env["CHANNEL"]; + const body = loginRevocationBody("login-user-200", "login-retry-close"); + + const first = await eventSubRouter.fetch( + signedRequest(secret, "revocation", body, "message-login-retry-close"), + environment(channel), + ); + + expect(first.status).toBe(500); + await expect(database.prepare( + "SELECT user_id FROM pending_realtime_user_revocations WHERE user_id = 'login-user-200'", + ).first()).resolves.toEqual({ user_id: "login-user-200" }); + + const second = await eventSubRouter.fetch( + signedRequest(secret, "revocation", body, "message-login-retry-close"), + environment(channel), + ); + + expect(second.status).toBe(204); + expect(revokeUser).toHaveBeenCalledTimes(2); + await expect(database.prepare( + "SELECT user_id, generation FROM pending_realtime_user_revocations WHERE user_id = 'login-user-200'", + ).first()).resolves.toEqual({ user_id: "login-user-200", generation: 2 }); + expect(new Set(reconciledChannels)).toEqual(new Set([ + "login-user-200", + "login-member-channel", + "login-audit-channel", + ])); + + const third = await eventSubRouter.fetch( + signedRequest(secret, "revocation", body, "message-login-retry-close"), + environment(channel), + ); + + expect(third.status).toBe(204); + expect(revokeUser).toHaveBeenCalledTimes(3); + await expect(database.prepare( + "SELECT user_id FROM pending_realtime_user_revocations WHERE user_id = 'login-user-200'", + ).first()).resolves.toBeNull(); + }); + it("locks the bot identity on a confirmed revocation of the moderator subscription", async () => { await insertChannel(database, "200"); await insertLoginIdentityAndSession(database, "200", ["channel:read:ads"]); diff --git a/tests/unit/login-maintenance.test.ts b/tests/unit/login-maintenance.test.ts index c5e66fb7..2ba54e8e 100644 --- a/tests/unit/login-maintenance.test.ts +++ b/tests/unit/login-maintenance.test.ts @@ -25,6 +25,7 @@ const makeEnvironment = async ( onSuccessfulTokenWrite?: () => void; memberChannelIds?: readonly string[]; revokeUser?: (userId: string) => Promise; + pendingRealtimeUserRevocations?: readonly string[]; } = {}, ) => { const keys = parseKeyRing(keyRingSerialized); @@ -49,6 +50,10 @@ const makeEnvironment = async ( revoked_at: null as string | null, revocation_reason: null as string | null, }]; + const pendingRealtimeRevocations = new Map(options.pendingRealtimeUserRevocations?.map((userId) => [userId, { + requested_at: "2026-09-17T00:00:00.000Z", + generation: 1, + }]) ?? []); const bind = vi.fn().mockReturnThis(); let tokenWriteAttempts = 0; const prepare = vi.fn((sql: string) => { @@ -60,12 +65,42 @@ const makeEnvironment = async ( return statement; }), all: vi.fn().mockImplementation(() => - sql.includes("FROM twitch_login_identity") - ? { results: [row] } + sql.includes("FROM pending_realtime_user_revocations") + ? { results: [...pendingRealtimeRevocations].map(([user_id, pending]) => ({ user_id, ...pending })) } + : sql.includes("FROM twitch_login_identity") + ? { results: row.status === "revoked" ? [] : [row] } : sql.includes("FROM channel_members") ? { results: (options.memberChannelIds ?? []).map((channel_id) => ({ channel_id })) } - : { results: [] }), + : { results: [] }), + first: vi.fn().mockImplementation(() => { + if (!sql.includes("FROM pending_realtime_user_revocations")) return null; + const pending = pendingRealtimeRevocations.get(String(values[0])); + return pending === undefined ? null : { + user_id: String(values[0]), + ...pending, + }; + }), run: vi.fn().mockImplementation(async () => { + if (sql.includes("INSERT INTO pending_realtime_user_revocations")) { + const userId = String(values[0]); + const previous = pendingRealtimeRevocations.get(userId); + pendingRealtimeRevocations.set(userId, { + requested_at: typeof values[2] === "string" + ? values[2] + : previous?.requested_at ?? "2026-09-17T00:00:00.000Z", + generation: (previous?.generation ?? 0) + 1, + }); + return { success: true, meta: { changes: 1 } }; + } + if (sql.includes("DELETE FROM pending_realtime_user_revocations")) { + const userId = String(values[0]); + const pending = pendingRealtimeRevocations.get(userId); + if (pending !== undefined && pending.requested_at === values[1] && pending.generation === values[2]) { + pendingRealtimeRevocations.delete(userId); + return { success: true, meta: { changes: 1 } }; + } + return { success: true, meta: { changes: 0 } }; + } if (sql.includes("UPDATE twitch_login_identity") && sql.includes("SET token_scopes_json")) { const [scopesJson, updatedAt, userId] = values; if (row.user_id !== userId) return { success: true, meta: { changes: 0 } }; @@ -187,6 +222,7 @@ const makeEnvironment = async ( getTokenWriteAttempts: () => tokenWriteAttempts, read: () => row, readSessions: () => sessions, + pendingRealtimeRevocations, getInitialAccessTokenCiphertext: () => initialAccessTokenCiphertext, getInitialRefreshTokenCiphertext: () => initialRefreshTokenCiphertext, }; @@ -415,8 +451,10 @@ describe("Login token maintenance", () => { }); it("surfaces a failed socket close after invalid_grant commits the identity revocation", async () => { - const revokeUser = vi.fn(() => Promise.reject(new Error("channel close failed"))); - const { environment, read } = await makeEnvironment("2026-09-18T00:59:59.000Z", { + const revokeUser = vi.fn() + .mockRejectedValueOnce(new Error("channel close failed")) + .mockResolvedValue(undefined); + const { environment, read, pendingRealtimeRevocations } = await makeEnvironment("2026-09-18T00:59:59.000Z", { memberChannelIds: ["channel-a"], revokeUser, }); @@ -436,6 +474,31 @@ describe("Login token maintenance", () => { expect(read().status).toBe("revoked"); expect(revokeUser).toHaveBeenCalledWith("user-1"); + expect([...pendingRealtimeRevocations.keys()]).toEqual(["user-1"]); + + await maintainLoginIdentities(environment, "2026-09-18T00:01:00.000Z"); + + expect(revokeUser).toHaveBeenCalledTimes(2); + expect(pendingRealtimeRevocations.size).toBe(0); + }); + + it("continues retrying later users when one pending socket close fails", async () => { + const revokeUser = vi.fn((userId: string) => userId === "user-1" + ? Promise.reject(new Error("first user close failed")) + : Promise.resolve()); + const { environment, pendingRealtimeRevocations } = await makeEnvironment( + "2099-09-18T00:59:59.000Z", + { + memberChannelIds: ["channel-a"], + pendingRealtimeUserRevocations: ["user-1", "user-2"], + revokeUser, + }, + ); + + await maintainLoginIdentities(environment, "2026-09-18T00:00:00.000Z"); + + expect(revokeUser.mock.calls.map(([userId]) => userId)).toEqual(["user-1", "user-2"]); + expect([...pendingRealtimeRevocations.keys()]).toEqual(["user-1"]); }); it("refreshes exactly once even after a 401 when expiry is still far off", async () => { diff --git a/tests/unit/panel-resource-revisions.test.ts b/tests/unit/panel-resource-revisions.test.ts index 343ecf57..8a7b7af6 100644 --- a/tests/unit/panel-resource-revisions.test.ts +++ b/tests/unit/panel-resource-revisions.test.ts @@ -8,6 +8,7 @@ import { MODULES } from "../../src/modules/registry"; import { dashboardDataKeys, queryKeys } from "../../src/dashboard/data/keys"; import { reconcileDashboardRealtimeMessage, reconcileDashboardPanelResourceRevisions } from "../../src/dashboard/data/realtime"; import { moduleQueryKey } from "../../src/dashboard/data/module-query"; +import { loadTextLibrary } from "../../src/modules/text_library/panel/service"; import { authRouter } from "../../src/worker/auth/routes"; import { eventSubChannelIdForNotification, eventSubRouter } from "../../src/worker/eventsub"; import { realtimeRouter } from "../../src/worker/realtime"; @@ -44,10 +45,16 @@ const routeHasNotifier = ( explicitRoutes: ReadonlySet, knownWritingGets: ReadonlySet = writingGetRoutes, nonPanelWrites: ReadonlySet = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.nonPanelWriteRoutes), -): boolean => !routeNeedsNotifier(route, knownWritingGets) || explicitRoutes.has(`${route.method} ${route.path}`) || - nonPanelWrites.has(`${route.method} ${route.path}`) || - nonPanelAllRoutes.has(`${route.method} ${route.path}`) || - patterns.some((pattern) => routeIsCovered(route.path, pattern)); + nonPanelReads: ReadonlySet = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.nonPanelGetRoutes), +): boolean => { + const routeKey = `${route.method} ${route.path}`; + if (explicitRoutes.has(routeKey) || nonPanelWrites.has(routeKey) || nonPanelAllRoutes.has(routeKey)) return true; + if (route.method === "GET") { + return nonPanelReads.has(route.path) || routeIsCovered(route.path, PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes); + } + if (!routeNeedsNotifier(route, knownWritingGets)) return true; + return patterns.some((pattern) => routeIsCovered(route.path, pattern)); +}; const addChannel = async (database: TestD1Database, channelId: string): Promise => { await database.prepare( @@ -166,7 +173,7 @@ const panelWriteRegressions: readonly PanelWriteRegressionCase[] = [ queryKeys.channel(id, "modules"), queryKeys.channel(id, "overview"), queryKeys.channels(), dashboardDataKeys.system(id), moduleQueryKey(id, "text_library", "library"), dashboardDataKeys.variables(id), moduleQueryKey(id, "chat_voting", "settings"), - moduleQueryKey(id, "text_commands", "commands"), + moduleQueryKey(id, "chat_voting", "panel"), moduleQueryKey(id, "text_commands", "commands"), ], write: async (database, id) => { await database.prepare( @@ -311,6 +318,18 @@ describe("panel resource revisions", () => { explicitRoutes, new Set([...writingGetRoutes, unwrappedWritingGet.path]), )).toBe(false); + const unnotifiedPlatformWritingGet = { method: "GET", path: "/api/platform/writing-get" }; + expect(routeHasNotifier( + unnotifiedPlatformWritingGet, + routePatterns, + explicitRoutes, + new Set([...writingGetRoutes, unnotifiedPlatformWritingGet.path]), + )).toBe(false); + expect(routeHasNotifier( + { method: "GET", path: "/api/platform/unclassified-read" }, + routePatterns, + explicitRoutes, + )).toBe(false); const moduleMutations = MODULES.flatMap((module) => module.routes?.routes ?? []) .filter(({ method }) => writeMethods.has(method)); @@ -345,6 +364,7 @@ describe("panel resource revisions", () => { expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.eventSubHandler).toBe("eventSubRouter notification/revocation finally"); const channelObject = readFileSync(resolve(root, "src/worker/durable/ChannelObject.ts"), "utf8"); + expect(channelObject).toContain("moduleAlarmHandlerEntries(modules)"); const alarm = /override async alarm\(\): Promise\s+\{([\s\S]*?)\n\s+\}\n\n\s+override webSocketMessage/u.exec(channelObject)?.[1] ?? ""; expect(alarm).toMatch(/finally \{\s*await this\.notifyCommittedPanelResources\(\);/u); expect(alarm).toContain("this.alarmHandlers(MODULES"); @@ -400,7 +420,7 @@ describe("panel resource revisions", () => { expect(memberRoutes).toMatch(/if \(!changed\) return[\s\S]*?await revokeRealtimeUser\(context\.env\.CHANNEL, channelId, userId\);/u); expect(platformRoutes).toMatch(/if \(!changed\) return[\s\S]*?await revokeRealtimeUser\(context\.env\.CHANNEL, channelId, userId\);/u); expect(authSource).toMatch(/await revokeSession\(context\.env\.DB, session\.sessionId, now, "logout"\);\s*await revokeRealtimeSessionForUser/u); - expect(loginMaintenance).toMatch(/if \(revoked\) \{\s*await revokeRealtimeUserFromAllChannels/u); + expect(loginMaintenance).toMatch(/if \(revoked\) \{\s*const pending = await getPendingRealtimeUserRevocation[\s\S]*?await revokeRealtimeUserFromAllChannels/u); }); it("does not enumerate channels for an unauthenticated OAuth callback", async () => { @@ -544,6 +564,54 @@ describe("panel resource revisions", () => { } }); + it("reloads the production text-library query after timezone and location changes", async () => { + const database = new TestD1Database(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const channelId = "library-settings-revisions"; + const queryKey = moduleQueryKey(channelId, "text_library", "library"); + let unsubscribe: (() => void) | undefined; + const requestUrl = (input: RequestInfo | URL): string => + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + try { + await addChannel(database, channelId); + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url.endsWith("/revisions")) { + return new Response(JSON.stringify({ revisions: await readPanelResourceRevisions(database as unknown as D1Database, channelId) }), { status: 200 }); + } + if (url.includes("/modules/text_library/")) { + return new Response(JSON.stringify({ blocks: [], categories: [], usage: {} }), { status: 200 }); + } + if (url.endsWith("/template-variables")) return new Response(JSON.stringify({ variables: [] }), { status: 200 }); + if (url.endsWith("/settings")) return new Response(JSON.stringify({ timeZone: "UTC", revision: 0 }), { status: 200 }); + throw new Error(`Unexpected library fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetcher); + await reconcileDashboardPanelResourceRevisions(queryClient, channelId); + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: ({ signal }) => loadTextLibrary(channelId, signal), + staleTime: Infinity, + }); + unsubscribe = observer.subscribe(() => undefined); + const libraryRequestCount = (): number => fetcher.mock.calls.filter(([input]) => requestUrl(input).includes("/modules/text_library/")).length; + await vi.waitFor(() => { expect(libraryRequestCount()).toBe(1); }); + + await database.prepare("UPDATE channels SET location_time_zone = 'Europe/Berlin' WHERE channel_id = ?").bind(channelId).run(); + await reconcileDashboardPanelResourceRevisions(queryClient, channelId); + await vi.waitFor(() => { expect(libraryRequestCount()).toBe(2); }); + + await database.prepare("UPDATE channels SET location_name = 'Berlin', location_latitude = 52.5, location_longitude = 13.4 WHERE channel_id = ?").bind(channelId).run(); + await reconcileDashboardPanelResourceRevisions(queryClient, channelId); + await vi.waitFor(() => { expect(libraryRequestCount()).toBe(3); }); + } finally { + unsubscribe?.(); + queryClient.clear(); + vi.unstubAllGlobals(); + database.close(); + } + }); + it.each(panelWriteRegressions)("$name refreshes each affected query on the second client", async (scenario) => { const database = new TestD1Database(); const channelId = "revision-two-client"; @@ -795,6 +863,8 @@ describe("panel resource revisions", () => { `INSERT INTO audit_log (audit_id, actor_user_id, created_at, channel_id, action, before_json, after_json) VALUES ('identity-audit', 'identity-owner', '2026-10-10T00:00:00.000Z', 'identity-member-channel', 'channel.updated', '{}', '{}')`, ).run(); + await database.prepare("DELETE FROM channel_members WHERE channel_id = 'identity-member-channel' AND user_id = 'identity-owner'").run(); + await database.prepare("DELETE FROM panel_resource_pending").run(); await database.prepare( `INSERT INTO twitch_login_identity (user_id, login, scopes_json, access_token_ciphertext, refresh_token_ciphertext, expires_at, status, created_at, updated_at) @@ -810,7 +880,7 @@ describe("panel resource revisions", () => { expect(ownerAfter["channel.modules"]).toBeGreaterThan(ownerBefore["channel.modules"] ?? 0); expect(ownerAfter["module:chat_voting:settings"]).toBeGreaterThan(ownerBefore["module:chat_voting:settings"] ?? 0); expect(ownerAfter["module:ads:schedule"]).toBeGreaterThan(ownerBefore["module:ads:schedule"] ?? 0); - expect(memberAfter["channel.members"]).toBeGreaterThan(memberBefore["channel.members"] ?? 0); + expect(memberAfter["channel.members"]).toBe(memberBefore["channel.members"]); expect(memberAfter["channel.audit"]).toBeGreaterThan(memberBefore["channel.audit"] ?? 0); } finally { database.close(); @@ -885,6 +955,7 @@ describe("panel resource revisions", () => { "panel_resource_dependencies", "panel_resource_pending", "panel_resource_revisions", + "pending_realtime_user_revocations", "text_command_user_cooldowns", "twitch_app_access_token", ]); diff --git a/tests/unit/sql-contract.test.ts b/tests/unit/sql-contract.test.ts index 68c33372..aef4b4a1 100644 --- a/tests/unit/sql-contract.test.ts +++ b/tests/unit/sql-contract.test.ts @@ -191,10 +191,10 @@ describe("SQL contract", () => { const objects = database.prepare( "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY tbl_name, type DESC, name", ).all() as unknown as SchemaObject[]; - expect(objects.filter((object) => object.type === "table")).toHaveLength(55); - expect(objects.filter((object) => object.type === "index")).toHaveLength(44); + expect(objects.filter((object) => object.type === "table")).toHaveLength(56); + expect(objects.filter((object) => object.type === "index")).toHaveLength(45); expect(objects.filter((object) => object.type === "trigger")).toHaveLength(124); - expect(objects).toHaveLength(223); + expect(objects).toHaveLength(225); const tableColumns = (table: string): Set => new Set( (database.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(({ name }) => name), diff --git a/tests/worker/channel-object.test.ts b/tests/worker/channel-object.test.ts index 4ceba3a1..783fd845 100644 --- a/tests/worker/channel-object.test.ts +++ b/tests/worker/channel-object.test.ts @@ -50,9 +50,18 @@ import { belaboxModule } from "../../src/modules/belabox"; import { BELABOX_DEFAULT_SETTINGS, BELABOX_STATS_URL_SECRET } from "../../src/modules/belabox/contracts"; import { getBelaboxStatus } from "../../src/modules/belabox/adapters/d1"; import { votekickModule } from "../../src/modules/votekick"; -import { chatVotingStartAnnouncementAlarmKey } from "../../src/modules/chat_voting/contracts"; +import { MODULES } from "../../src/modules/registry"; +import { + CHAT_VOTING_ALARM_HANDLER, + chatVotingStartAnnouncementAlarmKey, +} from "../../src/modules/chat_voting/contracts"; import { ChannelObject } from "../../src/worker/durable/ChannelObject"; import { ballotVoterHash } from "../../src/worker/durable/ballots"; +import { + CHANNEL_HOST_ALARM_HANDLER_KEYS, + missingAlarmHandlerKeys, + moduleAlarmHandlerEntries, +} from "../../src/worker/durable/module-alarm-registry"; import { REALTIME_PRINCIPAL_HEADER, REALTIME_PROTOCOL } from "../../src/worker/realtime-protocol"; import { jsonResponse } from "../unit/fixtures"; @@ -3242,7 +3251,10 @@ describe("ChannelObject realtime path", () => { it("revokes only the connection of the affected user", async () => { const affected = socketFor(validPrincipal()); const other = socketFor(validPrincipal({ userId: "user-2", sessionId: "session-2" })); - const object = objectFor([affected, other]); + const database = { + prepare: () => ({ bind: () => ({ first: () => Promise.resolve(null) }) }), + } as unknown as D1Database; + const object = objectFor([affected, other], database); await object.revokeUser("user-1"); @@ -3250,6 +3262,50 @@ describe("ChannelObject realtime path", () => { expect(other.close.mock.calls).toHaveLength(0); }); + it("does not let a delayed user revocation close a newly authorized session", async () => { + const oldSession = socketFor(validPrincipal({ sessionId: "old-session" })); + const newSession = socketFor(validPrincipal({ sessionId: "new-session" })); + let bindings: unknown[] = []; + const statement = { + bind: vi.fn((...values: unknown[]) => { + bindings = values; + return statement; + }), + first: vi.fn(() => Promise.resolve(bindings[1] === "new-session" ? { + session_id: "new-session", + user_id: "user-1", + role: "operator", + } : null)), + }; + const database = { prepare: vi.fn(() => statement) } as unknown as D1Database; + const object = objectFor([oldSession, newSession], database); + + await object.revokeUser("user-1"); + + expect(oldSession.close.mock.calls).toEqual([[4003, "Channel access revoked"]]); + expect(newSession.close.mock.calls).toHaveLength(0); + }); + + it("checks the runtime alarm registry against scheduled host and module handlers", () => { + const object = objectFor([]); + const runtimeHandlers = (object as unknown as { + alarmHandlers: (modules: readonly BotModule[]) => Map; + }).alarmHandlers(MODULES); + const scheduledModuleHandlers = moduleAlarmHandlerEntries(MODULES) + .map(({ moduleId, registration }) => `module:${moduleId}:${registration.key}`); + const scheduledHandlers = [...CHANNEL_HOST_ALARM_HANDLER_KEYS, ...scheduledModuleHandlers]; + + expect(missingAlarmHandlerKeys(scheduledHandlers, runtimeHandlers.keys())).toEqual([]); + expect(missingAlarmHandlerKeys( + scheduledHandlers, + [...runtimeHandlers.keys()].filter((key) => key !== `module:chat_voting:${CHAT_VOTING_ALARM_HANDLER}`), + )).toContain(`module:chat_voting:${CHAT_VOTING_ALARM_HANDLER}`); + expect(missingAlarmHandlerKeys( + scheduledHandlers, + [...runtimeHandlers.keys()].filter((key) => key !== "channel.security_round"), + )).toContain("channel.security_round"); + }); + it("keeps two deadlines separate, processes the earlier one and keeps the later one", async () => { vi.useFakeTimers(); const jetzt = Date.parse("2026-09-21T12:00:00.000Z"); diff --git a/tests/worker/healthz.test.ts b/tests/worker/healthz.test.ts index 612b50ba..6f15e849 100644 --- a/tests/worker/healthz.test.ts +++ b/tests/worker/healthz.test.ts @@ -6,6 +6,7 @@ import { hashOverlayToken } from "../../src/worker/auth/crypto"; import type { ModuleTemplateValueContext } from "../../src/modules/contract"; import { sunModule, resolveSunTemplateValues } from "../../src/modules/sun"; import { readChannelLocation } from "../../src/worker/db/channel-settings"; +import { LATEST_SCHEMA_MIGRATION } from "../../src/worker/config"; /** * The worker test environment starts with an empty database. Since #43, the @@ -151,7 +152,7 @@ describe("worker skeleton", () => { it("checks the schema by highest migration number, not by application order", async () => { const database = (env as unknown as { DB: D1Database }).DB; const status = async () => (await exports.default.fetch(new Request("http://localhost/healthz"))).status; - const sentinel: string = "0048_committed_panel_resource_delivery.sql"; + const sentinel: string = LATEST_SCHEMA_MIGRATION; const original = (await database.prepare("SELECT name FROM d1_migrations WHERE name LIKE '0040_%' OR name = ?") .bind(sentinel).all<{ name: string }>()).results.map((row) => row.name); const reinsert = async (names: string[]): Promise => { @@ -162,7 +163,7 @@ describe("worker skeleton", () => { } }; try { - // 0040 applied last (highest id) but the highest number is still 0048. + // 0040 applied last (highest id) but the highest migration number still decides health. await reinsert(original.filter((name) => name === sentinel).concat(original.filter((name) => name !== sentinel))); expect(await status()).toBe(200); From c8bbb7b231605f9a6f8d565cde5014e265240589 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 16:51:16 +0200 Subject: [PATCH 10/16] =?UTF-8?q?=F0=9F=90=9B=20fix(realtime):=20publish?= =?UTF-8?q?=20all=20queued=20channels,=20retry=20failed=20refreshes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #387 - Publish all queued channel revisions after authenticated channel commits, with unauthenticated fanout guarded. - Retry revision reads and affected query refreshes with capped backoff; cancel pending retries on socket disposal and auth renewal. - Preserve per-key trailing refresh ordering and cover cross-channel delivery, retry recovery, and teardown. --- src/dashboard/data/realtime.ts | 410 ++++++++++++++++---- src/dashboard/realtime.ts | 3 + src/worker/app-routes.ts | 2 +- tests/unit/dashboard-realtime.test.ts | 111 ++++++ tests/unit/panel-resource-revisions.test.ts | 85 +++- 5 files changed, 543 insertions(+), 68 deletions(-) diff --git a/src/dashboard/data/realtime.ts b/src/dashboard/data/realtime.ts index 0f00e67c..24700cb9 100644 --- a/src/dashboard/data/realtime.ts +++ b/src/dashboard/data/realtime.ts @@ -8,11 +8,21 @@ import { dispatchDashboardAuthenticationRequired } from "./events"; const REALTIME_REFRESH_INTERVAL_MS = 1_000; const PANEL_REVISION_READ_INTERVAL_MS = 250; +const REALTIME_RETRY_INITIAL_DELAY_MS = 500; +const REALTIME_RETRY_MAX_DELAY_MS = 30_000; interface ScheduledRealtimeRefresh { lastStartedAt: number; pending: boolean; + running: boolean; + retryCount: number; + retryWaiting: boolean; timer: ReturnType | null; + promise: Promise | null; + resolve: ((refreshed: boolean) => void) | null; + queryClient: QueryClient; + queryKey: readonly unknown[]; + retryChannelId: string | null; } const scheduledRefreshes = new WeakMap>(); @@ -20,11 +30,115 @@ const panelRevisionSnapshots = new WeakMap | null; } const panelRevisionFetches = new WeakMap>(); const revisionFetchWasDirtied = (state: PanelRevisionFetch): boolean => state.dirty; +interface RealtimeRetryWaiter { + queryClient: QueryClient; + resume: () => void; + cancel: () => void; +} + +const realtimeRetryWaiters = new Map>(); +const realtimeRetryWaitersByClient = new WeakMap void>>>(); +const realtimeRetryGenerations = new WeakMap>(); + +const realtimeRetryGeneration = (queryClient: QueryClient, channelId: string): number => + realtimeRetryGenerations.get(queryClient)?.get(channelId) ?? 0; + +export const cancelDashboardRealtimeRetries = (queryClient: QueryClient, channelId: string): void => { + let generations = realtimeRetryGenerations.get(queryClient); + if (generations === undefined) { + generations = new Map(); + realtimeRetryGenerations.set(queryClient, generations); + } + generations.set(channelId, realtimeRetryGeneration(queryClient, channelId) + 1); + + const revisionFetches = panelRevisionFetches.get(queryClient); + revisionFetches?.delete(channelId); + + const refreshes = scheduledRefreshes.get(queryClient); + if (refreshes !== undefined) { + for (const [id, state] of refreshes) { + if (state.retryChannelId !== channelId) continue; + if (state.timer !== null) clearTimeout(state.timer); + state.timer = null; + state.pending = false; + state.retryWaiting = false; + state.resolve?.(false); + state.resolve = null; + state.promise = null; + refreshes.delete(id); + } + } + + for (const cancel of [...(realtimeRetryWaitersByClient.get(queryClient)?.get(channelId) ?? [])]) cancel(); +}; + +const retryBackoffDelay = (retryCount: number): number => + Math.min(REALTIME_RETRY_INITIAL_DELAY_MS * (2 ** Math.min(16, Math.max(0, retryCount - 1))), REALTIME_RETRY_MAX_DELAY_MS); + +const waitForRealtimeRetry = ( + queryClient: QueryClient, + channelId: string, + delay: number, + generation: number, +): Promise => new Promise((resolve) => { + let settled = false; + let timer: ReturnType | null = null; + let channelWaiters: Set | null = null; + const removeWaiter = (): void => { + if (channelWaiters !== null) { + channelWaiters.delete(waiter); + if (channelWaiters.size === 0) realtimeRetryWaiters.delete(channelId); + channelWaiters = null; + } + const clientChannels = realtimeRetryWaitersByClient.get(queryClient); + const clientWaiters = clientChannels?.get(channelId); + clientWaiters?.delete(cancel); + if (clientWaiters?.size === 0) clientChannels?.delete(channelId); + }; + const finish = (connected: boolean): void => { + if (settled) return; + settled = true; + if (timer !== null) clearTimeout(timer); + timer = null; + removeWaiter(); + resolve(connected && realtimeRetryGeneration(queryClient, channelId) === generation); + }; + const cancel = (): void => { finish(false); }; + const waiter: RealtimeRetryWaiter = { queryClient, resume: () => { finish(true); }, cancel }; + + let clientChannels = realtimeRetryWaitersByClient.get(queryClient); + if (clientChannels === undefined) { + clientChannels = new Map(); + realtimeRetryWaitersByClient.set(queryClient, clientChannels); + } + let clientWaiters = clientChannels.get(channelId); + if (clientWaiters === undefined) { + clientWaiters = new Set(); + clientChannels.set(channelId, clientWaiters); + } + clientWaiters.add(cancel); + + timer = setTimeout(() => { + timer = null; + if (realtimeRetryGeneration(queryClient, channelId) !== generation) { + finish(false); + return; + } + if (getDashboardRealtimeStatus(channelId) === "connected") { + finish(true); + return; + } + channelWaiters = realtimeRetryWaiters.get(channelId) ?? new Set(); + realtimeRetryWaiters.set(channelId, channelWaiters); + channelWaiters.add(waiter); + }, delay); +}); export type DashboardRealtimeStatus = "connecting" | "connected" | "reconnecting" | "offline" | "renew"; @@ -38,6 +152,9 @@ export const setDashboardRealtimeStatus = (channelId: string, status: DashboardR if (statuses.get(channelId) === status) return; statuses.set(channelId, status); for (const subscriber of subscribers.get(channelId) ?? []) subscriber(); + if (status === "connected") { + for (const waiter of [...(realtimeRetryWaiters.get(channelId) ?? [])]) waiter.resume(); + } }; export const useDashboardRealtimeStatus = (channelId: string): DashboardRealtimeStatus => { @@ -60,93 +177,126 @@ export const useDashboardRealtimeStatus = (channelId: string): DashboardRealtime const invalidate = ( queryClient: QueryClient, queryKey: readonly unknown[], - scheduledQueryKeys?: Set, + scheduledQueryRefreshes?: Map>, + resourceRefreshes?: Promise[], + retryChannelId?: string, ): void => { const matchingQueries = queryClient.getQueryCache().findAll({ queryKey }); void queryClient.invalidateQueries({ queryKey, refetchType: "none" }); for (const query of matchingQueries) { if (!query.isActive()) continue; const queryId = hashKey(query.queryKey); - if (scheduledQueryKeys?.has(queryId)) continue; - scheduledQueryKeys?.add(queryId); - scheduleRealtimeRefresh(queryClient, query.queryKey); + const scheduled = scheduledQueryRefreshes?.get(queryId); + if (scheduled !== undefined) { + resourceRefreshes?.push(scheduled); + continue; + } + const refresh = scheduleRealtimeRefresh(queryClient, query.queryKey, retryChannelId); + scheduledQueryRefreshes?.set(queryId, refresh); + resourceRefreshes?.push(refresh); } }; -const invalidateBelaboxLiveQueries = (queryClient: QueryClient, channelId: string, scheduledQueryKeys: Set): void => { +const invalidateBelaboxLiveQueries = ( + queryClient: QueryClient, + channelId: string, + scheduledQueryRefreshes: Map>, + resourceRefreshes: Promise[], +): void => { const moduleQueries = queryClient.getQueryCache().findAll({ queryKey: dashboardDataKeys.module(channelId, "belabox") }); for (const query of moduleQueries) { const part = query.queryKey[4]; if (part === "status" || part === "streams" || part === "history-live" || (typeof part === "string" && part.startsWith("history-stream-"))) { - invalidate(queryClient, query.queryKey, scheduledQueryKeys); + invalidate(queryClient, query.queryKey, scheduledQueryRefreshes, resourceRefreshes, channelId); } } }; -const invalidateModuleDataQueries = (queryClient: QueryClient, channelId: string, moduleId: string, scheduledQueryKeys: Set): void => { +const invalidateModuleDataQueries = ( + queryClient: QueryClient, + channelId: string, + moduleId: string, + scheduledQueryRefreshes: Map>, + resourceRefreshes: Promise[], +): void => { const moduleQueries = queryClient.getQueryCache().findAll({ queryKey: dashboardDataKeys.module(channelId, moduleId) }); for (const query of moduleQueries) { const part = query.queryKey[4]; - if (typeof part === "string" && part !== "settings") invalidate(queryClient, query.queryKey, scheduledQueryKeys); + if (typeof part === "string" && part !== "settings") { + invalidate(queryClient, query.queryKey, scheduledQueryRefreshes, resourceRefreshes, channelId); + } } }; -const invalidateModuleSettingsQueries = (queryClient: QueryClient, channelId: string, scheduledQueryKeys: Set): void => { +const invalidateModuleSettingsQueries = ( + queryClient: QueryClient, + channelId: string, + scheduledQueryRefreshes: Map>, + resourceRefreshes: Promise[], +): void => { const moduleQueries = queryClient.getQueryCache().findAll({ queryKey: ["channel", channelId, "module"] }); for (const query of moduleQueries) { - if (query.queryKey[4] === "settings") invalidate(queryClient, query.queryKey, scheduledQueryKeys); + if (query.queryKey[4] === "settings") { + invalidate(queryClient, query.queryKey, scheduledQueryRefreshes, resourceRefreshes, channelId); + } } }; -const invalidatePanelResource = (queryClient: QueryClient, channelId: string, resource: string, scheduledQueryKeys: Set): void => { +const invalidatePanelResource = ( + queryClient: QueryClient, + channelId: string, + resource: string, + scheduledQueryRefreshes: Map>, + resourceRefreshes: Promise[], +): void => { switch (resource) { case "channels": - invalidate(queryClient, queryKeys.channels(), scheduledQueryKeys); + invalidate(queryClient, queryKeys.channels(), scheduledQueryRefreshes, resourceRefreshes, channelId); return; case "channel.all": // This is only the bounded socket hint for a large batch. The following // authoritative vector names each changed resource exactly. return; case "channel.overview": - invalidate(queryClient, queryKeys.channel(channelId, "overview"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.channel(channelId, "overview"), scheduledQueryRefreshes, resourceRefreshes, channelId); return; case "channel.system": - invalidate(queryClient, dashboardDataKeys.system(channelId), scheduledQueryKeys); + invalidate(queryClient, dashboardDataKeys.system(channelId), scheduledQueryRefreshes, resourceRefreshes, channelId); return; case "channel.settings": - invalidate(queryClient, queryKeys.channel(channelId, "settings"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.channel(channelId, "settings"), scheduledQueryRefreshes, resourceRefreshes, channelId); return; case "channel.members": - invalidate(queryClient, dashboardDataKeys.members(channelId), scheduledQueryKeys); + invalidate(queryClient, dashboardDataKeys.members(channelId), scheduledQueryRefreshes, resourceRefreshes, channelId); return; case "channel.modules": - invalidate(queryClient, queryKeys.channel(channelId, "modules"), scheduledQueryKeys); - invalidate(queryClient, queryKeys.channel(channelId, "overview"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.channel(channelId, "modules"), scheduledQueryRefreshes, resourceRefreshes, channelId); + invalidate(queryClient, queryKeys.channel(channelId, "overview"), scheduledQueryRefreshes, resourceRefreshes, channelId); return; case "channel.events": - invalidate(queryClient, queryKeys.channel(channelId, "events"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.channel(channelId, "events"), scheduledQueryRefreshes, resourceRefreshes, channelId); return; case "channel.audit": - invalidate(queryClient, queryKeys.channel(channelId, "audit-log"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.channel(channelId, "audit-log"), scheduledQueryRefreshes, resourceRefreshes, channelId); return; case "channel.variables": - invalidate(queryClient, dashboardDataKeys.variables(channelId), scheduledQueryKeys); - invalidate(queryClient, queryKeys.module(channelId, "text_commands", "commands"), scheduledQueryKeys); - invalidateModuleSettingsQueries(queryClient, channelId, scheduledQueryKeys); + invalidate(queryClient, dashboardDataKeys.variables(channelId), scheduledQueryRefreshes, resourceRefreshes, channelId); + invalidate(queryClient, queryKeys.module(channelId, "text_commands", "commands"), scheduledQueryRefreshes, resourceRefreshes, channelId); + invalidateModuleSettingsQueries(queryClient, channelId, scheduledQueryRefreshes, resourceRefreshes); return; case "channel.overlays": - invalidate(queryClient, dashboardDataKeys.overlays(channelId), scheduledQueryKeys); + invalidate(queryClient, dashboardDataKeys.overlays(channelId), scheduledQueryRefreshes, resourceRefreshes, channelId); return; case "channel.overlay-accesses": - invalidate(queryClient, dashboardDataKeys.overlays(channelId), scheduledQueryKeys); - invalidate(queryClient, queryKeys.channel(channelId, "overlay-accesses"), scheduledQueryKeys); - invalidate(queryClient, dashboardDataKeys.legacyOverlayTokens(channelId), scheduledQueryKeys); + invalidate(queryClient, dashboardDataKeys.overlays(channelId), scheduledQueryRefreshes, resourceRefreshes, channelId); + invalidate(queryClient, queryKeys.channel(channelId, "overlay-accesses"), scheduledQueryRefreshes, resourceRefreshes, channelId); + invalidate(queryClient, dashboardDataKeys.legacyOverlayTokens(channelId), scheduledQueryRefreshes, resourceRefreshes, channelId); return; case "channel.library": - invalidate(queryClient, queryKeys.module(channelId, "text_library", "library"), scheduledQueryKeys); - invalidate(queryClient, queryKeys.module(channelId, "timers", "panel"), scheduledQueryKeys); - invalidate(queryClient, queryKeys.module(channelId, "faq", "panel"), scheduledQueryKeys); - invalidate(queryClient, queryKeys.module(channelId, "text_commands", "template-variables"), scheduledQueryKeys); + invalidate(queryClient, queryKeys.module(channelId, "text_library", "library"), scheduledQueryRefreshes, resourceRefreshes, channelId); + invalidate(queryClient, queryKeys.module(channelId, "timers", "panel"), scheduledQueryRefreshes, resourceRefreshes, channelId); + invalidate(queryClient, queryKeys.module(channelId, "faq", "panel"), scheduledQueryRefreshes, resourceRefreshes, channelId); + invalidate(queryClient, queryKeys.module(channelId, "text_commands", "template-variables"), scheduledQueryRefreshes, resourceRefreshes, channelId); return; default: break; @@ -157,14 +307,14 @@ const invalidatePanelResource = (queryClient: QueryClient, channelId: string, re const part = moduleMatch?.[2]; if (moduleId === undefined || part === undefined) return; if (part === "data") { - invalidateModuleDataQueries(queryClient, channelId, moduleId, scheduledQueryKeys); + invalidateModuleDataQueries(queryClient, channelId, moduleId, scheduledQueryRefreshes, resourceRefreshes); return; } if (moduleId === "belabox" && part === "live") { - invalidateBelaboxLiveQueries(queryClient, channelId, scheduledQueryKeys); + invalidateBelaboxLiveQueries(queryClient, channelId, scheduledQueryRefreshes, resourceRefreshes); return; } - invalidate(queryClient, queryKeys.module(channelId, moduleId, part), scheduledQueryKeys); + invalidate(queryClient, queryKeys.module(channelId, moduleId, part), scheduledQueryRefreshes, resourceRefreshes, channelId); }; const validPanelRevisionVector = (value: unknown): value is Readonly> => @@ -172,7 +322,20 @@ const validPanelRevisionVector = (value: unknown): value is Readonly resource.length <= 128 && Number.isSafeInteger(revision) && Number(revision) >= 0); -/** Reads at most once per short interval; hints retain one trailing channel reconciliation. */ +const advancePanelRevisionSnapshot = ( + queryClient: QueryClient, + channelId: string, + resource: string, + revision: number, +): void => { + const snapshots = panelRevisionSnapshots.get(queryClient); + if (snapshots === undefined) return; + const current = snapshots.get(channelId) ?? {}; + if (revision <= (current[resource] ?? 0)) return; + snapshots.set(channelId, { ...current, [resource]: revision }); +}; + +/** Coalesces hints into trailing reads and retries failed reads with a capped backoff. */ export const reconcileDashboardPanelResourceRevisions = ( queryClient: QueryClient, channelId: string, @@ -189,7 +352,7 @@ export const reconcileDashboardPanelResourceRevisions = ( } let state = fetches.get(channelId); if (state === undefined) { - state = { lastStartedAt: Number.NEGATIVE_INFINITY, dirty: false, promise: null }; + state = { lastStartedAt: Number.NEGATIVE_INFINITY, dirty: false, retryCount: 0, promise: null }; fetches.set(channelId, state); } if (state.promise !== null) { @@ -197,37 +360,72 @@ export const reconcileDashboardPanelResourceRevisions = ( return state.promise; } const scheduled = state; + const generation = realtimeRetryGeneration(queryClient, channelId); const request = (async (): Promise => { - do { + scheduled.dirty = true; + while (revisionFetchWasDirtied(scheduled)) { const wait = Math.max(0, PANEL_REVISION_READ_INTERVAL_MS - (Date.now() - scheduled.lastStartedAt)); if (wait > 0) await new Promise((resolve) => { setTimeout(resolve, wait); }); + if (realtimeRetryGeneration(queryClient, channelId) !== generation) break; // All hints received before this read are represented by its authoritative vector. scheduled.dirty = false; scheduled.lastStartedAt = Date.now(); + let failed = false; try { const response = await fetch(`/api/channels/${encodeURIComponent(channelId)}/revisions`, { credentials: "same-origin", headers: { Accept: "application/json" }, }); + if (realtimeRetryGeneration(queryClient, channelId) !== generation) break; if (response.status === 401 || response.status === 403) { dispatchDashboardAuthenticationRequired(); - return; + scheduled.dirty = false; + break; } - if (!response.ok) continue; - const body: unknown = await response.json(); - if (typeof body !== "object" || body === null || Array.isArray(body)) continue; - const revisions = (body as Record).revisions; - if (!validPanelRevisionVector(revisions)) continue; - const previous = snapshots.get(channelId) ?? {}; - const scheduledQueryKeys = new Set(); - for (const [resource, revision] of Object.entries(revisions)) { - if (revision > (previous[resource] ?? 0)) invalidatePanelResource(queryClient, channelId, resource, scheduledQueryKeys); + if (!response.ok) { + failed = true; + } else { + const body: unknown = await response.json(); + if (typeof body !== "object" || body === null || Array.isArray(body)) { + failed = true; + } else { + const revisions = (body as Record).revisions; + if (!validPanelRevisionVector(revisions)) { + failed = true; + } else { + const previous = snapshots.get(channelId) ?? {}; + const next = { ...previous }; + const scheduledQueryRefreshes = new Map>(); + for (const [resource, revision] of Object.entries(revisions)) { + if (revision <= (previous[resource] ?? 0)) continue; + const resourceRefreshes: Promise[] = []; + invalidatePanelResource(queryClient, channelId, resource, scheduledQueryRefreshes, resourceRefreshes); + if (resourceRefreshes.length === 0) { + next[resource] = revision; + } else { + void Promise.all(resourceRefreshes).then((results) => { + if (results.every(Boolean)) advancePanelRevisionSnapshot(queryClient, channelId, resource, revision); + }); + } + } + snapshots.set(channelId, next); + scheduled.retryCount = 0; + } + } } - snapshots.set(channelId, revisions); } catch { - // The durable vector is compared again on the next hint, reconnect, or focus. + failed = true; + } + if (failed) { + scheduled.dirty = true; + scheduled.retryCount += 1; + const resumed = await waitForRealtimeRetry(queryClient, channelId, retryBackoffDelay(scheduled.retryCount), generation); + if (!resumed) { + scheduled.dirty = false; + break; + } } - } while (revisionFetchWasDirtied(scheduled)); + } })().finally(() => { scheduled.promise = null; }); @@ -235,8 +433,81 @@ export const reconcileDashboardPanelResourceRevisions = ( return request; }; +const queryRefreshSucceeded = (queryClient: QueryClient, queryKey: readonly unknown[]): boolean => { + const query = queryClient.getQueryCache().find({ queryKey, exact: true }); + return query === undefined || !query.isActive() || (query.state.status === "success" && !query.state.isInvalidated); +}; + +const runScheduledRealtimeRefresh = (state: ScheduledRealtimeRefresh): void => { + if (!state.pending) return; + state.pending = false; + state.lastStartedAt = Date.now(); + if (state.running) { + // Let refreshQuery own the trailing read so module queries keep their + // per-key ordering while a prior realtime refresh is still in flight. + void refreshQuery(state.queryClient, state.queryKey); + return; + } + state.running = true; + const generation = state.retryChannelId === null + ? null + : realtimeRetryGeneration(state.queryClient, state.retryChannelId); + void refreshQuery(state.queryClient, state.queryKey) + .then(() => queryRefreshSucceeded(state.queryClient, state.queryKey)) + .catch(() => false) + .then((refreshed) => { + if (generation !== null && state.retryChannelId !== null && + realtimeRetryGeneration(state.queryClient, state.retryChannelId) !== generation) return; + state.running = false; + if (!refreshed) { + if (state.timer !== null) clearTimeout(state.timer); + state.timer = null; + state.pending = true; + state.retryCount += 1; + if (state.retryChannelId === null) return; + if (state.retryWaiting) return; + state.retryWaiting = true; + void waitForRealtimeRetry( + state.queryClient, + state.retryChannelId, + retryBackoffDelay(state.retryCount), + generation ?? realtimeRetryGeneration(state.queryClient, state.retryChannelId), + ).then((resumed) => { + state.retryWaiting = false; + if (resumed) runScheduledRealtimeRefresh(state); + }); + return; + } + + state.retryCount = 0; + if (state.pending) { + scheduleRealtimeRefreshAttempt(state, Math.max(0, REALTIME_REFRESH_INTERVAL_MS - (Date.now() - state.lastStartedAt))); + return; + } + state.resolve?.(true); + state.resolve = null; + state.promise = null; + }); +}; + +const scheduleRealtimeRefreshAttempt = (state: ScheduledRealtimeRefresh, delay: number): void => { + if (state.timer !== null || !state.pending) return; + if (delay <= 0) { + runScheduledRealtimeRefresh(state); + return; + } + state.timer = setTimeout(() => { + state.timer = null; + runScheduledRealtimeRefresh(state); + }, delay); +}; + /** Runs a leading refresh, then limits hints for this key to one refresh per interval. */ -const scheduleRealtimeRefresh = (queryClient: QueryClient, queryKey: readonly unknown[]): void => { +const scheduleRealtimeRefresh = ( + queryClient: QueryClient, + queryKey: readonly unknown[], + retryChannelId?: string, +): Promise => { let refreshes = scheduledRefreshes.get(queryClient); if (refreshes === undefined) { refreshes = new Map(); @@ -247,23 +518,30 @@ const scheduleRealtimeRefresh = (queryClient: QueryClient, queryKey: readonly un const now = Date.now(); let state = refreshes.get(id); if (state === undefined) { - state = { lastStartedAt: now, pending: false, timer: null }; + state = { + lastStartedAt: Number.NEGATIVE_INFINITY, + pending: false, + running: false, + retryCount: 0, + retryWaiting: false, + timer: null, + promise: null, + resolve: null, + queryClient, + queryKey, + retryChannelId: retryChannelId ?? (queryKey[0] === "channel" && typeof queryKey[1] === "string" ? queryKey[1] : null), + }; refreshes.set(id, state); - void refreshQuery(queryClient, queryKey); - return; } - state.pending = true; - if (state.timer !== null) return; - const wait = Math.max(0, REALTIME_REFRESH_INTERVAL_MS - (now - state.lastStartedAt)); - const scheduled = state; - scheduled.timer = setTimeout(() => { - scheduled.timer = null; - if (!scheduled.pending) return; - scheduled.pending = false; - scheduled.lastStartedAt = Date.now(); - void refreshQuery(queryClient, queryKey); - }, wait); + if (state.promise === null) { + state.promise = new Promise((resolve) => { state.resolve = resolve; }); + } + const wait = state.retryCount > 0 + ? retryBackoffDelay(state.retryCount) + : Math.max(0, REALTIME_REFRESH_INTERVAL_MS - (now - state.lastStartedAt)); + if (!state.retryWaiting) scheduleRealtimeRefreshAttempt(state, wait); + return state.promise; }; /** @@ -280,5 +558,5 @@ export const reconcileDashboardRealtimeMessage = ( /** A successful reconnect makes every cached resource for that channel stale. */ export const invalidateDashboardChannelQueries = (queryClient: QueryClient, channelId: string): void => { - invalidate(queryClient, ["channel", channelId]); + invalidate(queryClient, ["channel", channelId], undefined, undefined, channelId); }; diff --git a/src/dashboard/realtime.ts b/src/dashboard/realtime.ts index 93f2fa74..6957d8b7 100644 --- a/src/dashboard/realtime.ts +++ b/src/dashboard/realtime.ts @@ -5,6 +5,7 @@ import type { PanelChannelControl, PanelChannelControls } from "../panel-contrac import type { RealtimeEnvelope, RealtimeEventLogHint, RealtimeMessage } from "../realtime-contract"; import { REALTIME_PROTOCOL } from "../realtime-contract"; import { + cancelDashboardRealtimeRetries, invalidateDashboardChannelQueries, reconcileDashboardRealtimeMessage, reconcileDashboardPanelResourceRevisions, @@ -157,6 +158,7 @@ const panelSocketConnections = new Map(); const disposePanelSocket = (channelId: string, connection: PanelSocketConnection): void => { if (connection.disposed) return; connection.disposed = true; + cancelDashboardRealtimeRetries(connection.queryClient, channelId); if (connection.reconnectTimer !== null) clearTimeout(connection.reconnectTimer); if (connection.cleanupTimer !== null) clearTimeout(connection.cleanupTimer); connection.reconnectTimer = null; @@ -213,6 +215,7 @@ const connectPanelSocket = (channelId: string, connection: PanelSocketConnection if (connection.disposed) return; if (connection.socket === socket) connection.socket = null; if (socketNeedsRenewal(event) || event.code === 1008) { + cancelDashboardRealtimeRetries(connection.queryClient, channelId); setDashboardRealtimeStatus(channelId, "renew"); return; } diff --git a/src/worker/app-routes.ts b/src/worker/app-routes.ts index badddece..3a3b09d6 100644 --- a/src/worker/app-routes.ts +++ b/src/worker/app-routes.ts @@ -20,7 +20,7 @@ app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes, async (context, next) => } finally { const channelId = context.req.param("channelId"); if (channelId.length > 0 && authorizedContextHas(context, "session")) { - await notifyCommittedResources(context.env, channelId); + await notifyCommittedResources(context.env); } } }); diff --git a/tests/unit/dashboard-realtime.test.ts b/tests/unit/dashboard-realtime.test.ts index 04f1fc05..5c81334f 100644 --- a/tests/unit/dashboard-realtime.test.ts +++ b/tests/unit/dashboard-realtime.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { parseRealtimeMessage } from "../../src/dashboard/realtime"; import { dashboardDataKeys, queryKeys } from "../../src/dashboard/data/keys"; import { + cancelDashboardRealtimeRetries, getDashboardRealtimeStatus, invalidateDashboardChannelQueries, reconcileDashboardRealtimeMessage, @@ -278,6 +279,116 @@ describe("dashboard realtime messages", () => { reader.clear(); }); + it("retries a failed revision read while connected without another hint", async () => { + vi.useFakeTimers(); + const revisionChannel = "revision-read-retry"; + const reader = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const events = queryKeys.channel(revisionChannel, "events"); + let eventReads = 0; + reader.setQueryData(events, { revision: 0 }); + const unsubscribe = new QueryObserver(reader, { + queryKey: events, + queryFn: () => { eventReads += 1; return Promise.resolve({ revision: eventReads }); }, + staleTime: Infinity, + }).subscribe(() => undefined); + let revisionReads = 0; + vi.stubGlobal("fetch", vi.fn(() => { + revisionReads += 1; + if (revisionReads === 1) return Promise.resolve(new Response("temporarily unavailable", { status: 503 })); + return Promise.resolve(new Response(JSON.stringify({ revisions: { "channel.events": 1 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + })); + setDashboardRealtimeStatus(revisionChannel, "connected"); + + const reconciliation = reconcileDashboardPanelResourceRevisions(reader, revisionChannel); + await vi.advanceTimersByTimeAsync(500); + await reconciliation; + + expect(revisionReads).toBe(2); + expect(eventReads).toBe(1); + expect(reader.getQueryState(events)?.isInvalidated).toBe(false); + unsubscribe(); + reader.clear(); + setDashboardRealtimeStatus(revisionChannel, "offline"); + }); + + it("cancels pending revision retries when a channel socket is disposed", async () => { + vi.useFakeTimers(); + const revisionChannel = "revision-retry-disposal"; + const oldReader = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const newReader = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const fetchRevisions = vi.fn() + .mockResolvedValueOnce(new Response("temporarily unavailable", { status: 503 })) + .mockResolvedValue(new Response(JSON.stringify({ revisions: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + vi.stubGlobal("fetch", fetchRevisions); + setDashboardRealtimeStatus(revisionChannel, "connected"); + + const oldReconciliation = reconcileDashboardPanelResourceRevisions(oldReader, revisionChannel); + await vi.advanceTimersByTimeAsync(0); + expect(fetchRevisions).toHaveBeenCalledTimes(1); + + cancelDashboardRealtimeRetries(oldReader, revisionChannel); + setDashboardRealtimeStatus(revisionChannel, "offline"); + await oldReconciliation; + setDashboardRealtimeStatus(revisionChannel, "connected"); + await reconcileDashboardPanelResourceRevisions(newReader, revisionChannel); + await vi.advanceTimersByTimeAsync(5_000); + + expect(fetchRevisions).toHaveBeenCalledTimes(2); + oldReader.clear(); + newReader.clear(); + cancelDashboardRealtimeRetries(newReader, revisionChannel); + setDashboardRealtimeStatus(revisionChannel, "offline"); + }); + + it("retries an affected query after its normal retries are exhausted", async () => { + vi.useFakeTimers(); + const revisionChannel = "revision-resource-retry"; + const reader = new QueryClient({ defaultOptions: { queries: { retry: 1, retryDelay: () => 0 } } }); + const events = queryKeys.channel(revisionChannel, "events"); + let eventReads = 0; + reader.setQueryData(events, { revision: 0 }); + const unsubscribe = new QueryObserver(reader, { + queryKey: events, + queryFn: () => { + eventReads += 1; + return eventReads <= 2 + ? Promise.reject(new Error("temporary query failure")) + : Promise.resolve({ revision: eventReads }); + }, + retry: 1, + retryDelay: () => 0, + staleTime: Infinity, + }).subscribe(() => undefined); + expect(eventReads).toBe(0); + let revision = 0; + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify({ revisions: { "channel.events": revision } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })))); + setDashboardRealtimeStatus(revisionChannel, "connected"); + + await reconcileDashboardPanelResourceRevisions(reader, revisionChannel); + revision = 1; + const reconciliation = reconcileDashboardPanelResourceRevisions(reader, revisionChannel); + await vi.advanceTimersByTimeAsync(250); + await vi.advanceTimersByTimeAsync(1); + expect(eventReads).toBe(2); + await vi.advanceTimersByTimeAsync(500); + await reconciliation; + + expect(eventReads).toBe(3); + expect(reader.getQueryState(events)?.isInvalidated).toBe(false); + unsubscribe(); + reader.clear(); + setDashboardRealtimeStatus(revisionChannel, "offline"); + }); + it("throttles sequential hints beyond request latency and observes the last committed revision", async () => { vi.useFakeTimers(); const revisionChannel = "revision-sustained-hints"; diff --git a/tests/unit/panel-resource-revisions.test.ts b/tests/unit/panel-resource-revisions.test.ts index 8a7b7af6..3e4aa479 100644 --- a/tests/unit/panel-resource-revisions.test.ts +++ b/tests/unit/panel-resource-revisions.test.ts @@ -16,7 +16,9 @@ import { panelRouter } from "../../src/worker/panel/routes"; import { moduleRouter } from "../../src/worker/panel/module-routes"; import { platformRouter } from "../../src/worker/platform/routes"; import { app } from "../../src/worker/worker-app"; +import { createSessionCookie } from "../../src/worker/auth/session"; import { notifyCommittedResources, panelResourceChangedMessage, PANEL_RESOURCE_NOTIFIER_COVERAGE, readCompletePanelResourceRevisions, readPanelResourceRevisions } from "../../src/worker/panel-resources"; +import { insertChannel, insertLoginIdentityAndSession, insertMember, testKey } from "./fixtures"; import { TestD1Database } from "./test-d1"; const writeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); @@ -385,7 +387,7 @@ describe("panel resource revisions", () => { const worker = readFileSync(resolve(root, "src/worker/app-routes.ts"), "utf8"); expect(worker).toContain("app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes"); expect(worker).toContain("app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.platformRoutes"); - expect(worker).toMatch(/finally \{\s*const channelId = context\.req\.param\("channelId"\);\s*if \(channelId\.length > 0 && authorizedContextHas\(context, "session"\)\) \{\s*await notifyCommittedResources\(context\.env, channelId\);/u); + expect(worker).toMatch(/finally \{\s*const channelId = context\.req\.param\("channelId"\);\s*if \(channelId\.length > 0 && authorizedContextHas\(context, "session"\)\) \{\s*await notifyCommittedResources\(context\.env\);/u); expect(worker).not.toContain("PANEL_RESOURCE_NOTIFIER_COVERAGE.authRoutes"); expect(worker).not.toMatch(/context\.res\.status !== 401/u); const rootWorker = readFileSync(resolve(root, "src/worker/worker-app.ts"), "utf8"); @@ -442,6 +444,87 @@ describe("panel resource revisions", () => { expect(idFromName).not.toHaveBeenCalled(); }); + it("publishes every channel queued by an authenticated channel request", async () => { + const database = new TestD1Database(); + const visitedChannels: string[] = []; + const cookieKeys = JSON.stringify({ active: { id: "cookie-v1", key: testKey(1) }, retired: [] }); + const encryptionKeys = JSON.stringify({ active: { id: "encryption-v1", key: testKey(2) }, retired: [] }); + try { + await insertChannel(database, "cross-channel-a"); + await insertChannel(database, "cross-channel-b"); + await insertLoginIdentityAndSession(database, "cross-channel-manager"); + await insertMember(database, "cross-channel-a", "cross-channel-manager", "manager"); + await database.prepare("DELETE FROM panel_resource_pending").run(); + + const env = { + DB: database as unknown as D1Database, + SESSION_COOKIE_KEYS: cookieKeys, + SESSION_ENCRYPTION_KEYS: encryptionKeys, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: (channelId: string) => ({ + getPanelResourceRevisions: () => Promise.resolve({}), + reconcilePanelResources: () => { visitedChannels.push(channelId); return Promise.resolve(); }, + }), + }, + } as unknown as Env; + const sessionCookie = await createSessionCookie( + { sessionId: "session-cross-channel-manager" }, + cookieKeys, + encryptionKeys, + ); + + // The target user's channel list is shared across channels, so this + // membership commit queues revisions for both existing channel sockets. + await insertMember(database, "cross-channel-a", "new-channel-member", "operator"); + const response = await app.fetch(new Request( + "https://brobot.example/api/channels/cross-channel-a/revisions", + { headers: { Cookie: `__Host-brobot_session=${sessionCookie}` } }, + ), env); + + expect(response.status).toBe(200); + expect(visitedChannels.sort((left, right) => left.localeCompare(right))) + .toEqual(["cross-channel-a", "cross-channel-b"]); + const remaining = await database.prepare( + "SELECT channel_id FROM panel_resource_pending ORDER BY channel_id LIMIT 1", + ).first<{ channel_id: string }>(); + expect(remaining).toBeNull(); + } finally { + database.close(); + } + }); + + it("does not fan out queued revisions from an unauthenticated channel request", async () => { + const database = new TestD1Database(); + const getChannel = vi.fn(); + try { + await insertChannel(database, "unauthenticated-a"); + await insertChannel(database, "unauthenticated-b"); + await database.prepare("DELETE FROM panel_resource_pending").run(); + await database.prepare( + `UPDATE panel_resource_revisions SET revision = revision + 1 + WHERE channel_id = 'unauthenticated-b' AND resource = 'channel.overview'`, + ).run(); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { idFromName: (channelId: string) => channelId, get: getChannel }, + } as unknown as Env; + + const response = await app.fetch(new Request( + "https://brobot.example/api/channels/unauthenticated-a/revisions", + ), env); + + expect(response.status).toBe(401); + expect(getChannel).not.toHaveBeenCalled(); + const pending = await database.prepare( + "SELECT channel_id FROM panel_resource_pending WHERE channel_id = 'unauthenticated-b'", + ).first<{ channel_id: string }>(); + expect(pending?.channel_id).toBe("unauthenticated-b"); + } finally { + database.close(); + } + }); + it("uses the subscription registry to resolve EventSub notifier channels, including raid directions", () => { expect(eventSubChannelIdForNotification({ subscription: { type: "channel.raid", condition: { to_broadcaster_user_id: "incoming-channel" } }, From d694fb0eab92e59cb782cc7c2ca826f71f01d458 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 17:36:09 +0200 Subject: [PATCH 11/16] =?UTF-8?q?=F0=9F=90=9B=20fix(realtime):=20one=20dra?= =?UTF-8?q?in=20path=20with=20leases,=20socket=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #387: claim pending channel revisions with recoverable leases and bounded delivery concurrency - #387: drain cross-channel commits while keeping local Durable Object reconciliation direct - #387: recheck socket capacity at acceptance and cover fan-out, lease recovery, and concurrent handshakes --- .../0049_panel_resource_delivery_leases.sql | 5 + src/worker/durable/ChannelObject.ts | 5 + src/worker/panel-resources.ts | 179 +++++++++++++++--- tests/unit/panel-resource-revisions.test.ts | 129 +++++++++++++ tests/unit/sql-contract.test.ts | 4 +- tests/worker/channel-object.test.ts | 64 +++++++ 6 files changed, 355 insertions(+), 31 deletions(-) create mode 100644 migrations/0049_panel_resource_delivery_leases.sql diff --git a/migrations/0049_panel_resource_delivery_leases.sql b/migrations/0049_panel_resource_delivery_leases.sql new file mode 100644 index 00000000..90659e6d --- /dev/null +++ b/migrations/0049_panel_resource_delivery_leases.sql @@ -0,0 +1,5 @@ +ALTER TABLE panel_resource_pending ADD COLUMN claimed_until INTEGER; +ALTER TABLE panel_resource_pending ADD COLUMN claim_token TEXT; + +CREATE INDEX panel_resource_pending_claim_idx + ON panel_resource_pending(claimed_until, channel_id); diff --git a/src/worker/durable/ChannelObject.ts b/src/worker/durable/ChannelObject.ts index 44525205..5ba4b94b 100644 --- a/src/worker/durable/ChannelObject.ts +++ b/src/worker/durable/ChannelObject.ts @@ -2305,6 +2305,11 @@ export class ChannelObject extends DurableObject { return new Response(null, { status: 503, headers: { "Retry-After": "1" } }); } } + // Authorization above awaits D1 and lets concurrent handshakes interleave. + // Reserve capacity again at the synchronous acceptance boundary. + if (!this.hasConnectionCapacity(principal)) { + return new Response(null, { status: 503, headers: { "Retry-After": "1" } }); + } this.ctx.acceptWebSocket(pair[1], tagsFor(principal)); pair[1].serializeAttachment(principal); await this.scheduleSecurityAlarm(); diff --git a/src/worker/panel-resources.ts b/src/worker/panel-resources.ts index c43e320c..8b130c84 100644 --- a/src/worker/panel-resources.ts +++ b/src/worker/panel-resources.ts @@ -8,6 +8,16 @@ export interface PanelResourceRevisionRow { } const MAX_PANEL_RESOURCES = 128; +const MAX_PENDING_CHANNELS_PER_CLAIM = 32; +const MAX_CONCURRENT_PANEL_RECONCILIATIONS = 4; +const PANEL_RESOURCE_CLAIM_LEASE_MS = 5 * 60 * 1_000; + +interface ClaimedPanelChannels { + channelIds: string[]; + claimToken: string; +} + +const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); export const PANEL_RESOURCE_NOTIFIER_COVERAGE = { channelRoutes: "/api/channels/:channelId/*", @@ -47,10 +57,16 @@ export const PANEL_RESOURCE_NOTIFIER_COVERAGE = { const reconcileChannelResources = async ( env: Pick, channelId: string, + claimToken: string, + reconcileLocal?: (revisions: PanelResourceRevisionVector) => Promise, ): Promise => { const revisions = await readPanelResourceRevisions(env.DB, channelId); - await env.CHANNEL.get(env.CHANNEL.idFromName(channelId)).reconcilePanelResources(revisions); - await clearDeliveredPanelResources(env.DB, channelId, revisions); + if (reconcileLocal === undefined) { + await env.CHANNEL.get(env.CHANNEL.idFromName(channelId)).reconcilePanelResources(revisions); + } else { + await reconcileLocal(revisions); + } + await clearDeliveredPanelResources(env.DB, channelId, revisions, claimToken); return revisions; }; @@ -58,20 +74,113 @@ const clearDeliveredPanelResources = async ( database: D1Database, channelId: string, revisions: PanelResourceRevisionVector, + claimToken: string, ): Promise => { const entries = Object.entries(revisions); - if (entries.length === 0) return; - await database.batch(entries.map(([resource, revision]) => database.prepare( - `DELETE FROM panel_resource_pending - WHERE channel_id = ? AND resource = ? AND revision <= ?`, - ).bind(channelId, resource, revision))); + await database.batch([ + database.prepare( + `DELETE FROM panel_resource_pending + WHERE channel_id = ? AND claim_token = ? + AND NOT EXISTS ( + SELECT 1 FROM panel_resource_revisions AS revisions + WHERE revisions.channel_id = panel_resource_pending.channel_id + AND revisions.resource = panel_resource_pending.resource + )`, + ).bind(channelId, claimToken), + ...entries.map(([resource, revision]) => database.prepare( + `DELETE FROM panel_resource_pending + WHERE channel_id = ? AND resource = ? AND revision <= ? AND claim_token = ?`, + ).bind(channelId, resource, revision, claimToken)), + database.prepare( + `UPDATE panel_resource_pending + SET claimed_until = NULL, claim_token = NULL + WHERE channel_id = ? AND claim_token = ?`, + ).bind(channelId, claimToken), + ]); }; -const pendingChannelIds = async (database: D1Database): Promise => { +/** Claims every queued resource for a bounded set of channels in one atomic D1 statement. */ +const claimPendingChannels = async ( + database: D1Database, + preferredChannelId?: string, +): Promise => { + const claimToken = crypto.randomUUID(); + const now = Date.now(); + await database.prepare( + `UPDATE panel_resource_pending + SET claimed_until = ?, claim_token = ? + WHERE channel_id IN ( + SELECT channel_id + FROM panel_resource_pending + GROUP BY channel_id + HAVING MAX(COALESCE(claimed_until, 0)) <= ? + ORDER BY CASE WHEN channel_id = ? THEN 0 ELSE 1 END, channel_id + LIMIT ? + )`, + ).bind( + now + PANEL_RESOURCE_CLAIM_LEASE_MS, + claimToken, + now, + preferredChannelId ?? "", + MAX_PENDING_CHANNELS_PER_CLAIM, + ).run(); const result = await database.prepare( - "SELECT DISTINCT channel_id FROM panel_resource_pending ORDER BY channel_id", - ).all<{ channel_id: string }>(); - return result.results.map(({ channel_id }) => channel_id); + `SELECT DISTINCT channel_id + FROM panel_resource_pending + WHERE claim_token = ? + ORDER BY channel_id`, + ).bind(claimToken).all<{ channel_id: string }>(); + const channelIds = result.results.flatMap(({ channel_id }) => + typeof channel_id === "string" && channel_id.length > 0 ? [channel_id] : []); + return { channelIds, claimToken }; +}; + +const channelHasPendingResources = async (database: D1Database, channelId: string): Promise => { + const result = await database.prepare( + "SELECT 1 AS queued FROM panel_resource_pending WHERE channel_id = ? LIMIT 1", + ).bind(channelId).all<{ queued: number }>(); + return result.results.some(({ queued }) => queued === 1); +}; + +const reconcileClaimedChannels = async ( + env: Pick, + claim: ClaimedPanelChannels, + localChannelId: string | undefined, + reconcileLocal: ((revisions: PanelResourceRevisionVector) => Promise) | undefined, +): Promise => { + let next = 0; + let localChannelWasReconciled = false; + let failedDeliveries = 0; + let firstDeliveryError: unknown; + const workerCount = Math.min(MAX_CONCURRENT_PANEL_RECONCILIATIONS, claim.channelIds.length); + await Promise.all(Array.from({ length: workerCount }, async () => { + while (next < claim.channelIds.length) { + const channelId = claim.channelIds[next++]; + if (channelId === undefined) continue; + const useLocalReconciliation = channelId === localChannelId && reconcileLocal !== undefined; + if (useLocalReconciliation) localChannelWasReconciled = true; + try { + await reconcileChannelResources( + env, + channelId, + claim.claimToken, + useLocalReconciliation ? reconcileLocal : undefined, + ); + } catch (error: unknown) { + // Keep the lease after a failed delivery. A later drain can reclaim + // it after expiry without duplicating an in-flight Durable Object call. + failedDeliveries += 1; + firstDeliveryError ??= error; + } + } + })); + if (failedDeliveries > 0) { + console.warn( + `Committed panel resources could not be published to ${String(failedDeliveries)} channels.`, + errorMessage(firstDeliveryError), + ); + } + return localChannelWasReconciled; }; /** Reads the durable D1 vector for one channel. */ @@ -114,29 +223,41 @@ export const notifyCommittedResources = async ( channelId?: string, reconcileLocal?: (revisions: PanelResourceRevisionVector) => Promise, ): Promise => { - if (channelId === undefined) { + let localChannelWasReconciled = false; + let queueDrainFailed = false; + try { + let claim = await claimPendingChannels(env.DB, channelId); + while (claim.channelIds.length > 0) { + const localWasReconciled = await reconcileClaimedChannels(env, claim, channelId, reconcileLocal); + localChannelWasReconciled = localChannelWasReconciled || localWasReconciled; + claim = await claimPendingChannels(env.DB, channelId); + } + } catch (error: unknown) { + queueDrainFailed = true; + console.warn("Committed panel resources could not be claimed.", errorMessage(error)); + } + + if (channelId !== undefined && reconcileLocal !== undefined && !localChannelWasReconciled) { + let revisions: PanelResourceRevisionVector = {}; try { - await Promise.all((await pendingChannelIds(env.DB)).map((pendingChannelId) => - reconcileChannelResources(env, pendingChannelId))); + // DO-only commits have no D1 queue row. Reconcile them locally; if a + // D1 row is leased by another drain, that drain owns its publication. + if (!queueDrainFailed && await channelHasPendingResources(env.DB, channelId)) return; } catch (error: unknown) { - console.warn("Committed panel resources could not be published.", error); + console.warn("Committed panel resources could not be checked locally.", channelId, errorMessage(error)); } - return; - } - try { - const pending = await env.DB.prepare( - "SELECT 1 AS present FROM panel_resource_pending WHERE channel_id = ? LIMIT 1", - ).bind(channelId).first<{ present: number }>(); - if (pending === null && reconcileLocal === undefined) return; - const revisions = await readPanelResourceRevisions(env.DB, channelId); - if (reconcileLocal === undefined) { - await env.CHANNEL.get(env.CHANNEL.idFromName(channelId)).reconcilePanelResources(revisions); - } else { + try { + revisions = await readPanelResourceRevisions(env.DB, channelId); + } catch (error: unknown) { + // Local DO revisions are independent of D1 and still need a same-DO + // notification when the D1 queue or revision read is unavailable. + console.warn("Committed panel resource revisions could not be read locally.", channelId, errorMessage(error)); + } + try { await reconcileLocal(revisions); + } catch (error: unknown) { + console.warn("Committed panel resources could not be published locally.", channelId, errorMessage(error)); } - await clearDeliveredPanelResources(env.DB, channelId, revisions); - } catch (error: unknown) { - console.warn("Committed panel resources could not be published.", error); } }; diff --git a/tests/unit/panel-resource-revisions.test.ts b/tests/unit/panel-resource-revisions.test.ts index 3e4aa479..056198d9 100644 --- a/tests/unit/panel-resource-revisions.test.ts +++ b/tests/unit/panel-resource-revisions.test.ts @@ -494,6 +494,135 @@ describe("panel resource revisions", () => { } }); + it("drains shared-cache revisions for every channel named by one commit", async () => { + const database = new TestD1Database(); + const visitedChannels: string[] = []; + const localReconciliation = vi.fn(() => Promise.resolve()); + try { + await addChannel(database, "weather-channel-a"); + await addChannel(database, "weather-channel-b"); + await database.prepare( + "UPDATE channels SET location_latitude = 52.5, location_longitude = 13.4 WHERE channel_id IN (?, ?)", + ).bind("weather-channel-a", "weather-channel-b").run(); + await database.prepare("DELETE FROM panel_resource_pending").run(); + await database.prepare( + `INSERT INTO weather_cache + (provider, location_key, latitude, longitude, payload_json, expires_at, updated_at) + VALUES ('met_norway', 'shared-berlin', 52.5, 13.4, '{}', '2026-10-10T01:00:00.000Z', '2026-10-10T00:00:00.000Z')`, + ).run(); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: (channelId: string) => ({ + reconcilePanelResources: () => { visitedChannels.push(channelId); return Promise.resolve(); }, + }), + }, + } as unknown as Env; + + await notifyCommittedResources(env, "weather-channel-a", localReconciliation); + + expect(localReconciliation).toHaveBeenCalledTimes(1); + expect(visitedChannels).toEqual(["weather-channel-b"]); + expect((await database.prepare("SELECT COUNT(*) AS count FROM panel_resource_pending").first<{ count: number }>())?.count) + .toBe(0); + } finally { + database.close(); + } + }); + + it("claims a migration backlog once across concurrent drains with bounded DO concurrency", async () => { + const database = new TestD1Database(); + const channelIds = Array.from({ length: 8 }, (_, index) => `backlog-${String(index)}`); + const visitedChannels: string[] = []; + const releases: Array<() => void> = []; + let activeCalls = 0; + let maximumConcurrentCalls = 0; + try { + for (const channelId of channelIds) await addChannel(database, channelId); + await database.prepare("DELETE FROM panel_resource_pending").run(); + // Recreate the migration backfill that queues every existing channel. + await database.prepare( + `INSERT INTO panel_resource_pending (channel_id, resource, revision) + SELECT channel_id, resource, revision FROM panel_resource_revisions + WHERE 1 + ON CONFLICT(channel_id, resource) DO UPDATE SET revision = MAX(revision, excluded.revision)`, + ).run(); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: (channelId: string) => ({ + reconcilePanelResources: () => new Promise((resolve) => { + visitedChannels.push(channelId); + activeCalls += 1; + maximumConcurrentCalls = Math.max(maximumConcurrentCalls, activeCalls); + releases.push(() => { activeCalls -= 1; resolve(); }); + }), + }), + }, + } as unknown as Env; + + const drains = Promise.all([ + notifyCommittedResources(env), + notifyCommittedResources(env), + notifyCommittedResources(env), + ]); + await vi.waitFor(() => { expect(visitedChannels).toHaveLength(4); }); + expect(maximumConcurrentCalls).toBe(4); + expect(new Set(visitedChannels).size).toBe(4); + releases.splice(0).forEach((release) => { release(); }); + await vi.waitFor(() => { expect(visitedChannels).toHaveLength(8); }); + releases.splice(0).forEach((release) => { release(); }); + await drains; + + expect(visitedChannels.slice().sort((left, right) => left.localeCompare(right))) + .toEqual(channelIds.slice().sort((left, right) => left.localeCompare(right))); + expect(maximumConcurrentCalls).toBeLessThanOrEqual(4); + expect((await database.prepare("SELECT COUNT(*) AS count FROM panel_resource_pending").first<{ count: number }>())?.count) + .toBe(0); + } finally { + releases.splice(0).forEach((release) => { release(); }); + database.close(); + } + }); + + it("reclaims a failed delivery after its lease expires", async () => { + const database = new TestD1Database(); + const reconcilePanelResources = vi.fn() + .mockRejectedValueOnce(new Error("temporary Durable Object failure")) + .mockResolvedValue(undefined); + try { + await addChannel(database, "lease-recovery"); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => ({ reconcilePanelResources }), + }, + } as unknown as Env; + + await notifyCommittedResources(env); + expect(reconcilePanelResources).toHaveBeenCalledTimes(1); + const leasedRow = await database.prepare( + "SELECT claimed_until, claim_token FROM panel_resource_pending WHERE channel_id = ? LIMIT 1", + ).bind("lease-recovery").first<{ claimed_until: number; claim_token: string }>(); + expect(leasedRow?.claimed_until).toBeGreaterThan(Date.now()); + expect(leasedRow?.claim_token).toEqual(expect.any(String)); + + await database.prepare( + "UPDATE panel_resource_pending SET claimed_until = ? WHERE channel_id = ?", + ).bind(Date.now() - 1, "lease-recovery").run(); + await notifyCommittedResources(env); + + expect(reconcilePanelResources).toHaveBeenCalledTimes(2); + expect((await database.prepare("SELECT COUNT(*) AS count FROM panel_resource_pending").first<{ count: number }>())?.count) + .toBe(0); + } finally { + database.close(); + } + }); + it("does not fan out queued revisions from an unauthenticated channel request", async () => { const database = new TestD1Database(); const getChannel = vi.fn(); diff --git a/tests/unit/sql-contract.test.ts b/tests/unit/sql-contract.test.ts index aef4b4a1..5899331b 100644 --- a/tests/unit/sql-contract.test.ts +++ b/tests/unit/sql-contract.test.ts @@ -192,9 +192,9 @@ describe("SQL contract", () => { "SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY tbl_name, type DESC, name", ).all() as unknown as SchemaObject[]; expect(objects.filter((object) => object.type === "table")).toHaveLength(56); - expect(objects.filter((object) => object.type === "index")).toHaveLength(45); + expect(objects.filter((object) => object.type === "index")).toHaveLength(46); expect(objects.filter((object) => object.type === "trigger")).toHaveLength(124); - expect(objects).toHaveLength(225); + expect(objects).toHaveLength(226); const tableColumns = (table: string): Set => new Set( (database.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(({ name }) => name), diff --git a/tests/worker/channel-object.test.ts b/tests/worker/channel-object.test.ts index 783fd845..ce4eb430 100644 --- a/tests/worker/channel-object.test.ts +++ b/tests/worker/channel-object.test.ts @@ -3217,6 +3217,70 @@ describe("ChannelObject realtime path", () => { expect(hasCapacity({ v: 1, kind: "overlay", channelId: "kanal-a", tokenId: "token-1", overlayId: null, expiresAt: null })).toBe(true); }); + it("rechecks the panel socket cap after concurrent authorization reads", async () => { + const pendingAuthorizations: Array<() => void> = []; + let deferredAuthorizationReads = 0; + const database = { + prepare: vi.fn((query: string) => { + let bindings: unknown[] = []; + const statement = { + bind: vi.fn((...values: unknown[]) => { bindings = values; return statement; }), + first: vi.fn(() => { + if (!query.includes("FROM auth_sessions AS session")) return Promise.resolve(null); + const row = { + session_id: bindings[1], + user_id: bindings[2], + role: bindings[3], + }; + return deferredAuthorizationReads < 11 + ? new Promise((resolve: (value: unknown) => void) => { + deferredAuthorizationReads += 1; + pendingAuthorizations.push(() => { + resolve(row); + }); + }) + : Promise.resolve(row); + }), + all: vi.fn(() => Promise.resolve({ results: [] })), + run: vi.fn(() => Promise.resolve({ success: true })), + }; + return statement; + }), + } as unknown as D1Database; + const object = objectFor([], database); + const pairs = Array.from({ length: 11 }, (_, index) => ({ + 0: new WebSocketPair()[0], + 1: { + tags: ["kind:panel", "user:shared-panel-user", `session:session-${String(index)}`], + accept: vi.fn(), + close: vi.fn(), + send: vi.fn(), + serializeAttachment: vi.fn(), + deserializeAttachment: vi.fn(), + } as unknown as WebSocket, + })); + let nextPair = 0; + vi.stubGlobal("WebSocketPair", function WebSocketPairDouble() { + const pair = pairs[nextPair]; + nextPair += 1; + if (pair === undefined) throw new Error("No test socket pair remains."); + return pair; + }); + + const handshakes = Array.from({ length: 11 }, (_, index) => object.fetch(upgradeRequest(validPrincipal({ + userId: "shared-panel-user", + sessionId: `session-${String(index)}`, + })))); + await vi.waitFor(() => { expect(pendingAuthorizations).toHaveLength(11); }); + pendingAuthorizations.splice(0).forEach((release) => { release(); }); + const responses = await Promise.all(handshakes); + const state = (object as unknown as { ctx: { acceptWebSocket: ReturnType } }).ctx; + + expect(state.acceptWebSocket).toHaveBeenCalledTimes(10); + expect(responses.filter(({ status }) => status === 101)).toHaveLength(10); + expect(responses.filter(({ status }) => status === 503)).toHaveLength(1); + }); + it("rate-limits repeated overlay handshakes per token", async () => { vi.useFakeTimers(); const now = Date.parse("2026-09-21T12:00:00.000Z"); From 165ec99c618fad4a178b25108652d85cdf21160b Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 18:31:20 +0200 Subject: [PATCH 12/16] =?UTF-8?q?=F0=9F=90=9B=20fix(realtime):=20one=20dra?= =?UTF-8?q?in=20path=20with=20leases,=20socket=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schema: place the lease migration after main’s 0050 and update the health sentinel. - Regression guard: reject duplicate migration numbers. - #387: preserve migration ordering for bounded revision delivery. --- ...y_leases.sql => 0051_panel_resource_delivery_leases.sql} | 0 src/worker/config.ts | 4 ++-- tests/unit/schema-baseline.test.ts | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) rename migrations/{0049_panel_resource_delivery_leases.sql => 0051_panel_resource_delivery_leases.sql} (100%) diff --git a/migrations/0049_panel_resource_delivery_leases.sql b/migrations/0051_panel_resource_delivery_leases.sql similarity index 100% rename from migrations/0049_panel_resource_delivery_leases.sql rename to migrations/0051_panel_resource_delivery_leases.sql diff --git a/src/worker/config.ts b/src/worker/config.ts index 121f434f..7a3694e2 100644 --- a/src/worker/config.ts +++ b/src/worker/config.ts @@ -19,8 +19,8 @@ export const REQUIRED_SECRET_NAMES = [ // update it and /healthz reports a freshly set-up database as broken (503) // even though everything is fine. `tests/unit/schema-baseline.test.ts` // therefore pins it to the last file in `migrations/`. -export const LATEST_SCHEMA_MIGRATION = "0050_pending_realtime_revocation_index.sql"; -export const LATEST_SCHEMA_TABLE = "pending_realtime_user_revocations"; +export const LATEST_SCHEMA_MIGRATION = "0051_panel_resource_delivery_leases.sql"; +export const LATEST_SCHEMA_TABLE = "panel_resource_pending"; const REQUIRED_BINDING_NAMES = ["DB", "CHANNEL", "ASSETS", "CF_VERSION_METADATA"] as const; diff --git a/tests/unit/schema-baseline.test.ts b/tests/unit/schema-baseline.test.ts index 727e76b8..9075cbf8 100644 --- a/tests/unit/schema-baseline.test.ts +++ b/tests/unit/schema-baseline.test.ts @@ -19,6 +19,12 @@ describe("Schema baseline", () => { expect(files.at(-1)).toBe(LATEST_SCHEMA_MIGRATION); }); + it("keeps migration numbers unique", () => { + const numbers = files.map((file) => /^(\d+)_/u.exec(file)?.[1]); + expect(numbers.every((number) => number !== undefined)).toBe(true); + expect(new Set(numbers).size).toBe(numbers.length); + }); + // Not every migration creates a table (0005 only backfills rows), so this // scans the whole history rather than assuming the latest file does it. it("actually creates the table /healthz checks, somewhere in the migration history", () => { From dec3a7da754df84dd42ef39d172b89dd7c74b500 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 19:14:26 +0200 Subject: [PATCH 13/16] =?UTF-8?q?=F0=9F=90=9B=20fix(realtime):=20compare-a?= =?UTF-8?q?nd-delete=20drains,=20drain=20background=20commits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #387. - Drain races - Reconcile local DO commits while D1 leases are active. - Keep newer D1 revisions queued with compare-and-delete cleanup. - Background commits - Share an outer-finally drain across waitUntil tasks and DO alarms. - Regression coverage - Exercise both lease interleavings and stream readback failure after commit. - Guard the source registry against unwrapped waitUntil paths. --- src/server-timing.ts | 19 +-- src/worker/auth/routes.ts | 23 ++-- src/worker/durable/ChannelObject.ts | 22 ++-- src/worker/panel-resources.ts | 60 ++++++--- src/worker/scheduled.ts | 12 +- tests/unit/panel-resource-revisions.test.ts | 133 ++++++++++++++++++-- tests/unit/panel-routes.test.ts | 79 ++++++++++++ 7 files changed, 284 insertions(+), 64 deletions(-) diff --git a/src/server-timing.ts b/src/server-timing.ts index daafb3d7..5fb532a5 100644 --- a/src/server-timing.ts +++ b/src/server-timing.ts @@ -1,3 +1,5 @@ +import { schedulePanelResourceBackgroundWork } from "./worker/panel-resources"; + export type ServerTimingPhase = "auth" | "d1" | "do" | "helix"; export type ServerTimingRecord = Record; @@ -37,14 +39,15 @@ export const measureServerTiming = async ( }; export const scheduleBackgroundWork = ( - context: { executionCtx: { waitUntil: (promise: Promise) => void } }, + context: { + env: Pick; + executionCtx: { waitUntil: (promise: Promise) => void }; + }, work: Promise, ): void => { - try { - context.executionCtx.waitUntil(work); - } catch { - // Direct Hono unit requests have no ExecutionContext; keep their work - // observed while production requests always use waitUntil above. - void work.catch((error: unknown) => { console.warn("Background dashboard work failed.", error); }); - } + void schedulePanelResourceBackgroundWork( + context.env, + work, + () => context.executionCtx, + ); }; diff --git a/src/worker/auth/routes.ts b/src/worker/auth/routes.ts index 103f0566..64a0ff46 100644 --- a/src/worker/auth/routes.ts +++ b/src/worker/auth/routes.ts @@ -74,7 +74,7 @@ import { hydrateModuleOverlayElements } from "../overlays/module-state"; import { hydrateCachedAdsCountdownSnapshot } from "../overlays/ads-countdown-cache"; import { ADS_COUNTDOWN_ELEMENT_KIND } from "../../modules/ads/overlay/kinds"; import { createOverlayElementContext } from "../overlays/element-context"; -import { notifyCommittedResources } from "../panel-resources"; +import { notifyCommittedResources, schedulePanelResourceBackgroundWork } from "../panel-resources"; const nowIso = (): string => new Date().toISOString(); const OVERLAY_VARIABLE_NAME_PATTERN = /^[a-z][a-z0-9_]{0,31}$/u; @@ -567,12 +567,11 @@ authRouter.get("/auth/twitch/callback", async (context) => { }, "connected", null, now); await notifyCommittedResources(context.env); const maintenance = maintainAfterBotAuthorization(context.env, now); - try { - context.executionCtx.waitUntil(maintenance); - } catch { - // In tests or other runtimes without an ExecutionContext, the work continues running anyway. - void maintenance; - } + void schedulePanelResourceBackgroundWork( + context.env, + maintenance, + () => context.executionCtx, + ); return context.redirect(redirectHome(context.env.PUBLIC_ORIGIN), 302); } @@ -654,11 +653,11 @@ authRouter.get("/auth/twitch/callback", async (context) => { context.header("Set-Cookie", serializeSessionCookie(cookie)); if (state.reconcileEventSub) { const maintenance = maintainAfterBroadcasterAuthorization(context.env, now); - try { - context.executionCtx.waitUntil(maintenance); - } catch { - void maintenance; - } + void schedulePanelResourceBackgroundWork( + context.env, + maintenance, + () => context.executionCtx, + ); } return context.redirect(redirectAfterLogin(context.env.PUBLIC_ORIGIN, transaction.redirectPath), 302); } catch (error) { diff --git a/src/worker/durable/ChannelObject.ts b/src/worker/durable/ChannelObject.ts index 5ba4b94b..15f33308 100644 --- a/src/worker/durable/ChannelObject.ts +++ b/src/worker/durable/ChannelObject.ts @@ -62,6 +62,7 @@ import { panelResourceChangedMessage, readPanelResourceRevisions, type PanelResourceRevisionVector, + withCommittedPanelResourceDrain, } from "../panel-resources"; import { createModuleSecretAccess } from "../module-secrets"; import { sendChatMessage } from "../chat"; @@ -1589,11 +1590,10 @@ export class ChannelObject extends DurableObject { undefined, invocation, ); - try { - return await this.runModuleAlarmHandler(moduleId, handlerKey, execute); - } finally { - await this.notifyCommittedPanelResources(); - } + return await withCommittedPanelResourceDrain( + () => this.runModuleAlarmHandler(moduleId, handlerKey, execute), + () => this.notifyCommittedPanelResources(), + ); } private ballotAccess(moduleId: string): ModuleBallotAccess { @@ -2738,16 +2738,14 @@ export class ChannelObject extends DurableObject { } override async alarm(): Promise { - const now = Date.now(); - const nowIso = new Date(now).toISOString(); - const externalFetchBudget = createModuleExternalFetchBudget(); - try { + await withCommittedPanelResourceDrain(async () => { + const now = Date.now(); + const nowIso = new Date(now).toISOString(); + const externalFetchBudget = createModuleExternalFetchBudget(); await this.pruneRevokedTokenMarkers(now); await this.stopSecurityAlarmIfIdle(); await this.dispatchDueAlarmEntries(now, nowIso, this.alarmHandlers(MODULES, externalFetchBudget)); - } finally { - await this.notifyCommittedPanelResources(); - } + }, () => this.notifyCommittedPanelResources()); } override webSocketMessage(webSocket: WebSocket, message: string | ArrayBuffer): void { diff --git a/src/worker/panel-resources.ts b/src/worker/panel-resources.ts index 8b130c84..f67f9628 100644 --- a/src/worker/panel-resources.ts +++ b/src/worker/panel-resources.ts @@ -50,8 +50,9 @@ export const PANEL_RESOURCE_NOTIFIER_COVERAGE = { nonPanelAllRoutes: ["ALL /api/*", "ALL /*"], eventSubRoute: "/api/twitch/eventsub", eventSubHandler: "eventSubRouter notification/revocation finally", - channelAlarm: "ChannelObject.alarm.finally", - scheduledMaintenance: "scheduled.finally", + channelAlarm: "ChannelObject.alarm with committed-resource drain", + scheduledMaintenance: "scheduled background drain helper", + backgroundCommitScheduler: "schedulePanelResourceBackgroundWork", } as const; const reconcileChannelResources = async ( @@ -135,13 +136,6 @@ const claimPendingChannels = async ( return { channelIds, claimToken }; }; -const channelHasPendingResources = async (database: D1Database, channelId: string): Promise => { - const result = await database.prepare( - "SELECT 1 AS queued FROM panel_resource_pending WHERE channel_id = ? LIMIT 1", - ).bind(channelId).all<{ queued: number }>(); - return result.results.some(({ queued }) => queued === 1); -}; - const reconcileClaimedChannels = async ( env: Pick, claim: ClaimedPanelChannels, @@ -224,7 +218,6 @@ export const notifyCommittedResources = async ( reconcileLocal?: (revisions: PanelResourceRevisionVector) => Promise, ): Promise => { let localChannelWasReconciled = false; - let queueDrainFailed = false; try { let claim = await claimPendingChannels(env.DB, channelId); while (claim.channelIds.length > 0) { @@ -233,20 +226,15 @@ export const notifyCommittedResources = async ( claim = await claimPendingChannels(env.DB, channelId); } } catch (error: unknown) { - queueDrainFailed = true; console.warn("Committed panel resources could not be claimed.", errorMessage(error)); } if (channelId !== undefined && reconcileLocal !== undefined && !localChannelWasReconciled) { let revisions: PanelResourceRevisionVector = {}; try { - // DO-only commits have no D1 queue row. Reconcile them locally; if a - // D1 row is leased by another drain, that drain owns its publication. - if (!queueDrainFailed && await channelHasPendingResources(env.DB, channelId)) return; - } catch (error: unknown) { - console.warn("Committed panel resources could not be checked locally.", channelId, errorMessage(error)); - } - try { + // DO-only commits have no D1 queue row. Always reconcile locally when + // this call did not claim the channel: a D1 row may be leased by a drain + // that captured an older revision and will compare-and-delete it later. revisions = await readPanelResourceRevisions(env.DB, channelId); } catch (error: unknown) { // Local DO revisions are independent of D1 and still need a same-DO @@ -261,6 +249,42 @@ export const notifyCommittedResources = async ( } }; +/** Wraps every deferred task that can commit dashboard data with an outer drain. */ +export const withCommittedPanelResourceDrain = async ( + work: () => Promise, + drain: () => Promise, +): Promise => { + try { + return await work(); + } finally { + try { + await drain(); + } catch (error: unknown) { + console.warn("Background panel resource drain failed.", errorMessage(error)); + } + } +}; + +export const schedulePanelResourceBackgroundWork = ( + env: Pick, + work: Promise, + executionContext: () => { waitUntil: (promise: Promise) => void }, +): Promise => { + const wrappedWork = withCommittedPanelResourceDrain( + () => work, + () => notifyCommittedResources(env), + ); + try { + executionContext().waitUntil(wrappedWork); + } catch (error: unknown) { + console.warn("Background work could not be registered with the execution context.", errorMessage(error)); + void wrappedWork.catch((workError: unknown) => { + console.warn("Background panel work failed without an execution context.", errorMessage(workError)); + }); + } + return wrappedWork; +}; + export const panelResourceChangedMessage = ( channelId: string, resources: readonly string[], diff --git a/src/worker/scheduled.ts b/src/worker/scheduled.ts index 032e500d..6cbc1ae5 100644 --- a/src/worker/scheduled.ts +++ b/src/worker/scheduled.ts @@ -11,7 +11,7 @@ import { eventSubMessageCutoff } from "./eventsub"; import { maintainStreamStates } from "./stream-state-lookup"; import { purgeRevokedOverlayAccesses } from "./auth/overlay-access-repository"; import { MODULES } from "../modules/registry"; -import { notifyCommittedResources } from "./panel-resources"; +import { schedulePanelResourceBackgroundWork } from "./panel-resources"; export const scheduled: NonNullable["scheduled"]> = async ( _controller, @@ -43,10 +43,10 @@ export const scheduled: NonNullable["scheduled"]> = async ( .then((results) => { const rejected = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); if (rejected !== undefined) throw rejected.reason; - }) - .finally(async () => { - await notifyCommittedResources(env); }); - executionContext.waitUntil(work); - await work; + await schedulePanelResourceBackgroundWork( + env, + work, + () => executionContext, + ); }; diff --git a/tests/unit/panel-resource-revisions.test.ts b/tests/unit/panel-resource-revisions.test.ts index 056198d9..43e7d94c 100644 --- a/tests/unit/panel-resource-revisions.test.ts +++ b/tests/unit/panel-resource-revisions.test.ts @@ -1,5 +1,5 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { readdirSync, readFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; @@ -65,6 +65,12 @@ const addChannel = async (database: TestD1Database, channelId: string): Promise< ).bind(channelId, channelId, channelId).run(); }; +const sourceFilesUnder = (directory: string): string[] => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) return sourceFilesUnder(path); + return /\.(?:ts|tsx)$/u.test(entry.name) ? [path] : []; +}); + interface PanelWriteRegressionCase { name: string; keys: (channelId: string) => readonly (readonly unknown[])[]; @@ -361,19 +367,20 @@ describe("panel resource revisions", () => { expect(authRouteSource).toMatch(/authRouter\.get\("\/api\/overlay\/bootstrap"[\s\S]*?finally \{[\s\S]*?notifyCommittedResources\(context\.env, record\.channelId\)/u); expect(moduleAlarms.length).toBeGreaterThan(0); expect(maintenanceJobs.length).toBeGreaterThan(0); - expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelAlarm).toBe("ChannelObject.alarm.finally"); - expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.scheduledMaintenance).toBe("scheduled.finally"); + expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelAlarm).toBe("ChannelObject.alarm with committed-resource drain"); + expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.scheduledMaintenance).toBe("scheduled background drain helper"); expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.eventSubHandler).toBe("eventSubRouter notification/revocation finally"); const channelObject = readFileSync(resolve(root, "src/worker/durable/ChannelObject.ts"), "utf8"); expect(channelObject).toContain("moduleAlarmHandlerEntries(modules)"); const alarm = /override async alarm\(\): Promise\s+\{([\s\S]*?)\n\s+\}\n\n\s+override webSocketMessage/u.exec(channelObject)?.[1] ?? ""; - expect(alarm).toMatch(/finally \{\s*await this\.notifyCommittedPanelResources\(\);/u); + expect(alarm).toContain("withCommittedPanelResourceDrain(async () => {"); + expect(alarm).toContain("() => this.notifyCommittedPanelResources()"); expect(alarm).toContain("this.alarmHandlers(MODULES"); const alarmStart = channelObject.indexOf("public async runModuleAlarm("); const alarmEnd = channelObject.indexOf("private ballotAccess(", alarmStart); const directAlarm = alarmStart < 0 || alarmEnd < 0 ? "" : channelObject.slice(alarmStart, alarmEnd); - expect(directAlarm).toMatch(/finally \{\s*await this\.notifyCommittedPanelResources\(\);/u); + expect(directAlarm).toContain("withCommittedPanelResourceDrain("); const alarmStorageStart = channelObject.indexOf("storage: {", channelObject.indexOf("private moduleAlarmContext(")); const alarmStorageEnd = channelObject.indexOf("schedule:", alarmStorageStart); const alarmStorage = alarmStorageStart < 0 || alarmStorageEnd < 0 @@ -382,14 +389,29 @@ describe("panel resource revisions", () => { expect(alarmStorage).toContain("this.ctx.storage.transaction"); expect(alarmStorage).toContain("bumpDurablePanelResources(transaction, [`module:${moduleId}:data`])"); const scheduled = readFileSync(resolve(root, "src/worker/scheduled.ts"), "utf8"); - expect(scheduled).toContain(".finally(async () => {"); - expect(scheduled).toContain("await notifyCommittedResources(env);"); + expect(scheduled).toContain("schedulePanelResourceBackgroundWork("); const worker = readFileSync(resolve(root, "src/worker/app-routes.ts"), "utf8"); expect(worker).toContain("app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes"); expect(worker).toContain("app.use(PANEL_RESOURCE_NOTIFIER_COVERAGE.platformRoutes"); expect(worker).toMatch(/finally \{\s*const channelId = context\.req\.param\("channelId"\);\s*if \(channelId\.length > 0 && authorizedContextHas\(context, "session"\)\) \{\s*await notifyCommittedResources\(context\.env\);/u); expect(worker).not.toContain("PANEL_RESOURCE_NOTIFIER_COVERAGE.authRoutes"); expect(worker).not.toMatch(/context\.res\.status !== 401/u); + const deferredSources = new Map(sourceFilesUnder(resolve(root, "src")).map((path) => [ + relative(root, path), readFileSync(path, "utf8"), + ])); + const unwrappedWaitUntilFiles = [...deferredSources] + .filter(([, source]) => /\.waitUntil\s*\(/u.test(source)) + .map(([path]) => path); + expect(unwrappedWaitUntilFiles).toEqual(["src/worker/panel-resources.ts"]); + expect(deferredSources.get("src/worker/panel-resources.ts")).toMatch( + /withCommittedPanelResourceDrain = async[\s\S]*?finally \{\s*try \{\s*await drain\(\);/u, + ); + expect(PANEL_RESOURCE_NOTIFIER_COVERAGE.backgroundCommitScheduler).toBe("schedulePanelResourceBackgroundWork"); + for (const path of ["src/server-timing.ts", "src/worker/auth/routes.ts", "src/worker/scheduled.ts"]) { + expect(deferredSources.get(path), `${path} must wrap deferred commits`) + .toContain(`${PANEL_RESOURCE_NOTIFIER_COVERAGE.backgroundCommitScheduler}(`); + } + expect(deferredSources.get("src/worker/panel-resources.ts")).toContain("executionContext().waitUntil(wrappedWork)"); const rootWorker = readFileSync(resolve(root, "src/worker/worker-app.ts"), "utf8"); expect(rootWorker).toContain('app.get("/healthz"'); expect(rootWorker).toContain('app.all("/api/*"'); @@ -623,6 +645,101 @@ describe("panel resource revisions", () => { } }); + it("reconciles a newer DO revision while an older D1 delivery is leased", async () => { + const database = new TestD1Database(); + let releaseExternal!: () => void; + let externalStarted!: () => void; + const externalGate = new Promise((resolve) => { releaseExternal = resolve; }); + const externalStart = new Promise((resolve) => { externalStarted = resolve; }); + let doRevision = 1; + let deliveredDoRevision = 0; + const reconcilePanelResources = vi.fn(async () => { + deliveredDoRevision = Math.max(deliveredDoRevision, doRevision); + if (reconcilePanelResources.mock.calls.length === 1) { + externalStarted(); + await externalGate; + } + }); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => ({ reconcilePanelResources }), + }, + } as unknown as Env; + try { + await addChannel(database, "leased-do-revision"); + await database.prepare("DELETE FROM panel_resource_pending").run(); + await database.prepare("UPDATE channels SET display_name = 'First' WHERE channel_id = ?") + .bind("leased-do-revision").run(); + + const externalDrain = notifyCommittedResources(env); + await externalStart; + expect(deliveredDoRevision).toBe(1); + + doRevision = 2; + const localReconciliation = vi.fn(() => { + deliveredDoRevision = Math.max(deliveredDoRevision, doRevision); + return Promise.resolve(); + }); + await notifyCommittedResources(env, "leased-do-revision", localReconciliation); + + expect(localReconciliation).toHaveBeenCalledTimes(1); + expect(deliveredDoRevision).toBe(2); + releaseExternal(); + await externalDrain; + expect(deliveredDoRevision).toBe(2); + expect((await database.prepare( + "SELECT COUNT(*) AS count FROM panel_resource_pending WHERE channel_id = ?", + ).bind("leased-do-revision").first<{ count: number }>())?.count).toBe(0); + } finally { + releaseExternal(); + database.close(); + } + }); + + it("compare-and-deletes only the D1 revision an earlier drain published", async () => { + const database = new TestD1Database(); + let releaseExternal!: () => void; + let externalStarted!: () => void; + const externalGate = new Promise((resolve) => { releaseExternal = resolve; }); + const externalStart = new Promise((resolve) => { externalStarted = resolve; }); + const reconcilePanelResources = vi.fn(async () => { + if (reconcilePanelResources.mock.calls.length === 1) { + externalStarted(); + await externalGate; + } + }); + const env = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (channelId: string) => channelId, + get: () => ({ reconcilePanelResources }), + }, + } as unknown as Env; + try { + await addChannel(database, "compare-delete"); + await database.prepare("DELETE FROM panel_resource_pending").run(); + await database.prepare("UPDATE channels SET display_name = 'First' WHERE channel_id = ?") + .bind("compare-delete").run(); + + const externalDrain = notifyCommittedResources(env); + await externalStart; + await database.prepare("UPDATE channels SET display_name = 'Second' WHERE channel_id = ?") + .bind("compare-delete").run(); + releaseExternal(); + await externalDrain; + + expect(reconcilePanelResources).toHaveBeenCalledTimes(2); + expect((await database.prepare( + "SELECT COUNT(*) AS count FROM panel_resource_pending WHERE channel_id = ?", + ).bind("compare-delete").first<{ count: number }>())?.count).toBe(0); + } finally { + releaseExternal(); + database.close(); + } + }); + it("does not fan out queued revisions from an unauthenticated channel request", async () => { const database = new TestD1Database(); const getChannel = vi.fn(); diff --git a/tests/unit/panel-routes.test.ts b/tests/unit/panel-routes.test.ts index 29a67bba..c07bc2cf 100644 --- a/tests/unit/panel-routes.test.ts +++ b/tests/unit/panel-routes.test.ts @@ -129,6 +129,7 @@ const makeStreamRefreshRuntime = (lease: string | null = "stream-refresh") => { beginStreamStateRefresh: vi.fn(() => Promise.resolve(lease)), endStreamStateRefresh: vi.fn(() => Promise.resolve()), getTwitchRateLimitRetryAfter: vi.fn(() => Promise.resolve(retryAfter !== null && retryAfter > Date.now() ? retryAfter : null)), + reconcilePanelResources: vi.fn<(revisions: Readonly>) => Promise>(() => Promise.resolve()), setTwitchRateLimitRetryAfter: vi.fn((deadline: number) => { retryAfter = deadline; return Promise.resolve(); @@ -461,6 +462,84 @@ describe("Panel read endpoints", () => { expect(requestedUrl(fetcher.mock.calls[0]?.[0])).toContain("/helix/streams?"); }); + it("drains a committed background stream refresh when its readback fails after the response", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-23T12:00:00.000Z")); + await insertChannel(database, "kanal-a", "Alpha"); + await insertLoginIdentityAndSession(database, "user-1"); + await insertMember(database, "kanal-a", "user-1", "manager"); + await database.prepare( + `INSERT INTO channel_stream_state (channel_id, state, changed_at, source, started_at) + VALUES ('kanal-a', 'online', '2026-09-23T11:49:00.000Z', 'helix', '2026-09-23T09:00:00.000Z')`, + ).run(); + await insertAppAccessToken( + database, + await encryptJson({ token: "app-token" }, parseKeyRing(environmentKeys.SESSION_ENCRYPTION_KEYS)), + "2099-09-21T00:00:00.000Z", + "2026-09-18T00:00:00.000Z", + "2026-09-18T00:00:00.000Z", + ); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [] }), { status: 200 }), + )); + + let streamStateReads = 0; + const databaseWithFailedReadback = new Proxy(database, { + get(target, property) { + if (property === "prepare") { + return (sql: string) => { + const statement = target.prepare(sql); + const normalized = sql.replace(/\s+/gu, " ").trim(); + if (normalized !== "SELECT state, source, changed_at, started_at, stream_id, checked_at FROM channel_stream_state WHERE channel_id = ?") { + return statement; + } + return new Proxy(statement, { + get(statementTarget, statementProperty, receiver) { + if (statementProperty === "first") { + return async (): Promise => { + streamStateReads += 1; + if (streamStateReads === 2) throw new Error("stream state readback failed"); + return await statementTarget.first(); + }; + } + return Reflect.get(statementTarget, statementProperty, receiver) as unknown; + }, + }); + }; + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) as unknown : value; + }, + }) as unknown as D1Database; + const runtime = makeStreamRefreshRuntime(); + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const response = await panelRouter.fetch( + await makeRequest("user-1", "/api/channels/kanal-a/overview"), + { + ...environment, + ...runtime, + DB: databaseWithFailedReadback, + TWITCH_CLIENT_ID: "client-id", + TWITCH_CLIENT_SECRET: "client-secret", + }, + runtime.executionContext, + ); + + expect(response.status).toBe(200); + await Promise.all(runtime.tasks); + expect(streamStateReads).toBe(2); + expect((await database.prepare( + "SELECT state FROM channel_stream_state WHERE channel_id = 'kanal-a'", + ).first<{ state: string }>())?.state).toBe("offline"); + const deliveredRevisions = runtime.channelObject.reconcilePanelResources.mock.calls[0]?.[0]; + expect(deliveredRevisions?.["channel.overview"]).toBeGreaterThan(0); + expect((await database.prepare( + "SELECT COUNT(*) AS count FROM panel_resource_pending WHERE channel_id = 'kanal-a'", + ).first<{ count: number }>())?.count).toBe(0); + warning.mockRestore(); + }); + it("serves stored overview controls while Helix binds a pending pause in the background", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-23T12:00:00.000Z")); From d05869ee9a3fb4719c1107b11eb6e2208c8fe598 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 19:54:37 +0200 Subject: [PATCH 14/16] =?UTF-8?q?=F0=9F=90=9B=20fix(realtime):=20mark=20lo?= =?UTF-8?q?cal=20reconciliation=20only=20after=20success?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark local delivery successful only after revision reads and reconciliation complete. - Recover local DO revisions after a claimed delivery fails. - Add the D1/DO interleaving regression test (Refs #387). --- src/worker/panel-resources.ts | 8 ++-- tests/unit/panel-resource-revisions.test.ts | 48 ++++++++++++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/worker/panel-resources.ts b/src/worker/panel-resources.ts index f67f9628..584c58c2 100644 --- a/src/worker/panel-resources.ts +++ b/src/worker/panel-resources.ts @@ -152,7 +152,6 @@ const reconcileClaimedChannels = async ( const channelId = claim.channelIds[next++]; if (channelId === undefined) continue; const useLocalReconciliation = channelId === localChannelId && reconcileLocal !== undefined; - if (useLocalReconciliation) localChannelWasReconciled = true; try { await reconcileChannelResources( env, @@ -160,6 +159,7 @@ const reconcileClaimedChannels = async ( claim.claimToken, useLocalReconciliation ? reconcileLocal : undefined, ); + if (useLocalReconciliation) localChannelWasReconciled = true; } catch (error: unknown) { // Keep the lease after a failed delivery. A later drain can reclaim // it after expiry without duplicating an in-flight Durable Object call. @@ -232,9 +232,9 @@ export const notifyCommittedResources = async ( if (channelId !== undefined && reconcileLocal !== undefined && !localChannelWasReconciled) { let revisions: PanelResourceRevisionVector = {}; try { - // DO-only commits have no D1 queue row. Always reconcile locally when - // this call did not claim the channel: a D1 row may be leased by a drain - // that captured an older revision and will compare-and-delete it later. + // DO-only commits have no D1 queue row. Also recover a claimed local + // delivery that failed before reconciliation completed; its D1 row may + // stay leased, but local revisions still need to reach connected panels. revisions = await readPanelResourceRevisions(env.DB, channelId); } catch (error: unknown) { // Local DO revisions are independent of D1 and still need a same-DO diff --git a/tests/unit/panel-resource-revisions.test.ts b/tests/unit/panel-resource-revisions.test.ts index 43e7d94c..80d882fa 100644 --- a/tests/unit/panel-resource-revisions.test.ts +++ b/tests/unit/panel-resource-revisions.test.ts @@ -17,7 +17,7 @@ import { moduleRouter } from "../../src/worker/panel/module-routes"; import { platformRouter } from "../../src/worker/platform/routes"; import { app } from "../../src/worker/worker-app"; import { createSessionCookie } from "../../src/worker/auth/session"; -import { notifyCommittedResources, panelResourceChangedMessage, PANEL_RESOURCE_NOTIFIER_COVERAGE, readCompletePanelResourceRevisions, readPanelResourceRevisions } from "../../src/worker/panel-resources"; +import { notifyCommittedResources, panelResourceChangedMessage, PANEL_RESOURCE_NOTIFIER_COVERAGE, readCompletePanelResourceRevisions, readPanelResourceRevisions, type PanelResourceRevisionVector } from "../../src/worker/panel-resources"; import { insertChannel, insertLoginIdentityAndSession, insertMember, testKey } from "./fixtures"; import { TestD1Database } from "./test-d1"; @@ -645,6 +645,52 @@ describe("panel resource revisions", () => { } }); + it("recovers a local DO-only revision when a claimed D1 revision read fails", async () => { + const database = new TestD1Database(); + let failRevisionRead = true; + const d1Binding = { + prepare: (sql: string) => { + if (failRevisionRead && /SELECT resource, revision[\s\S]*FROM panel_resource_revisions/u.test(sql)) { + failRevisionRead = false; + throw new Error("temporary panel resource revision read failure"); + } + return database.prepare(sql); + }, + batch: database.batch.bind(database), + } as unknown as D1Database; + const doOnlyRevision = { "module:chat_voting:panel": 1 }; + const deliveredRevisions: PanelResourceRevisionVector[] = []; + const reconcileLocal = vi.fn((d1Revisions: PanelResourceRevisionVector): Promise => { + deliveredRevisions.push({ ...d1Revisions, ...doOnlyRevision }); + return Promise.resolve(); + }); + const env = { + DB: d1Binding, + CHANNEL: { idFromName: (channelId: string) => channelId, get: vi.fn() }, + } as unknown as Env; + + try { + await addChannel(database, "claimed-local-read-failure"); + await database.prepare("UPDATE channels SET display_name = 'Changed' WHERE channel_id = ?") + .bind("claimed-local-read-failure").run(); + + await notifyCommittedResources(env, "claimed-local-read-failure", reconcileLocal); + + expect(failRevisionRead).toBe(false); + expect(reconcileLocal).toHaveBeenCalledTimes(1); + expect(deliveredRevisions).toHaveLength(1); + expect(deliveredRevisions[0]?.["channel.overview"]).toBeGreaterThan(0); + expect(deliveredRevisions[0]?.["module:chat_voting:panel"]).toBe(1); + const pending = await database.prepare( + "SELECT claimed_until, claim_token FROM panel_resource_pending WHERE channel_id = ? LIMIT 1", + ).bind("claimed-local-read-failure").first<{ claimed_until: number; claim_token: string }>(); + expect(pending?.claimed_until).toBeGreaterThan(Date.now()); + expect(pending?.claim_token).toEqual(expect.any(String)); + } finally { + database.close(); + } + }); + it("reconciles a newer DO revision while an older D1 delivery is leased", async () => { const database = new TestD1Database(); let releaseExternal!: () => void; From 3fad21e520014058e29d11d212832133438469a8 Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 20:39:22 +0200 Subject: [PATCH 15/16] =?UTF-8?q?=F0=9F=90=9B=20fix(realtime):=20panel=20r?= =?UTF-8?q?eads=20never=20commit=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keep panel GETs read-only across core and module routes. - Back off failed BELABOX samples and add an explicit manager schedule refresh. - Cover GET revision invariants and bounded realtime requests for #387. --- src/modules/ads/panel/index.tsx | 30 +++- src/modules/ads/panel/locale.ts | 12 ++ src/modules/ads/panel/service.ts | 10 ++ src/modules/ads/routes.ts | 66 ++++---- src/modules/api_source/adapters/fetch-json.ts | 12 ++ src/modules/api_source/index.ts | 9 +- src/modules/belabox/adapters/d1.ts | 10 +- src/modules/belabox/routes.ts | 13 -- src/modules/belabox/service.ts | 18 ++- src/modules/chat_voting/routes.ts | 7 - src/modules/contract.ts | 3 + src/modules/text_library/routes.ts | 2 +- src/modules/votekick/routes.ts | 4 - src/modules/weather/index.ts | 7 +- src/worker/panel/module-routes.ts | 3 +- src/worker/panel/routes.ts | 29 +--- tests/components/ads-panel.test.tsx | 3 + tests/unit/ads-routes.test.ts | 127 +++++++-------- tests/unit/belabox-polling.test.ts | 28 +++- tests/unit/belabox.test.ts | 18 ++- tests/unit/chat-voting-routes.test.ts | 4 +- tests/unit/panel-resource-revisions.test.ts | 149 +++++++++++++++++- tests/unit/panel-routes.test.ts | 73 ++++----- tests/unit/votekick-routes.test.ts | 6 +- 24 files changed, 426 insertions(+), 217 deletions(-) diff --git a/src/modules/ads/panel/index.tsx b/src/modules/ads/panel/index.tsx index 25c0b569..00aeaf6a 100644 --- a/src/modules/ads/panel/index.tsx +++ b/src/modules/ads/panel/index.tsx @@ -3,7 +3,7 @@ import { useRef, useState, type ReactElement } from "react"; import type { DashboardLanguage } from "../../../dashboard/locale"; import type { AdsScheduleResponse } from "../contracts"; import { Button, EmptyCellValue, Icon, LoadState, Skeleton, notify } from "../../../dashboard/ui"; -import { loadAdsSchedule, snoozeAds } from "./service"; +import { loadAdsSchedule, refreshAdsSchedule, snoozeAds } from "./service"; import { adsPanelTexts } from "./locale"; import { moduleQueryKey, runModuleQueryWrite, useDashboardQueryError, useModuleQuery } from "../../../dashboard/data"; import { useDashboardQueryClient } from "../../../dashboard/data"; @@ -19,7 +19,7 @@ const formatTimestamp = (value: string | null, language: DashboardLanguage): str }).format(date); }; -export const AdsPanel = ({ channelId, language = "de" }: { channelId: string; language?: DashboardLanguage }): ReactElement => { +export const AdsPanel = ({ channelId, language = "de", canManage = false }: { channelId: string; language?: DashboardLanguage; canManage?: boolean }): ReactElement => { const labels = adsPanelTexts(language); const loadError = labels.loadError; const realtimeStatus = useDashboardRealtimeStatus(channelId); @@ -48,6 +48,7 @@ export const AdsPanel = ({ channelId, language = "de" }: { channelId: string; la ? scheduleQuery.isPending ? "loading" : scheduleLoadFailed ? "error" : "loading" : "success"; const [snoozeBusy, setSnoozeBusy] = useState(false); + const [refreshBusy, setRefreshBusy] = useState(false); const snoozeCount = schedule?.schedule.snoozeCount ?? null; const snoozeButtonDisabled = schedule === null || snoozeBusy || !schedule.snoozeScopeAvailable || snoozeCount === null || snoozeCount <= 0; @@ -76,9 +77,33 @@ export const AdsPanel = ({ channelId, language = "de" }: { channelId: string; la setSnoozeBusy(false); } }; + const refreshSchedule = async (): Promise => { + if (!canManage || refreshBusy) return; + setRefreshBusy(true); + try { + await runModuleQueryWrite(queryClient, channelId, "ads", "schedule", () => refreshAdsSchedule(channelId), { + baselineRevision: null, + updateCache: (_current, result) => result, + }); + notify({ tone: "success", message: labels.refreshSuccess }); + } catch { + notify({ tone: "error", message: labels.refreshError }); + } finally { + setRefreshBusy(false); + } + }; return (
+
+

{labels.scheduleSection}

+ +
+

+ {canManage ? "" : labels.refreshReadOnly} +

@@ -91,7 +116,6 @@ export const AdsPanel = ({ channelId, language = "de" }: { channelId: string; la refreshError={schedule !== null && scheduleLoadFailed}> {schedule === null ? null : <>
-

{labels.scheduleSection}

{schedule.asOf === undefined ? "" : labels.asOf(formatTimestamp(schedule.asOf, language))} diff --git a/src/modules/ads/panel/locale.ts b/src/modules/ads/panel/locale.ts index cff3ae2a..7ce9255f 100644 --- a/src/modules/ads/panel/locale.ts +++ b/src/modules/ads/panel/locale.ts @@ -21,6 +21,10 @@ export interface AdsPanelTexts { recentTime: (timestamp: string) => string; loading: string; loadError: string; + refreshSchedule: string; + refreshReadOnly: string; + refreshSuccess: string; + refreshError: string; immediateTitle: string; immediateLength: string; immediateLengthHint: string; @@ -51,6 +55,10 @@ const panelCatalog: LocaleCatalog = { recentTime: (timestamp) => timestamp, loading: "Werbeplan wird geladen …", loadError: "Der Werbeplan konnte nicht geladen werden.", + refreshSchedule: "Werbeplan aktualisieren", + refreshReadOnly: "Nur Broadcaster und Verwalter dürfen den Werbeplan aktualisieren.", + refreshSuccess: "Der Werbeplan wurde aktualisiert.", + refreshError: "Der Werbeplan konnte nicht aktualisiert werden.", immediateTitle: "Werbung", immediateLength: "Werbedauer", immediateLengthHint: "Sekunden. Startet sofort.", @@ -79,6 +87,10 @@ const panelCatalog: LocaleCatalog = { recentTime: (timestamp) => timestamp, loading: "Loading the ad schedule …", loadError: "The ad schedule could not be loaded.", + refreshSchedule: "Refresh ad schedule", + refreshReadOnly: "Only broadcasters and managers may refresh the ad schedule.", + refreshSuccess: "The ad schedule was refreshed.", + refreshError: "The ad schedule could not be refreshed.", immediateTitle: "Ads", immediateLength: "Ad length", immediateLengthHint: "Seconds. Starts immediately.", diff --git a/src/modules/ads/panel/service.ts b/src/modules/ads/panel/service.ts index 637a2407..2816b7b0 100644 --- a/src/modules/ads/panel/service.ts +++ b/src/modules/ads/panel/service.ts @@ -41,6 +41,16 @@ export const loadAdsSchedule = async (channelId: string, signal?: AbortSignal): }; }; +export const refreshAdsSchedule = async (channelId: string): Promise => { + const csrfResponse = await fetch("/api/csrf"); + const csrf = await json<{ token: string }>(csrfResponse); + const response = await fetch(`${schedulePathFor(channelId)}/refresh`, { + method: "POST", + headers: { "X-CSRF-Token": csrf.token }, + }); + return json(response); +}; + export const snoozeAds = async (channelId: string): Promise => { const csrfResponse = await fetch("/api/csrf"); const csrf = await json<{ token: string }>(csrfResponse); diff --git a/src/modules/ads/routes.ts b/src/modules/ads/routes.ts index 346ae7b5..4dc0f4dc 100644 --- a/src/modules/ads/routes.ts +++ b/src/modules/ads/routes.ts @@ -1,6 +1,6 @@ import { Hono, type Context } from "hono"; -import type { EventCode } from "../../contracts/values"; +import { canManage, type EventCode } from "../../contracts/values"; import { apiErrorDetail, type ModuleRouteEnvironment } from "../contract"; import { snoozeNextAd, type SnoozeNextAdResult } from "./adapters/ad-schedule"; import type { AdsSchedule } from "./contracts"; @@ -22,7 +22,6 @@ const statusFor = (reason: string | null): 403 | 429 | 502 | 503 => { return 502; }; -const SCHEDULE_CACHE_TTL_MS = 60_000; interface ScheduleCacheValue { schedule: AdsSchedule; asOf: string } const scheduleStatusFor = (reason: string | null): 403 | 429 | 502 | 503 => @@ -80,50 +79,53 @@ export const adsRoutes = new Hono(); adsRoutes.get("/schedule", async (context) => { const channelId = context.req.param("channelId") ?? ""; const object = context.env.CHANNEL.get(context.env.CHANNEL.idFromName(channelId)); - const [cached, details] = await Promise.all([ + const [cached, details, retryAfter] = await Promise.all([ context.get("measureServerTiming")("do", () => object.getCachedAdSchedule()), context.get("measureServerTiming")("d1", () => Promise.all([ listLastAdBreaks(context.env.DB, channelId), context.get("broadcasterScopesForChannel")(context.env.DB, channelId), ])), + context.get("measureServerTiming")("do", () => object.getTwitchRateLimitRetryAfter()), ]); - const fresh = cached !== null && Date.now() - Date.parse(cached.asOf) < SCHEDULE_CACHE_TTL_MS; - let current: ScheduleCacheValue | null = cached === null ? null : { schedule: cached.schedule, asOf: cached.asOf }; - if (cached !== null) { - await context.get("measureServerTiming")("do", () => object.reconcileCachedAdPrewarning(details[1])); - } - const retryAfter = await context.get("measureServerTiming")("do", () => object.getTwitchRateLimitRetryAfter()); - if (!fresh && cached !== null && retryAfter === null) { - context.get("scheduleBackgroundWork")(object.refreshAdSchedule(details[1]).then((refresh) => { - if (refresh.reason !== null) console.warn("Background ad schedule refresh did not complete.", refresh.reason); - }).catch((error: unknown) => { - console.warn("Background ad schedule refresh failed.", error); - })); - } else if (!fresh && cached === null && retryAfter === null) { - const refresh = await context.get("measureServerTiming")("do", () => object.refreshAdSchedule(details[1])); - context.get("recordServerTiming")("d1", refresh.d1Ms); - context.get("recordServerTiming")("helix", refresh.helixMs); - current = refresh.cache === null ? null : { schedule: refresh.cache.schedule, asOf: refresh.cache.asOf }; - if (current === null) { - return context.json({ - error: "ad_schedule_read_failed", - reason: refresh.reason, - detail: apiErrorDetail(refresh.detail), - }, scheduleStatusFor(refresh.reason)); - } - } else if (!fresh && cached === null && retryAfter !== null) { + const current: ScheduleCacheValue | null = cached === null ? null : { schedule: cached.schedule, asOf: cached.asOf }; + if (current === null && retryAfter !== null) { return context.json({ error: "ad_schedule_read_failed", reason: "rate_limited", detail: { retryAfter }, }, scheduleStatusFor("rate_limited")); } - - const schedule = current?.schedule; - if (schedule === undefined || current === null) return context.json({ error: "ad_schedule_read_failed" }, 503); + if (current === null) return context.json({ error: "ad_schedule_read_failed", reason: "cache_empty" }, 503); const [recentAdBreaks, grantedScopes] = details; const snoozeScopeAvailable = grantedScopes.includes(MANAGE_ADS_SCOPE); - return context.json({ schedule, asOf: current.asOf, snoozeScopeAvailable, recentAdBreaks }); + return context.json({ schedule: current.schedule, asOf: current.asOf, snoozeScopeAvailable, recentAdBreaks }); +}); + +adsRoutes.post("/schedule/refresh", async (context) => { + if (!canManage(context.get("channelRole"))) return context.json({ error: "ad_schedule_refresh_denied" }, 403); + const channelId = context.req.param("channelId") ?? ""; + const [recentAdBreaks, grantedScopes] = await Promise.all([ + listLastAdBreaks(context.env.DB, channelId), + context.get("broadcasterScopesForChannel")(context.env.DB, channelId), + ]); + const object = context.env.CHANNEL.get(context.env.CHANNEL.idFromName(channelId)); + const refresh = await context.get("measureServerTiming")("do", () => object.refreshAdSchedule(grantedScopes)); + context.get("recordServerTiming")("d1", refresh.d1Ms); + context.get("recordServerTiming")("helix", refresh.helixMs); + const current = refresh.cache === null ? null : { schedule: refresh.cache.schedule, asOf: refresh.cache.asOf }; + if (refresh.reason !== null || current === null) { + return context.json({ + error: "ad_schedule_refresh_failed", + reason: refresh.reason, + detail: apiErrorDetail(refresh.detail), + }, scheduleStatusFor(refresh.reason)); + } + return context.json({ + schedule: current.schedule, + asOf: current.asOf, + snoozeScopeAvailable: grantedScopes.includes(MANAGE_ADS_SCOPE), + recentAdBreaks, + }); }); adsRoutes.post("/snooze", async (context) => { diff --git a/src/modules/api_source/adapters/fetch-json.ts b/src/modules/api_source/adapters/fetch-json.ts index 86a0b2fa..ac9807eb 100644 --- a/src/modules/api_source/adapters/fetch-json.ts +++ b/src/modules/api_source/adapters/fetch-json.ts @@ -113,3 +113,15 @@ export const fetchCachedApiSourceJson = async ( if (result.expiresAt !== null) await storeCachedApiPayload(db, channelId, urlHash, result.payload, result.expiresAt, now); return result.payload; }; + +export const readCachedApiSourceJson = async ( + db: D1Database, + channelId: string, + url: string, + ownOrigin: string | undefined, + now: number, +): Promise => { + const canonicalUrl = validateApiSourceUrl(url, ownOrigin).href; + const cached = await readCachedApiPayload(db, await cacheHash(channelId, canonicalUrl), now); + return cached?.payload ?? null; +}; diff --git a/src/modules/api_source/index.ts b/src/modules/api_source/index.ts index ba6ccd58..dfe07a0c 100644 --- a/src/modules/api_source/index.ts +++ b/src/modules/api_source/index.ts @@ -4,7 +4,7 @@ import type { BotModule, ModuleExternalFetchBudget, ModuleTextBlockConditionDefi import { API_SOURCE_VALUE_VARIABLE, apiSourceModuleCatalog } from "./contracts"; import type { ApiSource } from "./contracts"; import { getApiSource, listApiSources } from "./adapters/d1"; -import { fetchCachedApiSourceJson } from "./adapters/fetch-json"; +import { fetchCachedApiSourceJson, readCachedApiSourceJson } from "./adapters/fetch-json"; import { evaluateApiSourceExpression, formatApiSourceValue, parseApiSourceName } from "./domain"; import { apiSourceRoutes } from "./routes"; @@ -23,6 +23,13 @@ const sourcePayload = ( source: ApiSource, context: ModuleTemplateValueContext | ModuleTemplateConditionContext, ): Promise => { + if ("readOnly" in context && context.readOnly) { + return readCachedApiSourceJson(context.DB, context.channelId, source.url, context.publicOrigin, context.now) + .then((cached) => { + if (cached === null) throw new Error("API source has no cached response."); + return cached; + }); + } const budget = context.externalFetchBudget; if (budget === undefined) { return fetchCachedApiSourceJson(context.DB, context.channelId, source.url, context.publicOrigin, context.now, budget); diff --git a/src/modules/belabox/adapters/d1.ts b/src/modules/belabox/adapters/d1.ts index f70aade4..ad818ae2 100644 --- a/src/modules/belabox/adapters/d1.ts +++ b/src/modules/belabox/adapters/d1.ts @@ -32,6 +32,7 @@ export interface BelaboxRecentPoint { export interface BelaboxFetchPhase { consecutiveFailures: number; failing: boolean; + lastAttemptAt?: string; } export interface BelaboxStatus { @@ -169,7 +170,13 @@ const parseFetchPhase = (value: string): BelaboxFetchPhase => { const state = parsed as Readonly>; return Number.isSafeInteger(state.consecutiveFailures) && Number(state.consecutiveFailures) >= 0 && typeof state.fetchFailing === "boolean" - ? { consecutiveFailures: Number(state.consecutiveFailures), failing: state.fetchFailing } + ? { + consecutiveFailures: Number(state.consecutiveFailures), + failing: state.fetchFailing, + ...(typeof state.lastAttemptAt === "string" && Number.isFinite(Date.parse(state.lastAttemptAt)) + ? { lastAttemptAt: state.lastAttemptAt } + : {}), + } : EMPTY_FETCH_PHASE; } catch { return EMPTY_FETCH_PHASE; @@ -735,6 +742,7 @@ const encodedRecent = (recent: readonly BelaboxRecentPoint[]): string => JSON.st const encodedPhase = (phase: BelaboxFetchPhase): string => JSON.stringify({ consecutiveFailures: phase.consecutiveFailures, fetchFailing: phase.failing, + ...(phase.lastAttemptAt === undefined ? {} : { lastAttemptAt: phase.lastAttemptAt }), }); export const belaboxHistoryStatusGuard = `WHERE EXISTS ( diff --git a/src/modules/belabox/routes.ts b/src/modules/belabox/routes.ts index 6c2fe44a..99cfadec 100644 --- a/src/modules/belabox/routes.ts +++ b/src/modules/belabox/routes.ts @@ -131,19 +131,6 @@ belaboxRoutes.get("/status", async (context) => { "SELECT state, stream_id FROM channel_stream_state WHERE channel_id = ?", ).bind(channelId).first<{ state: string; stream_id: string | null }>(), ]); - if (moduleState?.enabled === true && moduleState.settings.mode === "on_demand" && secretStatus.configured) { - try { - await context.get("runModuleAlarm")( - channelId, - BELABOX_MODULE_ID, - BELABOX_POLL_ALARM_KEY, - BELABOX_POLL_ALARM_KEY, - { reason: "on_demand" }, - ); - } catch { - // The status response still reports the last persisted sample. - } - } const status = await getBelaboxStatus(context.env.DB, channelId); const pollingDesired = moduleState?.enabled === true && moduleState.settings.mode === "interval" && secretStatus.configured && streamState?.state === "online"; diff --git a/src/modules/belabox/service.ts b/src/modules/belabox/service.ts index 5abba597..9ba6884b 100644 --- a/src/modules/belabox/service.ts +++ b/src/modules/belabox/service.ts @@ -63,6 +63,7 @@ const LIVE_BUFFER_MS = 10 * 60_000; const LIVE_BUFFER_MAX_POINTS = 120; const FETCH_FAILURE_THRESHOLD = 3; const INFRASTRUCTURE_RETRY_MAX_MS = 60_000; +const ON_DEMAND_FAILURE_RETRY_BASE_MS = 1_000; /** Sets fresh chat templates to the channel language whenever BELABOX is enabled. */ export const belaboxAlertDefaultsOnEnable = async ( @@ -403,7 +404,9 @@ const storePollResult = async ( const previousPhase = sameStream ? currentStatus?.fetchPhase ?? { consecutiveFailures: 0, failing: false } : { consecutiveFailures: 0, failing: false }; const now = Date.now(); - const phase = result.ok ? { consecutiveFailures: 0, failing: false } : failedPhase(previousPhase); + const phase = result.ok + ? { consecutiveFailures: 0, failing: false, lastAttemptAt: new Date(now).toISOString() } + : { ...failedPhase(previousPhase), lastAttemptAt: new Date(now).toISOString() }; const alreadyClassified = sameStream && streamId !== null && currentStatus?.belaboxStreamId === streamId; // Persisted classification comes only from a connected sample (or an existing one); // the on-demand override below affects presentation only. @@ -706,6 +709,19 @@ const runBelaboxPoll = async ( age < BELABOX_ON_DEMAND_CACHE_MS) { return { ok: true, sample: cachedStatus.sample }; } + const failureCount = cachedStatus?.fetchPhase.consecutiveFailures ?? 0; + const lastAttemptAt = cachedStatus?.fetchPhase.lastAttemptAt; + const failedAt = lastAttemptAt === undefined ? Number.NaN : Date.parse(lastAttemptAt); + if (cachedStatus?.errorCode !== null && cachedStatus?.errorCode !== undefined && + cachedStatus.errorCode !== BELABOX_SECRET_UNAVAILABLE_STATUS_CODE && + failureCount > 0 && Number.isFinite(failedAt)) { + const retryDelay = Math.min( + INFRASTRUCTURE_RETRY_MAX_MS, + ON_DEMAND_FAILURE_RETRY_BASE_MS * (2 ** Math.min(failureCount - 1, 6)), + ); + const elapsed = Date.now() - failedAt; + if (elapsed >= 0 && elapsed < retryDelay) return { ok: false, reason: cachedStatus.errorCode }; + } } let delay = INFRASTRUCTURE_RETRY_MAX_MS; diff --git a/src/modules/chat_voting/routes.ts b/src/modules/chat_voting/routes.ts index 957ab69e..dfeef709 100644 --- a/src/modules/chat_voting/routes.ts +++ b/src/modules/chat_voting/routes.ts @@ -38,13 +38,6 @@ chatVotingRoutes.get("/current", async (context) => { getEnabledSettings(context.env.DB, channelId), channelLanguage(context.env.DB, channelId), ]); - const openVote = await repository.open(channelId); - if (openVote?.legacyWritten === true) { - await requestChatVoteClose(repository, channelId, async (pollId, deadline, ownerRevision) => { - const object = context.env.CHANNEL.get(context.env.CHANNEL.idFromName(channelId)); - await object.scheduleModuleAlarm(CHAT_VOTING_MODULE_ID, CHAT_VOTING_ALARM_HANDLER, pollId, deadline, ownerRevision); - }, undefined, { pollId: openVote.id, legacyOnly: true }); - } const vote = await repository.latest(channelId); const effectiveSettings = settings ?? DEFAULT_CHAT_VOTING_SETTINGS; const defaultLabels: Record = { diff --git a/src/modules/contract.ts b/src/modules/contract.ts index 4413db84..582f159e 100644 --- a/src/modules/contract.ts +++ b/src/modules/contract.ts @@ -917,6 +917,8 @@ export interface ModuleTemplateConditionContext { publicOrigin?: string; /** Shared across the providers involved in a single template render. */ externalFetchBudget?: ModuleExternalFetchBudget; + /** Prevents provider lookups or cache fills when resolving a panel read. */ + readOnly?: boolean; now: number; commandInput?: { commandName: string; arguments: string; usageText?: string }; /** Records a source label when a condition uses values from this module. */ @@ -1165,6 +1167,7 @@ export interface ModuleRouteVariables { channelId: string, ids: readonly string[], now: number, + options?: { readOnly?: boolean }, ) => Promise>>; publishModuleOverlayMessage: ( channelId: string, diff --git a/src/modules/text_library/routes.ts b/src/modules/text_library/routes.ts index 83dcf31c..7c4bc72e 100644 --- a/src/modules/text_library/routes.ts +++ b/src/modules/text_library/routes.ts @@ -128,7 +128,7 @@ textLibraryRoutes.get("/library", async (context) => { context.get("listTextBlockConditions")(channelId), ]); const conditionIds = [...new Set(data.blocks.flatMap((block) => block.variants.flatMap((variant) => Object.keys(variant.conditions.data ?? {}))))]; - const dataConditionValues = await context.get("resolveTextBlockConditions")(channelId, conditionIds, Date.now()); + const dataConditionValues = await context.get("resolveTextBlockConditions")(channelId, conditionIds, Date.now(), { readOnly: true }); const reservedNames = [...new Set([ ...SYSTEM_TEMPLATE_VARIABLE_LIST.map((variable) => variable.name), ...registeredVariables.filter(({ moduleId }) => moduleId !== TEXT_LIBRARY_MODULE_ID).map(({ name }) => name), diff --git a/src/modules/votekick/routes.ts b/src/modules/votekick/routes.ts index 3c3df77c..57fdeb9b 100644 --- a/src/modules/votekick/routes.ts +++ b/src/modules/votekick/routes.ts @@ -22,10 +22,6 @@ votekickRoutes.get("/votekicks", async (context) => { const channelId = requiredParam(context.req.param("channelId"), "channelId"); const now = new Date().toISOString(); const repository = createVotekickRepository(context.env.DB); - const running = await repository.running(channelId); - if (running !== null) { - await context.get("runModuleAlarm")(channelId, VOTEKICK_MODULE_ID, "close", `close:${running.id}`); - } const votekicks = await repository.listRecent(channelId, recentCutoff(now)); return context.json({ running: votekicks.find((item) => item.status === "running") ?? null, votekicks, now }); }); diff --git a/src/modules/weather/index.ts b/src/modules/weather/index.ts index f70f0b1c..9a757dff 100644 --- a/src/modules/weather/index.ts +++ b/src/modules/weather/index.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { BotModule, ModuleChannelLocation, ModuleTemplateConditionContext, ModuleTemplateValueContext } from "../contract"; import { validChannelTimeZone, type TemplateVariable } from "../contract"; import { DEFAULT_WEATHER_ERROR_TEXTS, readWeatherSettings } from "./adapters/d1"; -import { fetchCachedWeather } from "./adapters/cache"; +import { fetchCachedWeather, readWeatherCache } from "./adapters/cache"; import { geocodeWeatherPlace } from "./adapters/geocoding"; import { weatherModuleCatalog, WEATHER_CONDITION_LABELS, WEATHER_TEMPLATE_VARIABLE_NAMES } from "./contracts/catalog"; import type { WeatherCondition, WeatherSettings } from "./contracts"; @@ -87,7 +87,10 @@ const resolveConditions = async ( : context.channelLocation(), ]); if (locationSetting === null || !validChannelTimeZone(locationSetting.timeZone)) return {}; - const cached = await fetchCachedWeather(context.DB, settings.provider, locationSetting.latitude, locationSetting.longitude, context.now); + const cached = context.readOnly + ? await readWeatherCache(context.DB, settings.provider, locationSetting.latitude, locationSetting.longitude, context.now) + : await fetchCachedWeather(context.DB, settings.provider, locationSetting.latitude, locationSetting.longitude, context.now); + if (cached === null) return {}; const condition: WeatherCondition = cached.weather.condition; context.addTemplateValueAttribution?.(settings.provider === "met_norway" ? "MET Norway" : "Open-Meteo"); context.addTemplateConditionNextChangeAt?.(new Date(cached.expiresAt).toISOString()); diff --git a/src/worker/panel/module-routes.ts b/src/worker/panel/module-routes.ts index 88694854..9598938b 100644 --- a/src/worker/panel/module-routes.ts +++ b/src/worker/panel/module-routes.ts @@ -255,7 +255,7 @@ moduleRouter.use("/api/channels/:channelId/*", (context, next) => { )); return issues.flat(); }); - context.set("resolveTextBlockConditions", async (channelId, ids, now) => { + context.set("resolveTextBlockConditions", async (channelId, ids, now, options) => { const requested = new Set(ids); const channel = await context.env.DB.prepare("SELECT time_zone FROM channels WHERE channel_id = ?") .bind(channelId).first<{ time_zone: string }>(); @@ -267,6 +267,7 @@ moduleRouter.use("/api/channels/:channelId/*", (context, next) => { channelLocation: () => readChannelLocation(context.env.DB, channelId), publicOrigin: context.env.PUBLIC_ORIGIN, externalFetchBudget: context.get("externalFetchBudget"), + readOnly: options?.readOnly ?? false, now, }; const resolved: Record = {}; diff --git a/src/worker/panel/routes.ts b/src/worker/panel/routes.ts index 4218f9da..2b1dff30 100644 --- a/src/worker/panel/routes.ts +++ b/src/worker/panel/routes.ts @@ -56,10 +56,8 @@ import { maintainEventSubSubscriptions } from "../eventsub-subscriptions"; import { isChannelControlInput, setChannelControl, type ChannelControlKind } from "../db/channel-controls"; import { actorGuard, bindActorGuard } from "../db/guards"; import { prepareAudit } from "../db/audit"; -import { HELIX_STREAM_STATE_TTL_MS, lookupAndRefreshStreamState } from "../stream-state-lookup"; -import { TWITCH_RATE_LIMIT_COOLDOWN_MS } from "../twitch/rate-limit"; import { getChannelModuleForChannel } from "../db/channel-modules"; -import { measureServerTiming, scheduleBackgroundWork } from "../server-timing"; +import { measureServerTiming } from "../server-timing"; import { DEFAULT_CHANNEL_TIME_ZONE, validChannelTimeZone } from "../../modules/contract"; import { readCompletePanelResourceRevisions } from "../panel-resources"; @@ -367,33 +365,8 @@ panelRouter.get( async (context) => { const session = context.get("session"); const channelId = context.req.param("channelId"); - const checkedAt = nowIso(); const overview = await measureServerTiming(context, "d1", () => getChannelOverviewForUser(context.env.DB, session.userId, channelId)); if (overview === null) return context.json({ error: "channel_not_found" }, 404); - const channelNamespace = Reflect.get(context.env, "CHANNEL") as Env["CHANNEL"] | undefined; - const observedAt = overview.streamStateCheckedAt === undefined || overview.streamStateCheckedAt === null - ? Number.NaN - : Date.parse(overview.streamStateCheckedAt); - const streamStateIsStale = !Number.isFinite(observedAt) || Date.parse(checkedAt) - observedAt >= HELIX_STREAM_STATE_TTL_MS; - if (channelNamespace !== undefined && streamStateIsStale) { - const object = channelNamespace.get(channelNamespace.idFromName(channelId)); - scheduleBackgroundWork(context, (async () => { - if (await object.getTwitchRateLimitRetryAfter() !== null) return; - const lease = await object.beginStreamStateRefresh(); - if (lease === null) return; - try { - const result = await lookupAndRefreshStreamState(context.env, channelId, checkedAt); - if (result.rateLimited) { - await object.setTwitchRateLimitRetryAfter(Date.now() + TWITCH_RATE_LIMIT_COOLDOWN_MS); - console.warn("Background stream-state refresh was rate-limited.", channelId); - } - } catch (error: unknown) { - console.warn("Background stream-state refresh failed.", channelId, error); - } finally { - await object.endStreamStateRefresh(lease); - } - })()); - } return context.json(overview); }, ); diff --git a/tests/components/ads-panel.test.tsx b/tests/components/ads-panel.test.tsx index 8ceff0e3..60c45d6d 100644 --- a/tests/components/ads-panel.test.tsx +++ b/tests/components/ads-panel.test.tsx @@ -102,6 +102,8 @@ describe("Ad settings editor declaration", () => { cleanup(); renderAds(adsFetch(), "operator"); expect(await screen.findByText("Nur Broadcaster und Verwalter dürfen Ansagen-Einstellungen ändern.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Werbeplan aktualisieren" })).toBeDisabled(); + expect(screen.getByText("Nur Broadcaster und Verwalter dürfen den Werbeplan aktualisieren.")).toBeInTheDocument(); const properties = document.querySelector("dl.ui-settings-editor__properties"); expect(properties).not.toBeNull(); expect(within(properties as HTMLElement).getByText("Vorwarnung vor der Werbung")).toBeInTheDocument(); @@ -162,6 +164,7 @@ describe("Ad settings editor declaration", () => { expect(screen.getByText(/Derzeit ist keine Werbung geplant\./u)).toBeInTheDocument(); expect(screen.getByRole("alert")).toHaveTextContent("Der Werbeplan konnte nicht geladen werden."); expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument(); expect(toastsSnapshot().filter((toast) => toast.message === "Der Werbeplan konnte nicht geladen werden.")).toHaveLength(1); scheduleReadFails = false; diff --git a/tests/unit/ads-routes.test.ts b/tests/unit/ads-routes.test.ts index ce1c34ab..4e047d32 100644 --- a/tests/unit/ads-routes.test.ts +++ b/tests/unit/ads-routes.test.ts @@ -255,20 +255,53 @@ describe("ad routes", () => { expect(oversized.status).toBe(400); }); - it("re-arms the prewarning alarm on a successful schedule fetch and writes no event", async () => { + it("keeps a cold schedule read passive until a manager requests a refresh", async () => { const environment = await setup("operator"); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(scheduleBody("2026-09-21T12:00:00Z"), { status: 200 }))); + const fetcher = vi.fn().mockResolvedValue(new Response(scheduleBody("2026-09-21T12:00:00Z"), { status: 200 })); + vi.stubGlobal("fetch", fetcher); const response = await panelRouter.fetch( await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule"), environment, ); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ error: "ad_schedule_read_failed", reason: "cache_empty" }); + expect(fetcher).not.toHaveBeenCalled(); + expect(schedule).not.toHaveBeenCalled(); + await expect(database.prepare("SELECT COUNT(*) AS count FROM event_log").first()).resolves.toEqual({ count: 0 }); + }); + + it("refreshes the schedule only through an authorized explicit action", async () => { + const environment = await setup("manager"); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(scheduleBody("2026-09-21T12:00:00Z"), { status: 200 }))); + + const response = await panelRouter.fetch( + await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule/refresh", "POST"), + environment, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ schedule: { nextAdAt: "2026-09-21T12:00:00Z" } }); expect(schedule).toHaveBeenCalledWith(); await expect(database.prepare("SELECT COUNT(*) AS count FROM event_log").first()).resolves.toEqual({ count: 0 }); }); + it("keeps schedule refresh disabled for an operator", async () => { + const environment = await setup("operator"); + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + + const response = await panelRouter.fetch( + await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule/refresh", "POST"), + environment, + ); + + expect(response.status).toBe(403); + expect(fetcher).not.toHaveBeenCalled(); + expect(schedule).not.toHaveBeenCalled(); + }); + it("serves a fresh cached schedule without calling Helix", async () => { const cached = { schedule: { nextAdAt: "2026-09-24T18:00:00.000Z", duration: 60, lastAdAt: null, prerollFreeTime: 120, snoozeCount: 1, snoozeRefreshAt: null }, @@ -289,107 +322,69 @@ describe("ad routes", () => { expect(schedule).not.toHaveBeenCalled(); }); - it("coalesces stale schedule refreshes and serves the last good value while they run", async () => { + it("serves a stale schedule without refreshing or reconciling it on GET", async () => { const cached = { schedule: { nextAdAt: "2026-09-24T18:00:00.000Z", duration: 60, lastAdAt: null, prerollFreeTime: 120, snoozeCount: 1, snoozeRefreshAt: null }, asOf: new Date(Date.now() - 61_000).toISOString(), } satisfies { schedule: AdsSchedule; asOf: string }; const environment = await setup("operator", ["channel:read:ads", "channel:manage:ads"], cached); - let resolveHelix: ((response: Response) => void) | undefined; - const fetcher = vi.fn().mockImplementation(() => new Promise((resolve) => { resolveHelix = resolve; })); + const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher); - const tasks: Promise[] = []; - const executionContext = { - waitUntil: (promise: Promise) => { tasks.push(promise); }, - passThroughOnException: () => undefined, - } as unknown as ExecutionContext; - const request = () => requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule"); - - const [left, right] = await Promise.all([ - request().then((input) => panelRouter.fetch(input, environment, executionContext)), - request().then((input) => panelRouter.fetch(input, environment, executionContext)), - ]); - expect(left.status).toBe(200); - expect(right.status).toBe(200); - await expect(left.json()).resolves.toMatchObject(cached); - await expect(right.json()).resolves.toMatchObject(cached); - await vi.waitFor(() => { expect(fetcher).toHaveBeenCalledTimes(1); }); - expect(tasks).toHaveLength(2); - resolveHelix?.(new Response(scheduleBody("2026-09-24T18:30:00.000Z"), { status: 200 })); - await Promise.all(tasks); - expect(schedule).toHaveBeenCalledTimes(1); + const response = await panelRouter.fetch( + await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule"), + environment, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject(cached); + expect(fetcher).not.toHaveBeenCalled(); + expect(schedule).not.toHaveBeenCalled(); }); - it("keeps the cached schedule and as-of time when a stale refresh is rate-limited", async () => { + it("does not start a background schedule refresh for a stale cache", async () => { const cached = { schedule: { nextAdAt: "2026-09-24T18:00:00.000Z", duration: 60, lastAdAt: null, prerollFreeTime: 120, snoozeCount: 1, snoozeRefreshAt: null }, asOf: new Date(Date.now() - 61_000).toISOString(), } satisfies { schedule: AdsSchedule; asOf: string }; const environment = await setup("operator", ["channel:read:ads", "channel:manage:ads"], cached); - const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ message: "slow down" }), { status: 429 })); + const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher); - const tasks: Promise[] = []; - const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const executionContext = { - waitUntil: (promise: Promise) => { tasks.push(promise); }, - passThroughOnException: () => undefined, - } as unknown as ExecutionContext; const response = await panelRouter.fetch( await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule"), environment, - executionContext, ); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject(cached); - await Promise.all(tasks); + expect(fetcher).not.toHaveBeenCalled(); expect(schedule).not.toHaveBeenCalled(); - expect(warning).toHaveBeenCalledWith("Background ad schedule refresh did not complete.", "rate_limited"); - - const nextViewTasks: Promise[] = []; - const nextExecutionContext = { - waitUntil: (promise: Promise) => { nextViewTasks.push(promise); }, - passThroughOnException: () => undefined, - } as unknown as ExecutionContext; - const nextResponse = await panelRouter.fetch( - await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule"), - environment, - nextExecutionContext, - ); - - expect(nextResponse.status).toBe(200); - await expect(nextResponse.json()).resolves.toMatchObject(cached); - expect(fetcher).toHaveBeenCalledTimes(1); - expect(nextViewTasks).toHaveLength(0); - warning.mockRestore(); }); - it("keeps a cold unauthorized schedule read mapped to 403", async () => { + it("does not contact Twitch while a cold schedule remains uncached", async () => { const environment = await setup("operator"); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response( - JSON.stringify({ message: "unauthorized" }), - { status: 401 }, - ))); + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); const response = await panelRouter.fetch( await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule"), environment, ); - expect(response.status).toBe(403); - await expect(response.json()).resolves.toMatchObject({ error: "ad_schedule_read_failed", reason: "unauthorized" }); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ error: "ad_schedule_read_failed", reason: "cache_empty" }); + expect(fetcher).not.toHaveBeenCalled(); }); - it("keeps schedule-read failures out of D1 diagnostics", async () => { - const environment = await setup("operator"); + it("keeps explicit schedule-refresh failures out of D1 diagnostics", async () => { + const environment = await setup("manager"); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response( JSON.stringify({ message: "Twitch nicht erreichbar" }), { status: 429 }, ))); const response = await panelRouter.fetch( - await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule"), + await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule/refresh", "POST"), environment, ); @@ -401,12 +396,12 @@ describe("ad routes", () => { expect(body.detail.twitchMessage).toBeUndefined(); }); - it("handles an empty successful schedule without an event", async () => { - const environment = await setup("operator"); + it("handles an empty successful explicit refresh without an event", async () => { + const environment = await setup("manager"); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(scheduleBody(null), { status: 200 }))); const response = await panelRouter.fetch( - await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule"), + await requestFor("user-1", "/api/channels/kanal-a/modules/ads/schedule/refresh", "POST"), environment, ); diff --git a/tests/unit/belabox-polling.test.ts b/tests/unit/belabox-polling.test.ts index f32758c1..aca121a1 100644 --- a/tests/unit/belabox-polling.test.ts +++ b/tests/unit/belabox-polling.test.ts @@ -2406,7 +2406,7 @@ describe("BELABOX polling", () => { expect(stored).toEqual({ error_code: "network", sample_json: null, - fetch_phase_json: JSON.stringify({ consecutiveFailures: 1, fetchFailing: false }), + fetch_phase_json: JSON.stringify({ consecutiveFailures: 1, fetchFailing: false, lastAttemptAt: "2026-10-05T12:00:00.000Z" }), recent_json: "[]", }); await expect(database.prepare("SELECT COUNT(*) AS count FROM event_log WHERE channel_id = ?") @@ -2432,6 +2432,29 @@ describe("BELABOX polling", () => { expect(fetcher).toHaveBeenCalledTimes(2); }); + it("backs off consecutive on-demand provider failures without writing during the pause", async () => { + await insertModule(database, { mode: "on_demand", intervalSeconds: 15 }); + const { context } = alarmContext(database); + const fetcher = vi.fn(() => Promise.reject(new TypeError("relay unavailable"))); + + const first = await pollOnDemand(context, fetcher); + expect(first).toEqual({ ok: false, reason: "network" }); + const afterFailure = await getBelaboxStatus(context.DB, CHANNEL_ID); + expect(afterFailure?.fetchPhase).toMatchObject({ consecutiveFailures: 1, lastAttemptAt: "2026-10-05T12:00:00.000Z" }); + + const suppressed = await pollOnDemand(context, fetcher); + expect(suppressed).toEqual(first); + expect(fetcher).toHaveBeenCalledOnce(); + expect(await getBelaboxStatus(context.DB, CHANNEL_ID)).toMatchObject({ revision: afterFailure?.revision }); + + vi.advanceTimersByTime(1_000); + await pollOnDemand(context, fetcher); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(await getBelaboxStatus(context.DB, CHANNEL_ID)).toMatchObject({ + fetchPhase: { consecutiveFailures: 2, lastAttemptAt: "2026-10-05T12:00:01.000Z" }, + }); + }); + it("keeps accumulated enrichment for online sessions without a Twitch stream ID", async () => { await insertModule(database, { mode: "on_demand", intervalSeconds: 15 }); const changedAt = "2026-10-05T11:59:00.000Z"; @@ -2509,6 +2532,7 @@ describe("BELABOX polling", () => { ).bind(CHANNEL_ID).first(); expect(afterFailure).toEqual({ sampled_at: null, sample_json: null }); + if (mode === "on_demand") vi.advanceTimersByTime(1_000); await fetchSample(); const status = await getBelaboxStatus(context.DB, CHANNEL_ID); expect(status).toMatchObject({ @@ -2646,7 +2670,7 @@ describe("BELABOX polling", () => { for (let attempt = 0; attempt < 4; attempt += 1) { await pollOnDemand(context, failing); - vi.setSystemTime(Date.now() + 1_000); + vi.advanceTimersByTime(1_000 * (2 ** attempt)); } await pollOnDemand(context, () => Promise.resolve(jsonResponse(relayPayload(true)))); diff --git a/tests/unit/belabox.test.ts b/tests/unit/belabox.test.ts index 19ee2d26..9ed6fc46 100644 --- a/tests/unit/belabox.test.ts +++ b/tests/unit/belabox.test.ts @@ -573,7 +573,7 @@ describe("BELABOX secret and route redaction", () => { expect(diagnostics.results).toEqual([{ code: "belabox.polling_ensure_failed" }]); }); - it("fetches status on demand and then reuses the ten-second cache", async () => { + it("returns persisted on-demand status without sampling on GET", async () => { const testDatabase = new TestD1Database(); database = testDatabase; const { send } = await createBelaboxRouteHarness(testDatabase); @@ -587,16 +587,16 @@ describe("BELABOX secret and route redaction", () => { const first = await send("/status", "GET"); const second = await send("/status", "GET"); - expect(await first.json()).toMatchObject({ sample: { connected: true, bitrateKbps: 2_400 }, polling: false }); - expect(await second.json()).toMatchObject({ sample: { connected: true, bitrateKbps: 2_400 }, polling: false }); - expect(fetcher).toHaveBeenCalledOnce(); + expect(await first.json()).toMatchObject({ sample: null, polling: false }); + expect(await second.json()).toMatchObject({ sample: null, polling: false }); + expect(fetcher).not.toHaveBeenCalled(); const history = await send("/history?range=live", "GET"); await expect(history.json()).resolves.toEqual([]); await expect(testDatabase.prepare("SELECT COUNT(*) AS count FROM belabox_minutes WHERE channel_id = ?") .bind(CHANNEL_ID).first<{ count: number }>()).resolves.toEqual({ count: 0 }); }); - it("routes on-demand status, Check now, and connection tests through the poll alarm handler", async () => { + it("keeps status reads passive and routes explicit polling actions through the alarm handler", async () => { const testDatabase = new TestD1Database(); database = testDatabase; const { runModuleAlarm, send } = await createBelaboxRouteHarness(testDatabase); @@ -613,9 +613,11 @@ describe("BELABOX secret and route redaction", () => { expect((await send("/polling/retry", "POST")).status).toBe(200); expect((await send("/test", "POST", {})).status).toBe(200); - expect(runModuleAlarm).toHaveBeenCalledWith(CHANNEL_ID, "belabox", "poll", "poll", { reason: "on_demand" }); - expect(runModuleAlarm).toHaveBeenCalledWith(CHANNEL_ID, "belabox", "poll", "poll", { reason: "check_now" }); - expect(runModuleAlarm).toHaveBeenCalledWith(CHANNEL_ID, "belabox", "poll", "poll", { + expect(runModuleAlarm).not.toHaveBeenCalledWith(CHANNEL_ID, "belabox", "poll", "poll", { reason: "on_demand" }); + expect(runModuleAlarm).toHaveBeenNthCalledWith(1, CHANNEL_ID, "belabox", "poll", "poll", { reason: "configuration_changed" }); + expect(runModuleAlarm).toHaveBeenNthCalledWith(2, CHANNEL_ID, "belabox", "ensure", "poll"); + expect(runModuleAlarm).toHaveBeenNthCalledWith(3, CHANNEL_ID, "belabox", "poll", "poll", { reason: "check_now" }); + expect(runModuleAlarm).toHaveBeenNthCalledWith(4, CHANNEL_ID, "belabox", "poll", "poll", { reason: "connection_test", statsUrl: STATS_URL, }); diff --git a/tests/unit/chat-voting-routes.test.ts b/tests/unit/chat-voting-routes.test.ts index 463aa2f3..19713313 100644 --- a/tests/unit/chat-voting-routes.test.ts +++ b/tests/unit/chat-voting-routes.test.ts @@ -140,7 +140,7 @@ describe("chat voting routes", () => { }); }); - it("schedules the close alarm when GET current finds a legacy open vote", async () => { + it("reads a legacy open vote without scheduling a close alarm", async () => { const database = await createDatabase(); await database.prepare("UPDATE chat_votes SET legacy_written = 1 WHERE channel_id = ? AND poll_id = ?") .bind(CHANNEL_ID, openTextVote.id).run(); @@ -157,7 +157,7 @@ describe("chat voting routes", () => { }); expect(response.status).toBe(200); - expect(scheduleModuleAlarm).toHaveBeenCalledWith("chat_voting", "close", openTextVote.id, expect.any(Number), 1); + expect(scheduleModuleAlarm).not.toHaveBeenCalled(); await expect(response.json()).resolves.toMatchObject({ vote: { id: openTextVote.id, status: "open", legacyWritten: true } }); }); diff --git a/tests/unit/panel-resource-revisions.test.ts b/tests/unit/panel-resource-revisions.test.ts index 80d882fa..a9fca5e2 100644 --- a/tests/unit/panel-resource-revisions.test.ts +++ b/tests/unit/panel-resource-revisions.test.ts @@ -6,8 +6,11 @@ import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { MODULES } from "../../src/modules/registry"; import { dashboardDataKeys, queryKeys } from "../../src/dashboard/data/keys"; -import { reconcileDashboardRealtimeMessage, reconcileDashboardPanelResourceRevisions } from "../../src/dashboard/data/realtime"; +import { cancelDashboardRealtimeRetries, reconcileDashboardRealtimeMessage, reconcileDashboardPanelResourceRevisions, setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; import { moduleQueryKey } from "../../src/dashboard/data/module-query"; +import { BELABOX_POLL_ALARM_KEY, BELABOX_STATS_URL_SECRET } from "../../src/modules/belabox/contracts"; +import { getBelaboxStatus } from "../../src/modules/belabox/adapters/d1"; +import { handleBelaboxPollAlarm } from "../../src/modules/belabox/service"; import { loadTextLibrary } from "../../src/modules/text_library/panel/service"; import { authRouter } from "../../src/worker/auth/routes"; import { eventSubChannelIdForNotification, eventSubRouter } from "../../src/worker/eventsub"; @@ -50,6 +53,7 @@ const routeHasNotifier = ( nonPanelReads: ReadonlySet = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.nonPanelGetRoutes), ): boolean => { const routeKey = `${route.method} ${route.path}`; + if (route.method === "GET" && routeIsCovered(route.path, PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes)) return false; if (explicitRoutes.has(routeKey) || nonPanelWrites.has(routeKey) || nonPanelAllRoutes.has(routeKey)) return true; if (route.method === "GET") { return nonPanelReads.has(route.path) || routeIsCovered(route.path, PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes); @@ -288,6 +292,7 @@ describe("panel resource revisions", () => { const nonPanelGetRoutes = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.nonPanelGetRoutes); const nonPanelWrites = new Set(PANEL_RESOURCE_NOTIFIER_COVERAGE.nonPanelWriteRoutes); expect(new Set([...writingGetRoutes].map((path) => `GET ${path}`))).toEqual(explicitRoutes); + expect([...writingGetRoutes].filter((path) => routeIsCovered(path, PANEL_RESOURCE_NOTIFIER_COVERAGE.channelRoutes))).toEqual([]); const uncovered = mutations.filter((route) => !routeHasNotifier(route, routePatterns, explicitRoutes, writingGetRoutes, nonPanelWrites)); expect(uncovered).toEqual([]); const unaccountedGets = assembledRoutes.filter(({ method, path }) => method === "GET" && @@ -326,6 +331,15 @@ describe("panel resource revisions", () => { explicitRoutes, new Set([...writingGetRoutes, unwrappedWritingGet.path]), )).toBe(false); + const channelWritingGet = { method: "GET", path: "/api/channels/channel-a/modules/belabox/status" }; + const hypotheticalChannelWritingGet = new Set([...writingGetRoutes, channelWritingGet.path]); + expect(routeNeedsNotifier(channelWritingGet, hypotheticalChannelWritingGet)).toBe(true); + expect(routeHasNotifier( + channelWritingGet, + routePatterns, + explicitRoutes, + hypotheticalChannelWritingGet, + )).toBe(false); const unnotifiedPlatformWritingGet = { method: "GET", path: "/api/platform/writing-get" }; expect(routeHasNotifier( unnotifiedPlatformWritingGet, @@ -939,6 +953,139 @@ describe("panel resource revisions", () => { } }); + it("keeps a connected on-demand BELABOX panel read passive after a failed sample revision", async () => { + const database = new TestD1Database(); + const channelId = "belabox-readonly-revision"; + const userId = "belabox-readonly-manager"; + const cookieKeys = JSON.stringify({ active: { id: "cookie-v1", key: testKey(21) }, retired: [] }); + const encryptionKeys = JSON.stringify({ active: { id: "encryption-v1", key: testKey(22) }, retired: [] }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryKey = moduleQueryKey(channelId, "belabox", "status"); + const providerFetch = vi.fn(() => Promise.resolve(new Response("unavailable", { status: 503 }))); + let statusReads = 0; + let environment!: Env; + let delivered: Readonly> = {}; + let unsubscribe: (() => void) | undefined; + const requestUrl = (input: RequestInfo | URL): string => + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const pollContext = { + DB: database as unknown as D1Database, + channelId, + secrets: { + readWithVersion: () => Promise.resolve({ value: "http://relay.belabox.net:8080/publisher", version: "ciphertext-v1" }), + }, + externalFetchBudget: { claim: () => true }, + schedule: () => Promise.resolve(), + } as unknown as Parameters[0]; + const runModuleAlarm = vi.fn(async ( + _moduleId: string, + _handlerKey: string, + alarmKey: string, + invocation?: unknown, + ) => { + await handleBelaboxPollAlarm(pollContext, alarmKey, Date.now(), undefined, providerFetch, invocation); + await notifyCommittedResources(environment, channelId); + }); + try { + await insertChannel(database, channelId); + await insertLoginIdentityAndSession(database, userId); + await insertMember(database, channelId, userId, "manager"); + await database.prepare( + `INSERT INTO channel_modules (channel_id, module_id, enabled, settings) + VALUES (?, 'belabox', 1, '{"mode":"on_demand","intervalSeconds":15}')`, + ).bind(channelId).run(); + await database.prepare( + `INSERT INTO module_secrets + (channel_id, module_id, name, ciphertext, key_id, revision, updated_at, updated_by) + VALUES (?, 'belabox', ?, 'ciphertext-v1', 'test', 1, '2026-10-10T00:00:00.000Z', ?)`, + ).bind(channelId, BELABOX_STATS_URL_SECRET, userId).run(); + await database.prepare( + `INSERT INTO channel_stream_state (channel_id, state, changed_at, source, started_at, stream_id) + VALUES (?, 'online', '2026-10-10T00:00:00.000Z', 'eventsub', '2026-10-10T00:00:00.000Z', 'stream-1')`, + ).bind(channelId).run(); + + const channelObject = { + getPanelResourceRevisions: () => Promise.resolve({}), + reconcilePanelResources: async (revisions: Readonly>) => { + const changed = Object.keys(revisions).filter((resource) => (revisions[resource] ?? 0) > (delivered[resource] ?? 0)); + delivered = { ...revisions }; + if (changed.length > 0) { + await reconcileDashboardRealtimeMessage(queryClient, panelResourceChangedMessage(channelId, changed, revisions)); + } + }, + runModuleAlarm, + }; + environment = { + DB: database as unknown as D1Database, + SESSION_COOKIE_KEYS: cookieKeys, + SESSION_ENCRYPTION_KEYS: encryptionKeys, + TOKEN_ENCRYPTION_KEYS: encryptionKeys, + CHANNEL: { + idFromName: (id: string) => id, + get: () => channelObject, + }, + } as unknown as Env; + const sessionCookie = await createSessionCookie( + { sessionId: `session-${userId}` }, cookieKeys, encryptionKeys, + ); + const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input); + if (url.includes("/modules/belabox/status")) statusReads += 1; + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init?.headers).forEach((value, name) => { headers.set(name, value); }); + headers.set("Cookie", `__Host-brobot_session=${sessionCookie}`); + return panelRouter.fetch(new Request(new URL(url, "https://brobot.example"), { ...init, headers }), environment); + }); + vi.stubGlobal("fetch", fetcher); + setDashboardRealtimeStatus(channelId, "connected"); + queryClient.setQueryData(queryKey, { persisted: true }); + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: async () => { + const response = await fetch(`/api/channels/${encodeURIComponent(channelId)}/modules/belabox/status`); + if (!response.ok) throw new Error("BELABOX status read failed."); + return await response.json(); + }, + staleTime: Infinity, + refetchInterval: false, + retry: false, + }); + unsubscribe = observer.subscribe(() => undefined); + await reconcileDashboardPanelResourceRevisions(queryClient, channelId); + + await handleBelaboxPollAlarm( + pollContext, + BELABOX_POLL_ALARM_KEY, + Date.now(), + undefined, + providerFetch, + { reason: "on_demand" }, + ); + const revisionsBeforeStatusRead = await readPanelResourceRevisions(database as unknown as D1Database, channelId); + await notifyCommittedResources(environment, channelId); + await vi.waitFor(() => { expect(statusReads).toBeGreaterThan(0); }); + const readsAfterRevision = statusReads; + await new Promise((resolvePromise) => { setTimeout(resolvePromise, 4_200); }); + + expect(providerFetch).toHaveBeenCalledOnce(); + expect(runModuleAlarm).not.toHaveBeenCalled(); + expect(readsAfterRevision).toBeLessThanOrEqual(2); + expect(statusReads).toBeLessThanOrEqual(2); + expect(await readPanelResourceRevisions(database as unknown as D1Database, channelId)).toEqual(revisionsBeforeStatusRead); + expect(await getBelaboxStatus(database as unknown as D1Database, channelId)).toMatchObject({ + errorCode: "http_5xx", + fetchPhase: { consecutiveFailures: 1 }, + }); + } finally { + cancelDashboardRealtimeRetries(queryClient, channelId); + setDashboardRealtimeStatus(channelId, "offline"); + unsubscribe?.(); + queryClient.clear(); + vi.unstubAllGlobals(); + database.close(); + } + }); + it("reloads the production text-library query after timezone and location changes", async () => { const database = new TestD1Database(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/tests/unit/panel-routes.test.ts b/tests/unit/panel-routes.test.ts index c07bc2cf..939dfc7d 100644 --- a/tests/unit/panel-routes.test.ts +++ b/tests/unit/panel-routes.test.ts @@ -9,9 +9,6 @@ import * as eventsubMaintenance from "../../src/worker/eventsub-subscriptions"; import { insertAppAccessToken, insertChannel, insertLoginIdentityAndSession, insertMember, testKey as key } from "./fixtures"; import { TestD1Database } from "./test-d1"; -const requestedUrl = (input?: RequestInfo | URL): string => - typeof input === "string" ? input : input instanceof URL ? input.href : input?.url ?? ""; - const environmentKeys = { SESSION_COOKIE_KEYS: JSON.stringify({ active: { id: "cookie-v1", key: key(1) }, retired: [] }), SESSION_ENCRYPTION_KEYS: JSON.stringify({ active: { id: "encryption-v1", key: key(2) }, retired: [] }), @@ -325,7 +322,7 @@ describe("Panel read endpoints", () => { }); }); - it("serves the overview before its background Helix lookup finishes (#178)", async () => { + it("serves the overview without starting a Helix lookup (#178)", async () => { await insertChannel(database, "kanal-a", "Alpha"); await insertLoginIdentityAndSession(database, "user-1"); await insertMember(database, "kanal-a", "user-1", "manager"); @@ -336,8 +333,7 @@ describe("Panel read endpoints", () => { "2026-09-18T00:00:00.000Z", "2026-09-18T00:00:00.000Z", ); - let resolveHelix: ((response: Response) => void) | undefined; - const fetcher = vi.fn().mockImplementation(() => new Promise((resolve) => { resolveHelix = resolve; })); + const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher); const runtime = makeStreamRefreshRuntime(); @@ -349,14 +345,11 @@ describe("Panel read endpoints", () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ channelId: "kanal-a", streamState: null }); - await vi.waitFor(() => { expect(fetcher).toHaveBeenCalledTimes(1); }); - expect(fetcher).toHaveBeenCalledTimes(1); - expect(requestedUrl(fetcher.mock.calls[0]?.[0])).toContain("/helix/streams?"); - resolveHelix?.(new Response(JSON.stringify({ data: [{ id: "live-1" }] }), { status: 200 })); - await Promise.all(runtime.tasks); + expect(fetcher).not.toHaveBeenCalled(); + expect(runtime.tasks).toHaveLength(0); await expect(database.prepare( "SELECT state, source FROM channel_stream_state WHERE channel_id = 'kanal-a'", - ).first()).resolves.toEqual({ state: "online", source: "helix" }); + ).first()).resolves.toBeNull(); }); it("does not call Helix from the overview route when a fresh stream state row exists", async () => { @@ -389,7 +382,7 @@ describe("Panel read endpoints", () => { expect(runtime.channelObject.endStreamStateRefresh).not.toHaveBeenCalled(); }); - it("records a per-channel Twitch cooldown after a stream refresh is rate-limited", async () => { + it("does not record a Twitch cooldown from an overview read", async () => { await insertChannel(database, "kanal-a", "Alpha"); await insertLoginIdentityAndSession(database, "user-1"); await insertMember(database, "kanal-a", "user-1", "manager"); @@ -403,7 +396,6 @@ describe("Panel read endpoints", () => { const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ message: "slow down" }), { status: 429 })); vi.stubGlobal("fetch", fetcher); const runtime = makeStreamRefreshRuntime(); - const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); for (let view = 0; view < 2; view++) { const response = await panelRouter.fetch( @@ -412,17 +404,15 @@ describe("Panel read endpoints", () => { runtime.executionContext, ); expect(response.status).toBe(200); - if (view === 0) await Promise.all(runtime.tasks); } - expect(fetcher).toHaveBeenCalledTimes(1); - expect(runtime.channelObject.setTwitchRateLimitRetryAfter).toHaveBeenCalledTimes(1); - expect(runtime.channelObject.beginStreamStateRefresh).toHaveBeenCalledTimes(1); - expect(warning).toHaveBeenCalledWith("Background stream-state refresh was rate-limited.", "kanal-a"); - warning.mockRestore(); + expect(fetcher).not.toHaveBeenCalled(); + expect(runtime.channelObject.setTwitchRateLimitRetryAfter).not.toHaveBeenCalled(); + expect(runtime.channelObject.beginStreamStateRefresh).not.toHaveBeenCalled(); + expect(runtime.channelObject.endStreamStateRefresh).not.toHaveBeenCalled(); }); - it("serves stale stream state and refreshes it in the background", async () => { + it("serves stale stream state without refreshing it during the panel read", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-23T12:00:00.000Z")); await insertChannel(database, "kanal-a", "Alpha"); @@ -458,11 +448,14 @@ describe("Panel read endpoints", () => { streamStartedAt: "2026-09-23T09:00:00.000Z", }); await Promise.all(runtime.tasks); - expect(fetcher).toHaveBeenCalledTimes(1); - expect(requestedUrl(fetcher.mock.calls[0]?.[0])).toContain("/helix/streams?"); + expect(fetcher).not.toHaveBeenCalled(); + expect(runtime.tasks).toHaveLength(0); + await expect(database.prepare( + "SELECT state, source FROM channel_stream_state WHERE channel_id = 'kanal-a'", + ).first()).resolves.toEqual({ state: "online", source: "helix" }); }); - it("drains a committed background stream refresh when its readback fails after the response", async () => { + it("does not read back or commit stream refresh state from the overview route", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-23T12:00:00.000Z")); await insertChannel(database, "kanal-a", "Alpha"); @@ -512,7 +505,6 @@ describe("Panel read endpoints", () => { }, }) as unknown as D1Database; const runtime = makeStreamRefreshRuntime(); - const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); const response = await panelRouter.fetch( await makeRequest("user-1", "/api/channels/kanal-a/overview"), @@ -528,19 +520,14 @@ describe("Panel read endpoints", () => { expect(response.status).toBe(200); await Promise.all(runtime.tasks); - expect(streamStateReads).toBe(2); + expect(streamStateReads).toBe(0); expect((await database.prepare( "SELECT state FROM channel_stream_state WHERE channel_id = 'kanal-a'", - ).first<{ state: string }>())?.state).toBe("offline"); - const deliveredRevisions = runtime.channelObject.reconcilePanelResources.mock.calls[0]?.[0]; - expect(deliveredRevisions?.["channel.overview"]).toBeGreaterThan(0); - expect((await database.prepare( - "SELECT COUNT(*) AS count FROM panel_resource_pending WHERE channel_id = 'kanal-a'", - ).first<{ count: number }>())?.count).toBe(0); - warning.mockRestore(); + ).first<{ state: string }>())?.state).toBe("online"); + expect(runtime.channelObject.reconcilePanelResources).not.toHaveBeenCalled(); }); - it("serves stored overview controls while Helix binds a pending pause in the background", async () => { + it("leaves pending pause controls unchanged during an overview read", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-23T12:00:00.000Z")); await insertChannel(database, "kanal-a", "Alpha"); @@ -562,10 +549,10 @@ describe("Panel read endpoints", () => { "2026-09-18T00:00:00.000Z", "2026-09-18T00:00:00.000Z", ); - const startedAt = "2026-09-23T11:59:00.000Z"; - vi.stubGlobal("fetch", vi.fn().mockResolvedValue( - new Response(JSON.stringify({ data: [{ id: "live-1", started_at: startedAt }] }), { status: 200 }), - )); + const fetcher = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [{ id: "live-1", started_at: "2026-09-23T11:59:00.000Z" }] }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetcher); const runtime = makeStreamRefreshRuntime(); const response = await panelRouter.fetch( @@ -581,12 +568,14 @@ describe("Panel read endpoints", () => { controls: { pause: { active: false, pending: true, mode: "until_stream_end" } }, }); await Promise.all(runtime.tasks); + expect(runtime.tasks).toHaveLength(0); + expect(fetcher).not.toHaveBeenCalled(); await expect(database.prepare( "SELECT pause_stream_started_at FROM channel_controls WHERE channel_id = 'kanal-a'", - ).first()).resolves.toEqual({ pause_stream_started_at: startedAt }); + ).first()).resolves.toEqual({ pause_stream_started_at: null }); }); - it("stores the real Helix stream start, not the check time, after the overview response (#178)", async () => { + it("does not fetch or store a stream start from an overview read (#178)", async () => { await insertChannel(database, "kanal-a", "Alpha"); await insertLoginIdentityAndSession(database, "user-1"); await insertMember(database, "kanal-a", "user-1", "manager"); @@ -612,9 +601,11 @@ describe("Panel read endpoints", () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ channelId: "kanal-a", streamState: null }); await Promise.all(runtime.tasks); + expect(fetcher).not.toHaveBeenCalled(); + expect(runtime.tasks).toHaveLength(0); await expect(database.prepare( "SELECT state, started_at FROM channel_stream_state WHERE channel_id = 'kanal-a'", - ).first()).resolves.toEqual({ state: "online", started_at: "2026-09-20T10:00:00.000Z" }); + ).first()).resolves.toBeNull(); }); it("returns the stored stream start for an EventSub-sourced online row, not its changed_at (#178)", async () => { diff --git a/tests/unit/votekick-routes.test.ts b/tests/unit/votekick-routes.test.ts index b50cc353..91dafb90 100644 --- a/tests/unit/votekick-routes.test.ts +++ b/tests/unit/votekick-routes.test.ts @@ -116,7 +116,7 @@ describe("Votekick routes", () => { expect(own.status).toBe(200); await expect(own.json()).resolves.toMatchObject({ running: { id: "ballot-a" }, votekicks: [{ id: "ballot-a" }] }); - expect(runModuleAlarm).toHaveBeenCalledWith("votekick", "close", "close:ballot-a"); + expect(runModuleAlarm).not.toHaveBeenCalled(); expect(foreign.status).toBe(403); expect(foreignCancel.status).toBe(403); expect(foreignRow).toEqual({ status: "running" }); @@ -199,7 +199,7 @@ describe("Votekick routes", () => { expect(revision?.revision).toBeGreaterThan(0); }); - it("reconciles a running votekick through its alarm handler on panel access", async () => { + it("reads a running votekick without invoking its alarm handler", async () => { const database = await databaseFor(); await insertLoginIdentityAndSession(database, "operator-a"); await insertMember(database, "channel-a", "operator-a", "operator"); @@ -211,6 +211,6 @@ describe("Votekick routes", () => { ); expect(response.status).toBe(200); - expect(runModuleAlarm).toHaveBeenCalledWith("votekick", "close", "close:ballot-panel"); + expect(runModuleAlarm).not.toHaveBeenCalled(); }); }); From 93a351078585a0d20073defedee978e2e581b24e Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 10 Oct 2026 21:05:32 +0200 Subject: [PATCH 16/16] =?UTF-8?q?=F0=9F=90=9B=20fix(realtime):=20passive?= =?UTF-8?q?=20ballot=20reads=20for=20panel=20GETs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Runtime - Add passive ballot snapshot and availability RPCs for panel reads. - Regression coverage (#387) - Exercise registered panel GETs against expired chat-voting and votekick ballots; assert unchanged D1/DO revision vectors and zero hints. --- src/modules/chat_voting/routes.ts | 6 +- src/modules/contract.ts | 4 + src/worker/durable/ChannelObject.ts | 10 ++ src/worker/durable/ballots.ts | 58 +++++++++- src/worker/module-ballots.ts | 2 + tests/unit/panel-resource-revisions.test.ts | 119 ++++++++++++++++++++ tests/worker/channel-object.test.ts | 32 ++++++ 7 files changed, 222 insertions(+), 9 deletions(-) diff --git a/src/modules/chat_voting/routes.ts b/src/modules/chat_voting/routes.ts index dfeef709..f5bc53b0 100644 --- a/src/modules/chat_voting/routes.ts +++ b/src/modules/chat_voting/routes.ts @@ -50,12 +50,12 @@ chatVotingRoutes.get("/current", async (context) => { }; const ballots = context.get("ballots")(channelId); if (vote === null) { - const hasOpenBallot = await ballots.hasOpenBallot?.() ?? false; + const hasOpenBallot = await ballots.hasOpenBallotSnapshot?.() ?? false; return context.json({ vote: null, counts: null, revision: 0, terms: null, moreTerms: null, hasOpenBallot, defaultDurationSeconds: effectiveSettings.autoCloseSeconds, defaultLabels }); } const [snapshot, hasOpenBallot] = await Promise.all([ - vote.status === "open" ? ballots.read(vote.id) : Promise.resolve(null), - ballots.hasOpenBallot?.() ?? Promise.resolve(false), + vote.status === "open" ? ballots.readSnapshot?.(vote.id) ?? Promise.resolve(null) : Promise.resolve(null), + ballots.hasOpenBallotSnapshot?.() ?? Promise.resolve(false), ]); return context.json({ vote, diff --git a/src/modules/contract.ts b/src/modules/contract.ts index 582f159e..b4092acd 100644 --- a/src/modules/contract.ts +++ b/src/modules/contract.ts @@ -809,6 +809,10 @@ export interface ModuleBallotAccess { read: (ballotId: string) => Promise; /** Reads whether any module currently owns the channel's exclusive ballot. */ hasOpenBallot?: () => Promise; + /** Reads a snapshot without finalizing an expired ballot or changing its alarm. */ + readSnapshot?: (ballotId: string) => Promise; + /** Reads channel-wide availability without finalizing or cleaning up a ballot. */ + hasOpenBallotSnapshot?: () => Promise; close: (ballotId: string) => Promise; /** Atomically finalizes using the stored pass rule and returns the outcome and snapshot. */ finalize: (ballotId: string) => Promise; diff --git a/src/worker/durable/ChannelObject.ts b/src/worker/durable/ChannelObject.ts index 15f33308..d2e25af0 100644 --- a/src/worker/durable/ChannelObject.ts +++ b/src/worker/durable/ChannelObject.ts @@ -98,9 +98,11 @@ import { expireStoredBallot, finalizeStoredBallot, hasOpenStoredBallot, + hasOpenStoredBallotSnapshot, hardDeleteFinalizedStoredBallot, openStoredBallot, readStoredBallot, + readStoredBallotSnapshot, setStoredBallotBlockedTerms, } from "./ballots"; @@ -1732,6 +1734,10 @@ export class ChannelObject extends DurableObject { return snapshot; } + public async readBallotSnapshot(moduleId: string, ballotId: string): Promise { + return await readStoredBallotSnapshot(this.ballotStorage(moduleId), moduleId, ballotId); + } + public async hasOpenBallot(): Promise { const hasOpen = await hasOpenStoredBallot(this.ballotStorage(CHAT_VOTING_MODULE_ID), async (transaction, moduleId, ballotId, hardDeleteAt) => { await this.writeBallotAlarmInTransaction( @@ -1745,6 +1751,10 @@ export class ChannelObject extends DurableObject { return hasOpen; } + public async hasOpenBallotSnapshot(): Promise { + return await hasOpenStoredBallotSnapshot(this.ballotStorage(CHAT_VOTING_MODULE_ID)); + } + public async closeBallot(moduleId: string, ballotId: string): Promise { const result = await closeStoredBallot(this.ballotStorage(moduleId), moduleId, ballotId); if (result !== null) await this.notifyCommittedPanelResources(); diff --git a/src/worker/durable/ballots.ts b/src/worker/durable/ballots.ts index 168ff1cb..1eb42a3d 100644 --- a/src/worker/durable/ballots.ts +++ b/src/worker/durable/ballots.ts @@ -197,6 +197,28 @@ const resultForFinalization = ( ...(snapshot.termFilterReady === undefined ? {} : { termFilterReady: snapshot.termFilterReady }), }); +const outcomeForBallot = (ballot: StoredBallot, now: number): Exclude => { + const existing = finalizationOf(ballot); + if (existing !== null) return existing.outcome; + const snapshot = snapshotOf(ballot); + const rulePassed = ballot.passRule !== undefined && + (snapshot.counts[ballot.passRule.passIf.yes] ?? 0) - + (snapshot.counts[ballot.passRule.passIf.no] ?? 0) >= ballot.passRule.passIf.netAtLeast; + return ballot.frozen === true || rulePassed ? "passed" : now >= ballot.expiresAt ? "expired" : "open"; +}; + +const terminalSnapshot = ( + outcome: Exclude, + snapshot: Omit, +): BallotSnapshot => ({ + counts: [...snapshot.counts], + revision: snapshot.revision, + ...(snapshot.terms === undefined ? {} : { terms: snapshot.terms.map((entry) => ({ ...entry })) }), + ...(snapshot.more === undefined ? {} : { more: snapshot.more }), + ...(snapshot.termFilterReady === undefined ? {} : { termFilterReady: snapshot.termFilterReady }), + outcome, +}); + const isActiveBallot = (value: unknown): value is ActiveBallot => { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const active = value as Partial; @@ -260,12 +282,7 @@ const finalizeInTransaction = async ( } const snapshot = snapshotOf(ballot); - const rulePassed = ballot.passRule !== undefined && - (snapshot.counts[ballot.passRule.passIf.yes] ?? 0) - - (snapshot.counts[ballot.passRule.passIf.no] ?? 0) >= ballot.passRule.passIf.netAtLeast; - const outcome: BallotFinalizeOutcome = ballot.frozen === true || rulePassed - ? "passed" - : now >= ballot.expiresAt ? "expired" : "open"; + const outcome = outcomeForBallot(ballot, now); if (outcome === "open") return resultForFinalization(outcome, snapshot); const hardDeleteAt = now + BALLOT_FINALIZATION_RETENTION_MS; @@ -630,6 +647,24 @@ export const readStoredBallot = async ( }); }; +/** Reads a ballot snapshot without finalizing it or changing its alarm state. */ +export const readStoredBallotSnapshot = async ( + storage: BallotStorage, + moduleId: string, + ballotId: string, +): Promise => { + validId(moduleId, "Module id"); + validId(ballotId, "Ballot id"); + return await storage.transaction(async (transaction) => { + const ballot = await transaction.get(ballotKey(moduleId, ballotId)); + if (!isStoredBallot(ballot) || ballot.moduleId !== moduleId || ballot.ballotId !== ballotId) return null; + const finalization = finalizationOf(ballot); + if (finalization !== null) return terminalSnapshot(finalization.outcome, finalization.snapshot); + const outcome = outcomeForBallot(ballot, Date.now()); + return outcome === "open" ? snapshotOf(ballot) : terminalSnapshot(outcome, snapshotOf(ballot)); + }); +}; + /** Reads the channel-wide ballot lock without exposing another module's ballot id. */ export const hasOpenStoredBallot = async ( storage: BallotStorage, @@ -648,6 +683,17 @@ export const hasOpenStoredBallot = async ( return (await finalizeInTransaction(transaction, ballot, Date.now(), onFinalized)).outcome === "open"; }); +/** Reads channel-wide ballot availability without cleaning up or finalizing stored state. */ +export const hasOpenStoredBallotSnapshot = async ( + storage: BallotStorage, +): Promise => await storage.transaction(async (transaction) => { + const active = await transaction.get(ACTIVE_BALLOT_KEY); + if (!isActiveBallot(active) || !ID_PATTERN.test(active.moduleId) || !ID_PATTERN.test(active.ballotId)) return false; + const ballot = await transaction.get(ballotKey(active.moduleId, active.ballotId)); + if (!isStoredBallot(ballot) || ballot.moduleId !== active.moduleId || ballot.ballotId !== active.ballotId) return false; + return outcomeForBallot(ballot, Date.now()) === "open"; +}); + const deleteVoters = async ( transaction: BallotTransaction, moduleId: string, diff --git a/src/worker/module-ballots.ts b/src/worker/module-ballots.ts index 3278716d..7f31a4e8 100644 --- a/src/worker/module-ballots.ts +++ b/src/worker/module-ballots.ts @@ -28,6 +28,8 @@ export const moduleBallots = ( approveTerm: (ballotId, term) => object.approveTerm(moduleId, ballotId, term), read: (ballotId) => object.readBallot(moduleId, ballotId), hasOpenBallot: () => object.hasOpenBallot(), + readSnapshot: (ballotId) => object.readBallotSnapshot(moduleId, ballotId), + hasOpenBallotSnapshot: () => object.hasOpenBallotSnapshot(), close: (ballotId) => object.closeBallot(moduleId, ballotId), finalize: (ballotId) => object.finalizeBallot(moduleId, ballotId), acknowledgeClosed: (ballotId) => object.acknowledgeClosedBallot(moduleId, ballotId), diff --git a/tests/unit/panel-resource-revisions.test.ts b/tests/unit/panel-resource-revisions.test.ts index a9fca5e2..9b573d4f 100644 --- a/tests/unit/panel-resource-revisions.test.ts +++ b/tests/unit/panel-resource-revisions.test.ts @@ -461,6 +461,125 @@ describe("panel resource revisions", () => { expect(loginMaintenance).toMatch(/if \(revoked\) \{\s*const pending = await getPendingRealtimeUserRevocation[\s\S]*?await revokeRealtimeUserFromAllChannels/u); }); + it("keeps every registered channel panel GET read-only with an expired ballot", async () => { + const channelId = "panel-get-read-only-channel"; + const userId = "panel-get-read-only-operator"; + const database = new TestD1Database(); + const cookieKeys = JSON.stringify({ active: { id: "cookie-v1", key: testKey(3) }, retired: [] }); + const encryptionKeys = JSON.stringify({ active: { id: "encryption-v1", key: testKey(4) }, retired: [] }); + const expiredBallots = new Map([ + ["chat_voting/expired-poll", { moduleId: "chat_voting", ballotId: "expired-poll", counts: [1, 0], revision: 2 }], + ["votekick/expired-kick", { moduleId: "votekick", ballotId: "expired-kick", counts: [1, 0], revision: 2 }], + ]); + const doRevisions: Record = { + "module:chat_voting:panel": 2, + "module:votekick:panel": 2, + }; + const hints: string[] = []; + let activeBallot: { moduleId: string; ballotId: string } = { moduleId: "chat_voting", ballotId: "expired-poll" }; + const finalizeExpired = (moduleId: string, ballotId: string): void => { + if (!expiredBallots.has(`${moduleId}/${ballotId}`)) return; + doRevisions[`module:${moduleId}:panel`] = (doRevisions[`module:${moduleId}:panel`] ?? 0) + 1; + doRevisions["module:chat_voting:panel"] = (doRevisions["module:chat_voting:panel"] ?? 0) + Number(moduleId !== "chat_voting"); + if (activeBallot.moduleId === moduleId && activeBallot.ballotId === ballotId) activeBallot = { moduleId: "", ballotId: "" }; + hints.push(`module:${moduleId}:panel`); + }; + const snapshotFor = (moduleId: string, ballotId: string) => { + const ballot = expiredBallots.get(`${moduleId}/${ballotId}`); + return ballot === undefined ? null : { counts: [...ballot.counts], revision: ballot.revision, outcome: "expired" as const }; + }; + const channelObject = new Proxy({ + getPanelResourceRevisions: vi.fn(() => Promise.resolve({ ...doRevisions })), + readBallot: vi.fn((moduleId: string, ballotId: string) => { + finalizeExpired(moduleId, ballotId); + return Promise.resolve(snapshotFor(moduleId, ballotId)); + }), + hasOpenBallot: vi.fn(() => { + finalizeExpired(activeBallot.moduleId, activeBallot.ballotId); + return Promise.resolve(false); + }), + readBallotSnapshot: vi.fn((moduleId: string, ballotId: string) => Promise.resolve(snapshotFor(moduleId, ballotId))), + hasOpenBallotSnapshot: vi.fn(() => Promise.resolve(false)), + getCachedAdSchedule: vi.fn(() => Promise.resolve(null)), + getTwitchRateLimitRetryAfter: vi.fn(() => Promise.resolve(null)), + }, { + get: (target, property, receiver) => { + if (Reflect.has(target, property)) return Reflect.get(target, property, receiver) as unknown; + return () => Promise.resolve(null); + }, + }); + const environment = { + DB: database as unknown as D1Database, + CHANNEL: { + idFromName: (id: string) => id, + get: () => channelObject, + }, + SESSION_COOKIE_KEYS: cookieKeys, + SESSION_ENCRYPTION_KEYS: encryptionKeys, + PUBLIC_ORIGIN: "https://brobot.example", + TWITCH_CLIENT_ID: "test-client", + } as unknown as Env; + const sessionCookie = await createSessionCookie({ sessionId: `session-${userId}` }, cookieKeys, encryptionKeys); + + try { + await insertChannel(database, channelId); + await insertLoginIdentityAndSession(database, userId); + await insertMember(database, channelId, userId, "manager"); + await database.prepare( + "INSERT INTO channel_modules (channel_id, module_id, enabled, settings) VALUES (?, 'chat_voting', 1, '{}')", + ).bind(channelId).run(); + await database.prepare( + `INSERT INTO chat_votes + (channel_id, poll_id, kind, preset, legacy_written, option_count, labels_json, status, opened_at, closes_at, close_reason) + VALUES (?, 'expired-poll', 'yes_no', 'yes_no', 0, 2, '["Yes","No"]', 'open', ?, ?, 'timer')`, + ).bind(channelId, new Date(Date.now() - 120_000).toISOString(), new Date(Date.now() - 60_000).toISOString()).run(); + await database.prepare( + `INSERT INTO votekicks (channel_id, votekick_id, status, threshold, yes_votes, no_votes, ballot_revision, started_at, expires_at) + VALUES (?, 'expired-kick', 'running', 3, 1, 0, 2, ?, ?)` , + ).bind(channelId, new Date(Date.now() - 120_000).toISOString(), new Date(Date.now() - 60_000).toISOString()).run(); + + const registeredGetPaths = [...new Set(panelRouter.routes + .filter(({ method, path }) => method === "GET" && (path === "/api/channels" || path.startsWith("/api/channels/:channelId"))) + .map(({ path }) => path))]; + expect(registeredGetPaths.length).toBeGreaterThan(10); + const parameterValues: Record = { + channelId, + moduleId: "chat_voting", + overlayId: "missing-overlay", + tokenId: "missing-token", + id: "missing-id", + name: "missing-name", + userId: "missing-user", + }; + const routeUrl = (path: string): string => path.replace(/:([A-Za-z0-9_]+)/gu, (_match, name: string) => parameterValues[name] ?? "missing-param"); + const totalChanges = async (): Promise => + (await database.prepare("SELECT total_changes() AS value").first<{ value: number }>())?.value ?? 0; + + for (const fixture of [ + { moduleId: "chat_voting", ballotId: "expired-poll" }, + { moduleId: "votekick", ballotId: "expired-kick" }, + ]) { + activeBallot = fixture; + for (const path of registeredGetPaths) { + const revisionsBefore = await readCompletePanelResourceRevisions(environment, channelId); + const d1ChangesBefore = await totalChanges(); + const request = new Request(`https://brobot.example${routeUrl(path)}`, { + headers: { Cookie: `__Host-brobot_session=${sessionCookie}` }, + }); + + await panelRouter.fetch(request, environment); + + expect(await totalChanges(), `D1 writes from GET ${path} with ${fixture.moduleId} expired`).toBe(d1ChangesBefore); + expect(await readCompletePanelResourceRevisions(environment, channelId), + `revision vector from GET ${path} with ${fixture.moduleId} expired`).toEqual(revisionsBefore); + expect(hints, `realtime hints from GET ${path} with ${fixture.moduleId} expired`).toEqual([]); + } + } + } finally { + database.close(); + } + }); + it("does not enumerate channels for an unauthenticated OAuth callback", async () => { const prepare = vi.fn(); const idFromName = vi.fn((channelId: string) => channelId); diff --git a/tests/worker/channel-object.test.ts b/tests/worker/channel-object.test.ts index ce4eb430..08e4fbd7 100644 --- a/tests/worker/channel-object.test.ts +++ b/tests/worker/channel-object.test.ts @@ -1956,6 +1956,38 @@ describe("ChannelObject realtime path", () => { await expect(object.hasOpenBallot()).resolves.toBe(false); }); + it.each([ + ["chat_voting", "expired-poll"], + ["votekick", "expired-kick"], + ])("reads an expired %s ballot snapshot and availability without committing changes", async (moduleId, ballotId) => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const panel = socketFor(validPrincipal()); + const object = objectFor([panel]); + await object.openBallot(moduleId, ballotId, 2, 20_000); + await object.castBallot(moduleId, ballotId, "viewer-1", 1); + + const storage = storageOf(object); + const key = `ballot:${moduleId}:${ballotId}`; + const ballotBefore = structuredClone(storage.values.get(key)); + const activeBefore = structuredClone(storage.values.get("ballot:active")); + const revisionsBefore = await object.getPanelResourceRevisions(); + panel.send.mockClear(); + vi.setSystemTime(20_000); + + const [snapshot, available] = await Promise.all([ + object.readBallotSnapshot(moduleId, ballotId), + object.hasOpenBallotSnapshot(), + ]); + + expect(snapshot).toEqual({ counts: [1, 0], revision: 1, outcome: "expired" }); + expect(available).toBe(false); + expect(storage.values.get(key)).toEqual(ballotBefore); + expect(storage.values.get("ballot:active")).toEqual(activeBefore); + expect(await object.getPanelResourceRevisions()).toEqual(revisionsBefore); + expect(panel.send.mock.calls).toEqual([]); + }); + it("preserves an active ballot alarm when another open uses the same id or invalid arguments", async () => { vi.useFakeTimers(); vi.setSystemTime(10_000);

{labels.name}{labels.elements}{labels.accesses}{labels.lastUsedAt}
{overlay.name} {overlay.elementCount} {overlay.accessCount}