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}