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/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/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/migrations/0051_panel_resource_delivery_leases.sql b/migrations/0051_panel_resource_delivery_leases.sql new file mode 100644 index 00000000..90659e6d --- /dev/null +++ b/migrations/0051_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/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 58e095fa..1dec3ea1 100644 --- a/src/dashboard/ChannelVariablesPage.tsx +++ b/src/dashboard/ChannelVariablesPage.tsx @@ -6,18 +6,20 @@ import { deleteChannelVariable, fetchOverlay, fetchOverlays, - fetchOverlayTokens, - fetchChannelVariables, PanelApiError, saveOverlay, updateChannelVariable, type PanelChannelVariable, + type PanelChannelVariableRecord, + type PanelChannelVariablesResponse, 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, useLegacyOverlayTokensQuery, useOverlaysQuery } from "./data/lists"; interface ChannelVariablesPageProperties { channelId: string; @@ -31,7 +33,10 @@ 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 => ({ id: element.id, kind: element.kind, @@ -49,11 +54,14 @@ const saveableOverlayElement = (element: PanelOverlayElement): Omit([]); + 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; + 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,50 +75,51 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o const [pending, setPending] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [useOverlayOpen, setUseOverlayOpen] = useState(false); - const [legacyLinkStatus, setLegacyLinkStatus] = useState<{ channelId: string; hasLinks: boolean } | null>(null); - const [overlayOptions, setOverlayOptions] = useState([]); + const overlaysQuery = useOverlaysQuery(channelId, useOverlayOpen); + const overlayOptions = (overlaysQuery.data?.overlays ?? []).map(({ id, name, elementCount }) => ({ id, name, elementCount })); + const refetchVariables = variablesQuery.refetch; 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]); - - useEffect(() => { - void Promise.resolve().then(refresh); - return () => { invalidateRefresh(); }; - }, [invalidateRefresh, refresh]); - - useEffect(() => { - let active = true; - void fetchOverlayTokens(channelId).then(({ tokens }) => { - if (active) setLegacyLinkStatus({ channelId, hasLinks: tokens.length > 0 }); - }).catch(() => { - if (active) setLegacyLinkStatus({ channelId, hasLinks: false }); + 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, + }; }); - return () => { active = false; }; - }, [channelId]); + }; + 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); }; - useRealtimeVariableUpdates({ channelId, refresh }); + useEffect(() => { + 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]); const selected = useMemo(() => variables.find((variable) => variable.name === selectedName) ?? null, [selectedName, variables]); useLayoutEffect(() => { @@ -118,7 +127,7 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o 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}`; @@ -137,7 +146,6 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o setDraftReset(false); setDraftSetValue(0); setUseOverlayOpen(false); - setOverlayOptions([]); setOverlaySelection(""); setCreatingOverlay(false); setNewOverlayName(""); @@ -151,7 +159,6 @@ export function ChannelVariablesPage({ channelId, canManage: canManageContent, o setDraftReset(variable.resetOnStreamStart); setDraftSetValue(variable.value); setUseOverlayOpen(false); - setOverlayOptions([]); setOverlaySelection(""); setCreatingOverlay(false); setNewOverlayName(""); @@ -191,19 +198,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 }); @@ -216,8 +225,9 @@ 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)); + 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 }); @@ -238,8 +248,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)); @@ -279,7 +291,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 { @@ -292,8 +304,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 35440b19..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"; @@ -23,9 +24,7 @@ import { import { DisabledFieldReasonContext } from "./ui/DisabledFieldReason"; import { createOverlay, - fetchChannelVariables, fetchModules, - fetchOverlay, PanelApiError, saveOverlay, type PanelChannelVariable, @@ -36,7 +35,8 @@ 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 { 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,63 +314,93 @@ 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 [moduleStates, setModuleStates] = useState([]); + 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: readonly PanelModuleState[] = modulesQuery.data?.modules ?? []; 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 moduleStatesReady = modulesQuery.data !== undefined || modulesQuery.isError; + const overlayLoadError = session === null && overlayId !== "new" && overlayQuery.isError + ? 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(() => { - let disposed = false; - const overlayRequest = overlayId === "new" - ? Promise.resolve({ overlay: { + 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; + notify({ tone: "error", message }); + return; + } + if (variablesQuery.data === undefined) return; + const variableData = variablesQuery.data; + if (overlayId !== "new" && overlayQuery.isError) { + const message = overlayQuery.error instanceof PanelApiError + ? apiErrorText(overlayQuery.error.code, labels.editorLoadError) + : labels.editorLoadError; + notify({ tone: "error", message }); + return; + } + if (overlayId !== "new" && (overlayQuery.data === undefined || overlayQuery.isFetching)) return; + const overlay = overlayId === "new" + ? { id: "new", channelId, name: initialOverlayName ?? "", width: 1920, height: 1080, css: "", revision: 1, createdAt: "", updatedAt: "", elements: [], - } satisfies PanelOverlay }) - : fetchOverlay(channelId, overlayId); - const moduleRequest = fetchModules(channelId).catch(() => ({ modules: [] })); - void Promise.all([overlayRequest, fetchChannelVariables(channelId), moduleRequest]) - .then(([overlayResult, variableResult, moduleResult]) => { - if (disposed) return; - setVariables(variableResult.variables); - 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(overlayResult.overlay, variableResult.variables, requestedVariable)); - }) - .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; }; - }, [channelId, initialOverlayName, labels.editorLoadError, loadGeneration, overlayId]); + } satisfies PanelOverlay + : overlayQuery.data?.overlay; + if (overlay === undefined) return; + 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 => { 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 || !moduleStatesReady) return
- {labels.editorLoading}

} empty={} error={}> diff --git a/src/dashboard/OverlaysPage.tsx b/src/dashboard/OverlaysPage.tsx index c03118c1..ac5af8f5 100644 --- a/src/dashboard/OverlaysPage.tsx +++ b/src/dashboard/OverlaysPage.tsx @@ -7,10 +7,6 @@ import streamElementsJs from "../../docs/embedding/streamelements/widget.js?raw" import { createOverlay, deleteOverlay, - fetchOverlay, - fetchOverlayAccesses, - fetchOverlays, - fetchOverlayTokens, importLegacyOverlay, issueOverlayAccess, removeOverlayAccess, @@ -27,6 +23,9 @@ 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 { useLegacyOverlayTokensQuery, useOverlayAccessesQuery, useOverlayQuery, useOverlaysQuery } from "./data/lists"; +import { dashboardDataKeys } from "./data/keys"; +import { useDashboardQueryError } from "./data"; 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,20 +232,20 @@ 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 [selectedOverlayData, setSelectedOverlayData] = useState<{ - id: string; - overlay: PanelOverlay; - accesses: readonly PanelOverlayAccess[]; - } | null>(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 [creating, setCreating] = useState(false); const [draftName, setDraftName] = useState(blankOverlayName); const [draftSize, setDraftSize] = useState("1920x1080"); @@ -262,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); @@ -273,9 +269,7 @@ 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); const permissionRef = useRef(canManage); const channelIdRef = useRef(channelId); @@ -310,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); @@ -321,57 +314,33 @@ 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 accesses = selectedOverlayData?.id === selectedId ? selectedOverlayData.accesses : []; + const refetchOverlays = overlaysQuery.refetch; + const selectedOverlay = selectedOverlayQuery.data?.overlay ?? null; + const selectedOverlayLoadFailed = selectedOverlayError !== null; + 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; 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]); - - 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]); + }, [changeSelection, initialSelection, labels.loadError, refetchOverlays]); 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 () => { @@ -383,40 +352,18 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi return () => { window.clearInterval(timer); }; }, []); - 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]) => { - if (!active) return; - setSelectedOverlayData({ id: selectedId, overlay: overlayResult.overlay, 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 [overlayResult, accessResult] = await Promise.all([ - fetchOverlay(channelId, overlayId), - fetchOverlayAccesses(channelId, overlayId), + await Promise.all([ + selectedOverlayQuery.refetch({ throwOnError: true }), + selectedOverlayAccessesQuery.refetch({ throwOnError: true }), ]); - if (channelIdRef.current === channelId && selectedIdRef.current === overlayId) { - setSelectedOverlayData({ id: overlayId, overlay: overlayResult.overlay, accesses: accessResult.accesses }); - } }; const beginCreate = (): void => { setCreating(true); changeSelection(null); - setSelectedOverlayData(null); setDraftName(""); setDraftSize("1920x1080"); setDraftWidth(1920); @@ -443,7 +390,6 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi const closeInspector = (): void => { setCreating(false); changeSelection(null); - setSelectedOverlayData(null); setError(null); }; @@ -455,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) { @@ -664,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 { @@ -686,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) }); @@ -743,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); } }}> @@ -766,7 +711,7 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi

{labels.legacyDescription}

{manageReason === undefined ? null :

{manageReason}

}
} empty={

{labels.legacyEmpty}

} @@ -785,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 : } } @@ -814,7 +759,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}
@@ -911,6 +865,7 @@ export function OverlaysPage({ channelId, canManage, initialSelection, onOpenEdi { setConfirmDelete(true); }}>{labels.delete}} />
} + ; return <> diff --git a/src/dashboard/api.ts b/src/dashboard/api.ts index 73e61aff..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, @@ -183,8 +186,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 +204,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 { @@ -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; @@ -552,8 +555,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..2a842f75 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)); } } @@ -40,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/index.ts b/src/dashboard/data/index.ts index ec7c9d01..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, ModuleQueryPart, ModuleQueryRevision, ModuleQueryWriteFunction, ModuleQueryWriteOptions } 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/keys.ts b/src/dashboard/data/keys.ts index b22fd48d..3c6aa081 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,32 @@ 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 }), + 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 02cf1957..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, fetchEvents, fetchMembers, 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,56 @@ export const useEventsQuery = (channelId: string, filters: PanelEventFilters) => }); }; -export const useSystemQuery = (channelId: string, enabled: boolean) => useQuery({ +export const useSystemQuery = (channelId: string, enabled: boolean, offlineRefetchInterval: number | false) => useQuery({ queryKey: dashboardDataKeys.system(channelId), queryFn: ({ signal }) => fetchSystemOverview(channelId, signal), enabled, + refetchInterval: offlineRefetchInterval, refetchOnWindowFocus: false, 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, + refetchOnMount: "always", + 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/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/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 new file mode 100644 index 00000000..24700cb9 --- /dev/null +++ b/src/dashboard/data/realtime.ts @@ -0,0 +1,562 @@ +import { useCallback, useSyncExternalStore } from "react"; +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; +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>(); +const panelRevisionSnapshots = new WeakMap>>>(); +interface PanelRevisionFetch { + lastStartedAt: number; + dirty: boolean; + retryCount: number; + promise: Promise | 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"; + +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(); + if (status === "connected") { + for (const waiter of [...(realtimeRetryWaiters.get(channelId) ?? [])]) waiter.resume(); + } +}; + +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[], + 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); + 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, + 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, scheduledQueryRefreshes, resourceRefreshes, channelId); + } + } +}; + +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, scheduledQueryRefreshes, resourceRefreshes, channelId); + } + } +}; + +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, scheduledQueryRefreshes, resourceRefreshes, channelId); + } + } +}; + +const invalidatePanelResource = ( + queryClient: QueryClient, + channelId: string, + resource: string, + scheduledQueryRefreshes: Map>, + resourceRefreshes: Promise[], +): void => { + switch (resource) { + case "channels": + 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"), scheduledQueryRefreshes, resourceRefreshes, channelId); + return; + case "channel.system": + invalidate(queryClient, dashboardDataKeys.system(channelId), scheduledQueryRefreshes, resourceRefreshes, channelId); + return; + case "channel.settings": + invalidate(queryClient, queryKeys.channel(channelId, "settings"), scheduledQueryRefreshes, resourceRefreshes, channelId); + return; + case "channel.members": + invalidate(queryClient, dashboardDataKeys.members(channelId), scheduledQueryRefreshes, resourceRefreshes, channelId); + return; + case "channel.modules": + 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"), scheduledQueryRefreshes, resourceRefreshes, channelId); + return; + case "channel.audit": + invalidate(queryClient, queryKeys.channel(channelId, "audit-log"), scheduledQueryRefreshes, resourceRefreshes, channelId); + return; + case "channel.variables": + 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), scheduledQueryRefreshes, resourceRefreshes, channelId); + return; + case "channel.overlay-accesses": + 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"), 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; + } + + 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, scheduledQueryRefreshes, resourceRefreshes); + return; + } + if (moduleId === "belabox" && part === "live") { + invalidateBelaboxLiveQueries(queryClient, channelId, scheduledQueryRefreshes, resourceRefreshes); + return; + } + invalidate(queryClient, queryKeys.module(channelId, moduleId, part), scheduledQueryRefreshes, resourceRefreshes, channelId); +}; + +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); + +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, +): 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); + } + let state = fetches.get(channelId); + if (state === undefined) { + state = { lastStartedAt: Number.NEGATIVE_INFINITY, dirty: false, retryCount: 0, promise: null }; + fetches.set(channelId, state); + } + if (state.promise !== null) { + state.dirty = true; + return state.promise; + } + const scheduled = state; + const generation = realtimeRetryGeneration(queryClient, channelId); + const request = (async (): Promise => { + 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(); + scheduled.dirty = false; + break; + } + 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; + } + } + } + } catch { + 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; + } + } + } + })().finally(() => { + scheduled.promise = null; + }); + scheduled.promise = request; + 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[], + retryChannelId?: string, +): Promise => { + 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: 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); + } + state.pending = true; + 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; +}; + +/** + * 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, +): 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, ["channel", channelId], undefined, undefined, 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/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 95b0408f..81cd72cd 100644 --- a/src/dashboard/main.tsx +++ b/src/dashboard/main.tsx @@ -57,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"; @@ -382,10 +383,86 @@ 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(""); + 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; + 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) { + refreshExpiredControl(staleExpiryKey); + } + 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); + if (retryTimer !== null) window.clearTimeout(retryTimer); + }; + }, [controls]); + 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 +470,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; @@ -421,7 +482,6 @@ const ChannelControlActions = ({ channelId, controls, now, onRefresh }: { try { await setChannelControl(channelId, kind, next); setDialogControl(null); - await onRefresh(); } catch (requestError: unknown) { notify({ tone: "error", @@ -429,34 +489,18 @@ const ChannelControlActions = ({ channelId, controls, now, onRefresh }: { ? apiErrorText(requestError.code, labels.failure) : labels.failure, }); - } finally { setBusy(null); + return; } - }; - - 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)); + try { + await onRefresh(); + } catch { + notify({ tone: "error", message: labels.refreshFailure }); + } finally { + setBusy(null); } - 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 +524,7 @@ const ChannelControlActions = ({ channelId, controls, now, onRefresh }: { disabled={busy !== null} onClick={() => { if (muteConfigured) void apply("mute", null); else setDialogControl("mute"); }} /> - {controlStateSlot(mute, "mute", muteConfigured)} +
+ { +const DashboardConnectionClock = ({ channel, loadedAt }: { + channel: PanelChannelState; + loadedAt?: number; +}): ReactElement => { const texts = dashboardTexts(); - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const id = window.setInterval(() => { setNow(Date.now()); }, 1000); - return () => { window.clearInterval(id); }; - }, []); - const tone = activeChannel === undefined ? "neutral" : channelStatus(activeChannel, loadedAt, now); - const connectionLabel = activeChannel === undefined - ? null - : tone === "healthy" ? texts.header.connectionRunning : statusText(activeChannel, loadedAt, now); - const connectionLed = connectionLabel === null ? null : ; - const streamState = activeChannel?.streamState; + const now = useSecondClock(); + const tone = channelStatus(channel, loadedAt, now); + const label = tone === "healthy" ? texts.header.connectionRunning : statusText(channel, loadedAt, now); + const status = tone === "healthy" ? "green" : tone === "warning" ? "amber" : tone === "error" ? "red" : "off"; + return ; +}; + +const DashboardStreamClock = ({ channel }: { channel: PanelChannelState }): ReactElement => { + const texts = dashboardTexts(); + const now = useSecondClock(); + const streamState = channel.streamState; const streamLabel = streamState === "online" - ? texts.header.streamLive(streamDuration(activeChannel?.streamStartedAt, now)) + ? texts.header.streamLive(streamDuration(channel.streamStartedAt, now)) : streamState === "offline" ? texts.header.streamOffline : texts.header.streamUnknown; + return + + {streamLabel} + {channel.streamStateCheckedAt === undefined || channel.streamStateCheckedAt === null || + !Number.isFinite(Date.parse(channel.streamStateCheckedAt)) ? null : ( + + )} + ; +}; + +const DashboardHeader = ({ route, channels, activeChannel, loadedAt, onNavigate, onLogout, loggingOut, onRefreshState }: DashboardHeaderProperties): ReactElement => { + const texts = dashboardTexts(); return (
{ event.preventDefault(); onNavigate({ kind: "overview" }); }}>BroBot @@ -555,18 +615,11 @@ const DashboardHeader = ({ route, channels, activeChannel, loadedAt, onNavigate,
)}
- {activeChannel === undefined ? null : - - {streamLabel} - {activeChannel.streamStateCheckedAt === undefined || activeChannel.streamStateCheckedAt === null || - !Number.isFinite(Date.parse(activeChannel.streamStateCheckedAt)) ? null : ( - - )} - } - {connectionLed} + {activeChannel === undefined ? null : } + {activeChannel === undefined ? null : } {loadedAt === undefined ? null : }
- {activeChannel === undefined ? null : } + {activeChannel === undefined ? null : } ); @@ -1168,10 +1221,14 @@ 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(); const [authenticationRequired, setAuthenticationRequired] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); const requestLogin = useCallback((): void => { setAuthenticationRequired(true); }, []); useEffect(() => { window.addEventListener(dashboardAuthenticationRequiredEvent, requestLogin); @@ -1190,6 +1247,7 @@ export const DashboardApp = (): ReactElement => { ), }; }, + refetchInterval: offlineRefetchInterval, enabled: !authenticationRequired, }); const channelsResponse = channelsQuery.data; @@ -1200,6 +1258,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; @@ -1216,21 +1276,22 @@ export const DashboardApp = (): ReactElement => { 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) => previousQuery?.queryKey[1] === overviewChannelId ? previousData : undefined, }); 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, realtimeEnabled); 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, + systemChannelId === null ? false : offlineRefetchInterval, + ); 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 @@ -1258,53 +1319,10 @@ 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); - await queryClient.invalidateQueries(filter); + await queryClient.invalidateQueries(filter, { throwOnError: true }); }, [queryClient]); const clearProtectedState = (): void => { @@ -1334,7 +1352,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); @@ -1343,30 +1361,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 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 () => { 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(), @@ -1377,17 +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(); - }; - document.addEventListener("visibilitychange", refresh); - const interval = window.setInterval(refresh, 3 * 60 * 1000); - return () => { - document.removeEventListener("visibilitychange", refresh); - window.clearInterval(interval); - }; - }, []); const routeUsesOverview = route.kind === "module" || (route.kind === "channel" && route.section === "overview"); // Keep the freshest token snapshot per channel. The channels query covers all @@ -1433,9 +1424,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 4074c64a..6957d8b7 100644 --- a/src/dashboard/realtime.ts +++ b/src/dashboard/realtime.ts @@ -1,26 +1,22 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } 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 type { RealtimeEnvelope, RealtimeEventLogHint, RealtimeMessage } from "../realtime-contract"; import { REALTIME_PROTOCOL } from "../realtime-contract"; -import { eventToneEntries, type EventCode } from "./locale"; +import { + cancelDashboardRealtimeRetries, + invalidateDashboardChannelQueries, + reconcileDashboardRealtimeMessage, + reconcileDashboardPanelResourceRevisions, + 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"; @@ -46,14 +42,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") && @@ -63,6 +51,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,12 +64,12 @@ 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 === "system.hello" || value.type === "panel.resources.changed" || value.type === "event_log.new" || + value.type === "variables.changed" || value.type === "overlay.changed" || value.type === "stream.state.changed"); export const parseRealtimeMessage = (raw: string, channelId: string): RealtimeParseResult => { @@ -95,15 +89,25 @@ export const parseRealtimeMessage = (raw: string, channelId: string): RealtimePa ? { 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"> } : { 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"> } + 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 === "stream.state.changed") { @@ -117,41 +121,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"> }; + 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 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; -}; - -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 +142,174 @@ 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; + 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; + cancelDashboardRealtimeRetries(connection.queryClient, channelId); + 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 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 refreshVariables = async (): Promise => { - stopVariableRefreshTimers(); - if (disposed) return; - if (variableRefreshInFlight) { - variableRefreshQueued = true; +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; + 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; + 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; } - 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 handleMessage = (event: MessageEvent): void => { - 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 */ } + if (parsed.kind !== "message" || connection.seenMessageIds.has(parsed.message.id)) return; + connection.seenMessageIds.add(parsed.message.id); + void reconcileDashboardRealtimeMessage(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) { + cancelDashboardRealtimeRetries(connection.queryClient, channelId); + setDashboardRealtimeStatus(channelId, "renew"); return; } - if (envelope.kind !== "message") return; - relayRealtimeMessage(envelope.message); - if (envelope.message.type !== "variables.changed") return; - scheduleVariableRefresh(); - }; + schedulePanelReconnect(channelId, connection); + }); + } catch { + schedulePanelReconnect(channelId, connection); + } +}; - const handleOpen = (): void => { - reconnectAttempt = 0; - void refreshVariables(); +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, + 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); + }; +}; - const handleClose = (event: CloseEvent): void => { - if (event.code === SOCKET_EXPIRED_CODE || event.code === SOCKET_REVOKED_CODE || event.code === 1008) { - fatalProtocolError = true; - return; - } - 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(); + const activeChannelId = useRef(null); - 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(); + useEffect(() => { + 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; } - - connect(); + activeChannelId.current = channelId; + const refreshOnFocus = (): void => { + if (document.visibilityState === "visible") { + void reconcileDashboardPanelResourceRevisions(queryClient, channelId); + } + }; + window.addEventListener("focus", refreshOnFocus); + const release = acquirePanelSocket(channelId, queryClient); return () => { - disposed = true; - if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); - stopVariableRefreshTimers(); - try { socket?.close(1000, "Leaving channel variables"); } catch { /* socket may already be closed */ } + window.removeEventListener("focus", refreshOnFocus); + release(); }; - }, [channelId]); + }, [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..d8d1d108 100644 --- a/src/dashboard/stream-manager.tsx +++ b/src/dashboard/stream-manager.tsx @@ -1,16 +1,17 @@ -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 { 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"; @@ -249,65 +250,48 @@ 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 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; + 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 (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 a79dffb4..00aeaf6a 100644 --- a/src/modules/ads/panel/index.tsx +++ b/src/modules/ads/panel/index.tsx @@ -1,13 +1,13 @@ -import { useEffect, useRef, useState, type ReactElement } from "react"; +import { useRef, 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 { Button, EmptyCellValue, Icon, LoadState, Skeleton, notify } from "../../../dashboard/ui"; +import { loadAdsSchedule, refreshAdsSchedule, 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 { moduleQueryKey, runModuleQueryWrite, useDashboardQueryError, 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 "—"; @@ -19,56 +19,36 @@ 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 [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 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 scheduleLoadFailed = scheduleError !== null; + const loadStatus = schedule === null + ? scheduleQuery.isPending ? "loading" : scheduleLoadFailed ? "error" : "loading" + : "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]); + const [refreshBusy, setRefreshBusy] = useState(false); const snoozeCount = schedule?.schedule.snoozeCount ?? null; const snoozeButtonDisabled = schedule === null || snoozeBusy || !schedule.snoozeScopeAvailable || snoozeCount === null || snoozeCount <= 0; @@ -86,7 +66,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 }); @@ -94,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} +

    @@ -104,12 +111,11 @@ 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}

{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 f86be683..2816b7b0 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 { @@ -41,6 +41,16 @@ export const loadAdsSchedule = 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/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..ac9807eb 100644 --- a/src/modules/api_source/adapters/fetch-json.ts +++ b/src/modules/api_source/adapters/fetch-json.ts @@ -110,6 +110,18 @@ 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; }; + +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 a36bc563..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; @@ -689,8 +696,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 +713,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 ( @@ -734,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 ( @@ -778,8 +787,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 +797,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/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/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..e7ecc335 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; @@ -16,97 +18,78 @@ 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 [status, setStatus] = useState(null); + const realtimeStatus = useDashboardRealtimeStatus(channelId); + const queryClient = useDashboardQueryClient(); + 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 + ? 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 refresh = useCallback(async (): Promise => { - setStatus(await loadBelaboxStatus(channelId)); - }, [channelId]); - - 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]); - - useEffect(() => { - if (statusRefreshInterval === null) return; - const timer = window.setInterval(() => { - void refresh().catch(() => undefined); - }, statusRefreshInterval); - return () => window.clearInterval(timer); - }, [refresh, statusRefreshInterval]); + 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 (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 +186,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 +281,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/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 924aac30..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. @@ -634,7 +637,9 @@ 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) { + return; + } } } }; @@ -704,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/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 02951f25..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, 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"; @@ -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"; @@ -53,41 +55,38 @@ 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", 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, + }); + const state = stateQuery.data ?? null; 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); + 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]); + }, [channelId, labels.loadError, queryClient]); const draft = draftState?.channelId === channelId ? draftState @@ -158,7 +157,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/chat_voting/routes.ts b/src/modules/chat_voting/routes.ts index 957ab69e..f5bc53b0 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 = { @@ -57,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 9e19fb45..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; @@ -917,6 +921,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. */ @@ -1041,8 +1047,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. */ @@ -1167,6 +1171,7 @@ export interface ModuleRouteVariables { channelId: string, ids: readonly string[], now: number, + options?: { readOnly?: boolean }, ) => Promise>>; publishModuleOverlayMessage: ( channelId: string, @@ -1360,6 +1365,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/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/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 37c88703..29be2e03 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,56 +17,80 @@ 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 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); - 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); - }, []); - useEffect(() => { - const timer = window.setInterval(() => { void reload(true); }, 2000); - return () => window.clearInterval(timer); - }, [reload]); - + }, [channelId, labels.loadError, queryClient]); const cancel = async (): Promise => { if (cancelTarget === null) return; setBusy(true); @@ -72,7 +98,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 +113,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 +122,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 +136,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 +160,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..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 }); }); @@ -111,5 +107,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/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/realtime-contract.ts b/src/realtime-contract.ts index 4677f9e4..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,10 +11,10 @@ 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]; @@ -34,6 +33,9 @@ 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; @@ -51,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[]; }; @@ -64,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; @@ -119,17 +124,18 @@ export const isModuleOverlayRealtimeMessageType = (type: string): type is Module export const isModuleOverlayRealtimeEnvelope = ( message: RealtimeMessage, -): message is AnyModuleOverlayRealtimeEnvelope => isModuleOverlayRealtimeMessageType(message.type); +): message is AnyModuleOverlayRealtimeEnvelope => + 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; 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/app-routes.ts b/src/worker/app-routes.ts new file mode 100644 index 00000000..3a3b09d6 --- /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); + } + } +}); +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/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..64a0ff46 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, schedulePanelResourceBackgroundWork } 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); @@ -550,13 +565,13 @@ 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); - } 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); } @@ -618,6 +633,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(); @@ -637,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) { @@ -669,7 +685,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 fe550b1b..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 = "0046_chat_voting_kind.sql"; -export const LATEST_SCHEMA_TABLE = "chat_votes"; +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/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 d7940184..d2e25af0 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"; @@ -46,6 +57,13 @@ 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 { + notifyCommittedResources, + panelResourceChangedMessage, + readPanelResourceRevisions, + type PanelResourceRevisionVector, + withCommittedPanelResourceDrain, +} from "../panel-resources"; import { createModuleSecretAccess } from "../module-secrets"; import { sendChatMessage } from "../chat"; import { sendModerationBan } from "../moderation"; @@ -80,9 +98,11 @@ import { expireStoredBallot, finalizeStoredBallot, hasOpenStoredBallot, + hasOpenStoredBallotSnapshot, hardDeleteFinalizedStoredBallot, openStoredBallot, readStoredBallot, + readStoredBallotSnapshot, setStoredBallotBlockedTerms, } from "./ballots"; @@ -101,12 +121,12 @@ 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"; 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"; @@ -122,6 +142,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 @@ -130,6 +151,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; @@ -146,11 +177,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 }, @@ -635,6 +661,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 || @@ -645,11 +672,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); @@ -667,14 +697,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; }); } @@ -960,7 +982,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 { @@ -1286,8 +1312,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); @@ -1552,7 +1592,10 @@ export class ChannelObject extends DurableObject { undefined, invocation, ); - return await this.runModuleAlarmHandler(moduleId, handlerKey, execute); + return await withCommittedPanelResourceDrain( + () => this.runModuleAlarmHandler(moduleId, handlerKey, execute), + () => this.notifyCommittedPanelResources(), + ); } private ballotAccess(moduleId: string): ModuleBallotAccess { @@ -1560,8 +1603,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), @@ -1582,7 +1625,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, @@ -1603,6 +1646,9 @@ export class ChannelObject extends DurableObject { }, termFilter, ); + if (result.status === "opened" || result.activeBallot !== undefined) { + await this.notifyCommittedPanelResources(); + } if (result.status === "opened") { return { status: "opened" }; } @@ -1618,7 +1664,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( @@ -1630,7 +1680,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( @@ -1638,7 +1692,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( @@ -1656,11 +1712,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), @@ -1668,10 +1728,18 @@ export class ChannelObject extends DurableObject { hardDeleteAt, ); }); + if (snapshot?.outcome !== undefined) { + await this.notifyCommittedPanelResources(); + } + return snapshot; + } + + public async readBallotSnapshot(moduleId: string, ballotId: string): Promise { + return await readStoredBallotSnapshot(this.ballotStorage(moduleId), moduleId, ballotId); } 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), @@ -1679,16 +1747,23 @@ export class ChannelObject extends DurableObject { hardDeleteAt, ); }); + if (!hasOpen) await this.notifyCommittedPanelResources(); + 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.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), @@ -1696,6 +1771,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 { @@ -1706,7 +1785,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) => { @@ -1715,6 +1794,7 @@ export class ChannelObject extends DurableObject { ); }, ); + if (nextExpiry === null) await this.notifyCommittedPanelResources(); if (nextExpiry !== null) { await this.scheduleAlarmEntry(key, BALLOT_EXPIRY_ALARM_HANDLER, nextExpiry); } @@ -1723,10 +1803,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, @@ -1780,25 +1957,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; } @@ -1916,6 +2100,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) => { @@ -2095,6 +2302,24 @@ 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" } }); + } + } + // 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(); @@ -2104,6 +2329,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, @@ -2113,7 +2346,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(); @@ -2123,6 +2356,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() : []; @@ -2152,10 +2386,58 @@ 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) ? [...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) { @@ -2168,6 +2450,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); @@ -2206,6 +2493,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(); @@ -2449,12 +2748,14 @@ export class ChannelObject extends DurableObject { } override async alarm(): Promise { - 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)); + 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)); + }, () => this.notifyCommittedPanelResources()); } override webSocketMessage(webSocket: WebSocket, message: string | ArrayBuffer): void { 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/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 b0b54478..0030b4b4 100644 --- a/src/worker/eventsub.ts +++ b/src/worker/eventsub.ts @@ -6,6 +6,9 @@ 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 { revokeRealtimeUserFromAllChannels } from "./realtime"; +import { clearPendingRealtimeUserRevocation, getPendingRealtimeUserRevocation } from "./db/login-identity"; import type { EventSubSubscriptionType } from "../contracts/values"; import { hasEventSubMessage, @@ -204,6 +207,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 +278,82 @@ 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)) { + 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, + messageId, + now, + revocation, + authorizationConfirmedRevoked, + ); + 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); + } } 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..8d34738e 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,132 +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"; export { ChannelObject } from "./durable/ChannelObject"; - -const app = new Hono<{ Bindings: Env }>(); - -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.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..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, @@ -16,6 +19,8 @@ import { validateBotToken, logMaintenanceError, } from "./bot-maintenance"; +import { revokeRealtimeUserFromAllChannels } from "./realtime"; +import { notifyCommittedResources } from "./panel-resources"; const markLoginRevoked = async ( env: Env, @@ -23,7 +28,7 @@ const markLoginRevoked = async ( now: string, reason: string, ): Promise => { - await revokeLoginIdentityAndSessionsForUser( + const revoked = await revokeLoginIdentityAndSessionsForUser( env.DB, identity.userId, identity.accessTokenCiphertext, @@ -31,6 +36,28 @@ const markLoginRevoked = async ( reason, 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 ( @@ -237,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/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/src/worker/module-overlay-realtime.ts b/src/worker/module-overlay-realtime.ts index 12d2b3fe..bd8b6b82 100644 --- a/src/worker/module-overlay-realtime.ts +++ b/src/worker/module-overlay-realtime.ts @@ -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) { + return { outcome: "no_recipients" }; + } return { outcome: "ready", diff --git a/src/worker/panel-resources.ts b/src/worker/panel-resources.ts new file mode 100644 index 00000000..584c58c2 --- /dev/null +++ b/src/worker/panel-resources.ts @@ -0,0 +1,306 @@ +import type { RealtimeEnvelope } from "../realtime-contract"; + +export type PanelResourceRevisionVector = Readonly>; + +export interface PanelResourceRevisionRow { + resource: string; + revision: number; +} + +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/*", + moduleRoutes: "channelRoutes", + platformRoutes: "/api/platform/*", + 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 with committed-resource drain", + scheduledMaintenance: "scheduled background drain helper", + backgroundCommitScheduler: "schedulePanelResourceBackgroundWork", +} as const; + +const reconcileChannelResources = async ( + env: Pick, + channelId: string, + claimToken: string, + reconcileLocal?: (revisions: PanelResourceRevisionVector) => Promise, +): Promise => { + 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, claimToken); + return revisions; +}; + +const clearDeliveredPanelResources = async ( + database: D1Database, + channelId: string, + revisions: PanelResourceRevisionVector, + claimToken: string, +): Promise => { + const entries = Object.entries(revisions); + 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), + ]); +}; + +/** 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 + 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 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; + try { + await reconcileChannelResources( + env, + channelId, + 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. + 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. */ +export const readPanelResourceRevisions = async ( + database: D1Database, + channelId: string, +): Promise => { + const result = await database.prepare( + `SELECT resource, revision + FROM panel_resource_revisions + WHERE channel_id = ?`, + ).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. + * 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, + channelId?: string, + reconcileLocal?: (revisions: PanelResourceRevisionVector) => Promise, +): Promise => { + let localChannelWasReconciled = 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) { + 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. 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 + // 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)); + } + } +}; + +/** 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[], + 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/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/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 fde12476..2b1dff30 100644 --- a/src/worker/panel/routes.ts +++ b/src/worker/panel/routes.ts @@ -56,11 +56,10 @@ 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"; interface PanelEnvironment { Bindings: Env; @@ -227,6 +226,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); @@ -357,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/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 c9187435..6f496411 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); @@ -145,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[], @@ -279,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 ( @@ -323,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/scheduled.ts b/src/worker/scheduled.ts index 300dad7b..6cbc1ae5 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 { schedulePanelResourceBackgroundWork } 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); - executionContext.waitUntil(work); - await work; + const work = Promise.allSettled(tasks) + .then((results) => { + const rejected = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (rejected !== undefined) throw rejected.reason; + }); + await schedulePanelResourceBackgroundWork( + env, + work, + () => executionContext, + ); }; 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/ads-panel.test.tsx b/tests/components/ads-panel.test.tsx index 7535583b..60c45d6d 100644 --- a/tests/components/ads-panel.test.tsx +++ b/tests/components/ads-panel.test.tsx @@ -5,6 +5,8 @@ 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 { reconcileDashboardPanelResourceRevisions, setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; import { renderWithQuery } from "../query-test-utils"; import { jsonResponse } from "../unit/fixtures"; @@ -21,11 +23,18 @@ 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 => { +const renderAds = ( + fetcher: typeof fetch, + ownRole: "manager" | "operator" = "manager", + channelId = "kanal-a", +): ReturnType => { vi.stubGlobal("fetch", fetcher); return renderWithQuery( { afterEach(() => { cleanup(); + setDashboardRealtimeStatus("kanal-a", "offline"); vi.unstubAllGlobals(); if (initialLanguage !== undefined) Object.defineProperty(window.navigator, "language", initialLanguage); }); @@ -92,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(); @@ -128,6 +140,40 @@ 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(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); @@ -152,64 +198,75 @@ describe("Ad settings editor declaration", () => { 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 }, 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; + 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, "manager", channelId); + await act(async () => { await reconcileDashboardPanelResourceRevisions(rendered.queryClient, channelId); }); expect(await screen.findByText(`Stand ${formatter.format(new Date(original.asOf))}`)).toBeInTheDocument(); - window.dispatchEvent(new CustomEvent("brobot:realtime", { detail: { - 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", - }, - } })); + serverSchedule = { + ...original, + schedule: { ...original.schedule, nextAdAt: "2026-09-24T18:00:00.000Z", snoozeCount: 1 }, + asOf: "2026-09-24T13:00:00.000Z", + }; + 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(channelId, "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; + 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" }); - const initial = { ...schedule, asOf: "2026-09-24T12:00:00.000Z" }; + 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.endsWith("/schedule")) return new Promise((resolve) => { resolveInitial = resolve; }); + 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)); + } 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, "manager", channelId); + await waitFor(() => { expect(scheduleCalls).toBe(1); }); + await act(async () => { await reconcileDashboardPanelResourceRevisions(rendered.queryClient, channelId); }); const newerAsOf = "2026-09-24T13:00:00.000Z"; - window.dispatchEvent(new CustomEvent("brobot:realtime", { detail: { - 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)); - }); + serverSchedule = { + ...schedule, + schedule: { ...schedule.schedule, nextAdAt: "2026-09-24T18:00:00.000Z", snoozeCount: 0 }, + asOf: newerAsOf, + }; + 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(); + 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..61f5b696 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 { reconcileDashboardRealtimeMessage } 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,74 +48,35 @@ 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(); }); 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(); - - await waitFor(() => { expect(fetcher).toHaveBeenCalledTimes(2); }); + await reconcileDashboardRealtimeMessage(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(statusReads).toBe(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..0af245e4 100644 --- a/tests/components/belabox-panel.test.tsx +++ b/tests/components/belabox-panel.test.tsx @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, 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 { reconcileDashboardPanelResourceRevisions, setDashboardRealtimeStatus } from "../../src/dashboard/data/realtime"; const mocks = vi.hoisted(() => ({ loadBelaboxStatus: vi.fn(), @@ -40,6 +43,8 @@ describe("BELABOX panel", () => { afterEach(() => { cleanup(); + vi.unstubAllGlobals(); + setDashboardRealtimeStatus("kanal-a", "offline"); for (const toast of toastsSnapshot()) dismissToast(toast.id); vi.resetAllMocks(); }); @@ -48,7 +53,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 +71,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?" }); @@ -130,7 +139,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 the Belabox resource revision advances", async () => { mocks.loadBelaboxStatus .mockResolvedValueOnce({ configured: true, updatedAt: null, mode: "on_demand", sample: null, errorCode: null, @@ -140,21 +149,51 @@ 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(<>); + 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(); - view.rerender(<>); + 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); - 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. 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, @@ -195,7 +234,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..cfaf2813 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 { reconcileDashboardRealtimeMessage } 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"); }); @@ -125,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) => { @@ -290,66 +326,34 @@ 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 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 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); + + await reconcileDashboardRealtimeMessage(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..fb6ef568 100644 --- a/tests/components/chat-voting-panel.test.tsx +++ b/tests/components/chat-voting-panel.test.tsx @@ -1,11 +1,15 @@ -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"; 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"; const defaultLabels: Record = { yes_no: ["Yes", "No"], @@ -44,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; @@ -84,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(); }); @@ -121,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; @@ -143,6 +180,35 @@ describe("chat voting live panel", () => { expect(screen.getByRole("textbox", { name: "Label for option 2" })).toHaveValue("Burger"); }); + it("uses the saved default duration for the next vote", async () => { + setDashboardRealtimeStatus("fictional-channel", "connected"); + let serverState = currentState(); + let startPayload: unknown; + vi.stubGlobal("fetch", fetchFor( + () => serverState, + (body) => { startPayload = body; return { vote: openVote }; }, + )); + const view = render(); + + expect(await screen.findByRole("button", { name: "Start vote" })).toBeEnabled(); + serverState = currentState({ + defaultDurationSeconds: 300, + }); + + 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 })), + }); + }); + + 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({ kind: "yes_no", labels: ["Yes", "No"], durationSeconds: 300 })); + }); + 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 ad989b24..a4067e6d 100644 --- a/tests/components/dashboard.test.tsx +++ b/tests/components/dashboard.test.tsx @@ -4,8 +4,11 @@ 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 { renderWithQuery as renderWithQueryBase, type DashboardQueryTestOptions } from "../query-test-utils"; import { jsonResponse } from "../unit/fixtures"; const moderator = { @@ -70,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 }); } @@ -178,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 })); } @@ -194,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 }); @@ -202,6 +240,7 @@ describe("Dashboard skeleton", () => { afterEach(() => { cleanup(); + activePanelRevisions = {}; TestWebSocket.reset(); vi.unstubAllGlobals(); vi.useRealTimers(); @@ -606,11 +645,11 @@ 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"); - 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")); @@ -618,6 +657,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 +667,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 () => { @@ -644,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"); @@ -655,14 +695,15 @@ 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 }] } })); - 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(); - expect(eventRequests).toBe(2); + expect(eventRequests).toBe(3); expect(screen.getAllByRole("row")).toHaveLength(2); Object.defineProperty(feed, "getBoundingClientRect", { configurable: true, value: () => ({ top: 0 }) }); @@ -783,16 +824,17 @@ 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"); - render(); + render(, undefined, { panelRevisions: { "channel.events": 1 } }); expect(await screen.findByText("alt")).toBeInTheDocument(); 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 +842,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 +2355,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,29 +2414,34 @@ 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: 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 === 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)); }); vi.stubGlobal("fetch", fetcher); + 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" }); - - 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(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(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))); @@ -2431,13 +2515,13 @@ 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("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"); + 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") { @@ -2448,28 +2532,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]); - fireEvent(document, new Event("visibilitychange", { bubbles: true })); + navigateToPath("/channels/kanal-a/system"); await flushQueryUpdates(); - expect([channelCalls, overviewCalls, modulesCalls]).toEqual([2, 2, 2]); + expect([channelCalls, overviewCalls, systemCalls]).toEqual([2, 2, 1]); - 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([3, 3, 3]); + expect([channelCalls, overviewCalls, systemCalls]).toEqual([3, 2, 2]); + + expect(TestWebSocket.instances).toHaveLength(1); + TestWebSocket.instances[0]?.open(); + await flushQueryUpdates(); + const connectedCounts = [channelCalls, overviewCalls, systemCalls]; + + await act(async () => { await vi.advanceTimersByTimeAsync(3 * 60 * 1000 + 1); }); + await flushQueryUpdates(); + expect([channelCalls, overviewCalls, systemCalls]).toEqual(connectedCounts); }); it("shows stale token expiry as neutral, reloads, then marks server-confirmed expiry red", async () => { @@ -2509,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); @@ -2964,6 +3059,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; @@ -3015,6 +3155,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" }; @@ -3083,6 +3250,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, { @@ -3111,29 +3279,46 @@ 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(); 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(); 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 +3344,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 +3357,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,26 +3367,38 @@ 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)); })); 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(); - fireEvent(document, new Event("visibilitychange")); - await waitFor(() => expect(channelsCalls).toBe(2)); + await waitFor(() => expect(channelsCalls).toBe(1)); 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,30 +3416,37 @@ describe("Dashboard skeleton", () => { }, }, })); - expect(await screen.findByText(/^Live ·/)).toBeInTheDocument(); - + await waitFor(() => expect(channelsCalls).toBe(2)); await act(async () => { resolveDelayedChannels?.(jsonResponse({ channels: [channel], bot: channel.bot })); await delayedChannels; }); + await waitFor(() => expect(channelsCalls).toBe(2)); + expect(await screen.findByText(/^Live ·/)).toBeInTheDocument(); expect(document.querySelector(".dashboard-header__stream")).toHaveAttribute("data-state", "live"); expect(screen.getByText(/^Pausiert ·/)).toBeInTheDocument(); 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 +3455,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 +3490,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,12 +3511,62 @@ 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); }); }); + 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(, undefined, { panelRevisions: { "channel.overview": 1 } }); + + 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"); @@ -4233,6 +4483,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/events-page.test.tsx b/tests/components/events-page.test.tsx index d6885208..58587d8c 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,39 @@ 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"; + +let activePanelRevisions: Record = {}; + +const RealtimeShell = ({ children }: { children: ReactNode }) => { + useRealtimePanelMessages("kanal-a", true); + return <>{children}; +}; + +const renderWithQuery = ( + ui: ReactElement, + options?: Omit, + 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(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", @@ -75,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 })); } @@ -127,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 }); @@ -174,9 +212,50 @@ 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); }); + 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, + panelRevisions: { "channel.events": 1 }, + 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\"}" }); @@ -241,7 +320,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 +351,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 +361,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 +528,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 +543,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 <= 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); }); @@ -481,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] }, @@ -493,6 +574,9 @@ 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 => { socket.receive(JSON.stringify({ version: 1, @@ -507,10 +591,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,12 +723,14 @@ 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(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([ @@ -915,7 +998,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 +1050,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(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(); + expect(screen.getByRole("button", { name: /1 neue Ereignisse/u })).toBeVisible(); expect(maxInFlight).toBeLessThanOrEqual(1); rendered.unmount(); } finally { @@ -1023,15 +1104,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", @@ -1040,10 +1123,12 @@ 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)); - expect(fetcher).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 500)); + 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 +1150,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" }, @@ -1079,12 +1164,15 @@ 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]; 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({ version: 1, id: "live-event-message", @@ -1093,7 +1181,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 +1207,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 +1228,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/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..c0d67d02 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,187 @@ 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("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 }; @@ -843,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/overlays-page.test.tsx b/tests/components/overlays-page.test.tsx index 8858de20..f5ebbc9a 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}; @@ -106,6 +107,7 @@ describe("Overlays page", () => { const routeFetcher = (options: { conflictDelete?: boolean; emptyOverlays?: boolean; + overlayDetailFailsOnce?: boolean; secondOverlay?: boolean; accessLastUsedAt?: string | null; accessExpiresAt?: string | null; @@ -125,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"; @@ -159,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") { @@ -255,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/stream-manager.test.tsx b/tests/components/stream-manager.test.tsx index ed4ae32a..436d1f12 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 { 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"; 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", @@ -478,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", @@ -496,20 +473,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({ + 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); }); 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..d4d74f97 100644 --- a/tests/components/votekick-panel.test.tsx +++ b/tests/components/votekick-panel.test.tsx @@ -1,11 +1,14 @@ -import { act, cleanup, render, 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"; 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"; const running: Votekick = { id: "ballot-a", @@ -26,13 +29,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 +44,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); + }); - expect(screen.getByText("sampleviewer")).toBeInTheDocument(); + 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); + + act(() => { setDashboardRealtimeStatus("channel-a", "offline"); }); + await act(async () => { await vi.advanceTimersByTimeAsync(2_100); }); expect(fetcher).toHaveBeenCalledTimes(2); }); @@ -63,6 +78,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,16 +100,43 @@ 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); }); + + 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/e2e/host-stable-layout.spec.ts b/tests/e2e/host-stable-layout.spec.ts index 08f01394..4335e61e 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; @@ -588,13 +594,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 +613,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 +626,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 +653,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(() => variableRequestCount).toBe(3); + 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).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(); }); @@ -700,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 ?? []; @@ -934,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", @@ -944,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/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/realtime-navigation.spec.ts b/tests/e2e/realtime-navigation.spec.ts new file mode 100644 index 00000000..1a6552a9 --- /dev/null +++ b/tests/e2e/realtime-navigation.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from "@playwright/test"; + +const channelId = "realtime-navigation-e2e"; +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-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-09-20T08:00:00.000Z", + loginStatus: "connected", + loginReason: null, + loginExpiresAt: "2099-09-20T08:00:00.000Z", + }, + lastError: null, +}; + +test.use({ locale: "en-US" }); + +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); + }); + 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], 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 }) }); + } else if (pathname === `/api/channels/${channelId}/events`) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ entries: [], nextCursor: null }) }); + } 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 page.goto(`/channels/${channelId}`); + await expect(page.getByRole("heading", { name: channel.displayName, level: 1 })).toBeVisible(); + await expect.poll(() => opened).toBe(1); + + 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.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/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/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/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/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/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/belabox-polling.test.ts b/tests/unit/belabox-polling.test.ts index 7a1bbd42..aca121a1 100644 --- a/tests/unit/belabox-polling.test.ts +++ b/tests/unit/belabox-polling.test.ts @@ -1279,6 +1279,11 @@ describe("BELABOX polling", () => { } expect(sendChat).not.toHaveBeenCalled(); + 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 () => { @@ -2401,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 = ?") @@ -2427,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"; @@ -2504,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({ @@ -2641,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/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/chat-voting-close.test.ts b/tests/unit/chat-voting-close.test.ts index 9bc70503..33bafd56 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,8 @@ 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 publishModuleOverlayMessage = vi.fn( () => { order.push("overlay"); return Promise.resolve(); }, ); @@ -221,6 +222,41 @@ 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 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(); + 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 } = 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"]); + }); + 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/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/dashboard-query-data.test.tsx b/tests/unit/dashboard-query-data.test.tsx index 24d9fc3f..8e227401 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 { 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"; import { emptyEventFilter } from "../../src/dashboard/events/model"; @@ -18,6 +20,8 @@ const channelId = "channel-a"; afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); + setDashboardRealtimeStatus(channelId, "offline"); }); describe("dashboard query data layer", () => { @@ -49,10 +53,169 @@ 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(); + 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; + const firstHint = reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + await vi.advanceTimersByTimeAsync(1_000); + await firstHint; + await refreshStarted; + revision = 2; + const secondHint = reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + revision = 3; + const thirdHint = reconcileDashboardPanelResourceRevisions(view.queryClient, channelId); + await vi.advanceTimersByTimeAsync(1_000); + await Promise.all([secondHint, thirdHint]); + expect(readCount).toBe(2); + + 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(); + }); + + 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(); + 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) { + revision += 1; + reconciliations.push(reconcileDashboardPanelResourceRevisions(view.queryClient, channelId)); + await vi.advanceTimersByTimeAsync(50); + } + await vi.advanceTimersByTimeAsync(1_000); + await Promise.all(reconciliations); + await act(async () => { await vi.advanceTimersByTimeAsync(1_000); }); + + // 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(); + }); + 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..5c81334f --- /dev/null +++ b/tests/unit/dashboard-realtime.test.ts @@ -0,0 +1,578 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +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, + reconcileDashboardPanelResourceRevisions, + 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"); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("dashboard realtime messages", () => { + it("ignores legacy module panel hints", () => { + const result = parseRealtimeMessage(JSON.stringify({ + version: 1, + id: "module-sample-1", + createdAt: "2026-10-09T08:00:00.000Z", + channelId: "channel-a", + type: "modul.belabox.changed", + payload: { part: "live" }, + }), "channel-a"); + + expect(result.kind).toBe("ignored"); + + 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("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); + }); + 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); + + 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; + reconciliations.push(reconcileDashboardRealtimeMessage(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); + await Promise.all(reconciliations); + + expect(reads.panel).toBeLessThanOrEqual(2); + expect(reads.events).toBeLessThanOrEqual(2); + expect(reads.settings).toBe(0); + + subscriptions.forEach((unsubscribe) => { unsubscribe(); }); + queryClient.clear(); + }); + + 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); + 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); + expect(detailReads).toBe(1); + unsubscribeList(); + unsubscribeDetail(); + queryClient.clear(); + }); + + 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); }); + + 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); + 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(false); + }); + + it("tracks connection state per channel for conditional fallback queries", () => { + setDashboardRealtimeStatus(channelId, "connected"); + 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 () => { + vi.useFakeTimers(); + 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 vi.advanceTimersByTimeAsync(1_000); + await Promise.all([first, second]); + expect(revisionReads).toBe(2); + await vi.waitFor(() => { expect(panelReads).toBe(1); }); + unsubscribe(); + 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"; + 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, "text_library", "library"), + ] 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, + }; + 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"], + ["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"), + ], + "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/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 7cc5feb9..2ba54e8e 100644 --- a/tests/unit/login-maintenance.test.ts +++ b/tests/unit/login-maintenance.test.ts @@ -23,6 +23,9 @@ const makeEnvironment = async ( failTokenWriteAfterCommitOnce?: boolean; beforeTokenWrite?: () => Promise; onSuccessfulTokenWrite?: () => void; + memberChannelIds?: readonly string[]; + revokeUser?: (userId: string) => Promise; + pendingRealtimeUserRevocations?: readonly string[]; } = {}, ) => { const keys = parseKeyRing(keyRingSerialized); @@ -47,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) => { @@ -58,8 +65,42 @@ const makeEnvironment = async ( return statement; }), all: vi.fn().mockImplementation(() => - sql.includes("FROM twitch_login_identity") ? { results: [row] } : { results: [] }), + 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: [] }), + 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 } }; @@ -168,6 +209,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, @@ -177,6 +222,7 @@ const makeEnvironment = async ( getTokenWriteAttempts: () => tokenWriteAttempts, read: () => row, readSessions: () => sessions, + pendingRealtimeRevocations, getInitialAccessTokenCiphertext: () => initialAccessTokenCiphertext, getInitialRefreshTokenCiphertext: () => initialRefreshTokenCiphertext, }; @@ -404,6 +450,57 @@ describe("Login token maintenance", () => { }]); }); + it("surfaces a failed socket close after invalid_grant commits the identity revocation", async () => { + 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, + }); + 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"); + 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 () => { 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/module-overlay-realtime.test.ts b/tests/unit/module-overlay-realtime.test.ts index 3cd363c0..350dacab 100644 --- a/tests/unit/module-overlay-realtime.test.ts +++ b/tests/unit/module-overlay-realtime.test.ts @@ -179,4 +179,25 @@ describe("module overlay realtime routing", () => { expect(result).toEqual({ outcome: "no_recipients" }); }); + + it("keeps module overlay payloads out of panel sockets", 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).toEqual({ outcome: "no_recipients" }); + }); }); 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..9b573d4f --- /dev/null +++ b/tests/unit/panel-resource-revisions.test.ts @@ -0,0 +1,1630 @@ +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"; + +import { MODULES } from "../../src/modules/registry"; +import { dashboardDataKeys, queryKeys } from "../../src/dashboard/data/keys"; +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"; +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 { createSessionCookie } from "../../src/worker/auth/session"; +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"; + +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 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), + 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); + } + 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( + `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(); +}; + +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[])[]; + 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: "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()], + 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"), + ], + 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.system(id), moduleQueryKey(id, "text_library", "library"), + dashboardDataKeys.variables(id), moduleQueryKey(id, "chat_voting", "settings"), + moduleQueryKey(id, "chat_voting", "panel"), 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, 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(); + }, + }, + { + 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, 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')`, + ).run(); + }, + }, + { + name: "shared API cache", + keys: (id) => [moduleQueryKey(id, "text_library", "library")], + write: async (database, id) => { + await database.prepare( + `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("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, 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.eventSubRoute, + ]; + 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); + 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" && + !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 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, + 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)); + 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)"); + 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 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).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).toContain("withCommittedPanelResourceDrain("); + 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("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/*"'); + 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); + 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, 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*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); + 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("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("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("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; + 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(); + 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" } }, + 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("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 } } }); + 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"; + 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("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("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) + 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"]).toBe(memberBefore["channel.members"]); + 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 { + 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_revisions", + "panel_global_resource_publications", + "panel_resource_dependencies", + "panel_resource_pending", + "panel_resource_revisions", + "pending_realtime_user_revocations", + "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"); + expect(resourcesFor("chat_votes")).toContain("module:chat_voting:panel"); + expect(resourcesFor("chat_vote_term_approvals")).toContain("module:chat_voting:panel"); + } finally { + database.close(); + } + }); +}); diff --git a/tests/unit/panel-routes.test.ts b/tests/unit/panel-routes.test.ts index 29a67bba..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: [] }), @@ -129,6 +126,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(); @@ -324,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"); @@ -335,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(); @@ -348,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 () => { @@ -388,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"); @@ -402,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( @@ -411,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"); @@ -457,11 +448,86 @@ 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("serves stored overview controls while Helix binds a pending pause in the background", 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"); + 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 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(0); + expect((await database.prepare( + "SELECT state FROM channel_stream_state WHERE channel_id = 'kanal-a'", + ).first<{ state: string }>())?.state).toBe("online"); + expect(runtime.channelObject.reconcilePanelResources).not.toHaveBeenCalled(); + }); + + 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"); @@ -483,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( @@ -502,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"); @@ -533,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/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/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", () => { diff --git a/tests/unit/sql-contract.test.ts b/tests/unit/sql-contract.test.ts index 915dc20d..5899331b 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(56); + expect(objects.filter((object) => object.type === "index")).toHaveLength(46); + expect(objects.filter((object) => object.type === "trigger")).toHaveLength(124); + 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/unit/votekick-routes.test.ts b/tests/unit/votekick-routes.test.ts index 1cfabcaf..91dafb90 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(); @@ -80,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" }); @@ -102,7 +138,68 @@ describe("Votekick routes", () => { expect(row).toEqual({ status: "cancelled" }); }); - it("reconciles a running votekick through its alarm handler on panel access", async () => { + 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).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("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"); @@ -114,6 +211,6 @@ describe("Votekick routes", () => { ); expect(response.status).toBe(200); - expect(runModuleAlarm).toHaveBeenCalledWith("votekick", "close", "close:ballot-panel"); + expect(runModuleAlarm).not.toHaveBeenCalled(); }); }); 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 d38e4329..08e4fbd7 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, @@ -49,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"; @@ -279,10 +289,36 @@ 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 ? sockets @@ -347,7 +383,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(); @@ -643,7 +682,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 () => { @@ -1158,6 +1217,39 @@ describe("ChannelObject realtime path", () => { expect(panel.send.mock.calls).toHaveLength(0); }); + 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 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 = 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: { messageType: type }, + } as unknown as RealtimeMessage)); + + await object.publish(messages); + + expect(panel.send.mock.calls).toHaveLength(0); + expect(overlay.send.mock.calls.map(([serialized]) => typeOfSerializedMessage(serialized))).toEqual([ + ...overlayMessageTypes, + ]); + }); + 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, @@ -1197,7 +1289,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]); @@ -1213,8 +1305,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 }); @@ -1222,11 +1316,55 @@ 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); }); + 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 = { @@ -1798,10 +1936,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 () => { @@ -1816,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); @@ -1857,6 +2029,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(); }); @@ -1899,6 +2072,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 () => { @@ -2324,8 +2498,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()); @@ -3073,6 +3249,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"); @@ -3107,7 +3347,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"); @@ -3115,6 +3358,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 f49a703c..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 = "0046_chat_voting_kind.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 0046. + // 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);
{labels.name}{labels.elements}{labels.accesses}{labels.lastUsedAt}
{overlay.name} {overlay.elementCount} {overlay.accessCount}