Skip to content

Proposal: push updates for plugin storage writes (plugin.storage.changed event) #347

Open

Description

@adamecker

Problem

Plugins that share state through the plugin storage API have no way to learn that a key changed. Today the only options are:

  • Poll HomeGlow.storage on an interval (e.g. every 10s), which adds latency (a write from another display or a household integration takes up to a full interval to appear) and pointless traffic on every display.
  • The built-in refreshInterval setting, which for plugins remounts the whole iframe on a timer — discarding in-progress edits and flashing the widget. Core widgets refetch in place on refresh; plugins do not.

So a shared list widget, or any multi-display plugin state, is either slow or destructive to update.

Proposal (additive, opt-in)

  1. Server: when PUT /api/plugin/v1/storage/:pluginId/:key succeeds, emit a new catalog event, e.g. plugin.storage.changed, carrying pluginId and key only — no values. Frames already fetch values through the SDK, so no new data flows through the event.
  2. Dashboard: forward that event to plugin frames over the existing postMessage bridge, the same way catalog events are already forwarded.
  3. Manifest: plugins opt in by declaring the keys they care about (e.g. "storageEvents": ["lists"]), so uninterested frames receive nothing extra.
  4. SDK: expose something like HomeGlow.storage.onChange(key, callback) so widgets refresh in place instead of polling.

Compatibility

  • New event type only. Existing catalog events, the storage API, and polling behavior are unchanged.
  • Plugins that do not opt in behave exactly as today, and polling remains a fallback for older servers.
  • Single-household trust boundary is unchanged: the event carries the same pluginId/key the frame could already read.

Why it matters

Near-instant multi-display updates (a write appears in about a second instead of after a poll interval), less traffic, and no edit loss from iframe remounts.

Happy to implement this as a PR, with tests, if the shape looks right. The motivating plugin itself stays plugin-only and keeps polling as its fallback either way.

Generated with Muse AI 🤖

Activity

  1. mrramam commented on Oct 27, 2026

    @mrramam
    Collaborator

    The problem is real. Plugins can't hear about a storage write, and refreshInterval remounts the frame (PluginWidgetWrapper keys the iframe on refreshNonce), which drops anything in progress.

    One suggestion on the shape: ride the change counters from #339 instead of adding a stream-only event. The event stream has no replay, so a display that drops its connection misses the write, which is why the proposal keeps polling as a fallback. householdChanges.js already solves that for household data: a revision counter per domain, pushed over the stream within a second, with the 15-second GET /api/changes check (304 when nothing moved) catching up anything missed. A per-plugin storage counter on that path gives the same speed, no missed writes, and no polling in the plugin.

    Two things either way:

    • Isolation. Since fix(plugins): isolate plugins from HomeGlow and from each other #320 a plugin can't read another plugin's storage (pluginRequestPolicy.js), so the dashboard should forward each frame only its own plugin's changes. Filtering by key alone would tell every frame other plugins' key names.
    • Every write path. Storage also changes through DELETE, POST …/increment, and manifest reactions run by the server, not only PUT. A shared list edited from two displays will hit more than one of these.
  2. adamecker commented on Oct 28, 2026

    @adamecker
    ContributorAuthor

    Thanks — all three points are right, and the first one changes the shape for the better.

    On counters vs a stream-only event: agreed. The replay gap is the flaw in what I proposed — a display that drops its connection misses the write, which is exactly why I kept polling as a fallback. A per-plugin storage counter on the #339 path fixes that properly: pushed over the stream within a second, caught up by the 15-second GET /api/changes check, and then the plugin doesn't need polling at all except as the older-server fallback. One note: #339 explicitly excludes plugin storage from its counters today, so this is an extension of that path (a counter per plugin), not a reuse as-is.

    On isolation: agreed, and it's a correction to my proposal. Forwarding has to be scoped per frame to that frame's own pluginId. Broadcasting pluginId+key to every frame would hand each plugin the key names of every other plugin's storage, which is exactly what #320 closed off. The "same pluginId/key the frame could already read" line in my proposal only holds with per-frame scoping.

    On write paths: agreed. The counter has to move on PUT, DELETE, and POST .../increment, plus server-side writes including declarative reactions — not just PUT. A list edited from two displays will hit more than one of those.

    Happy to implement this revised shape as a PR, with tests, once the shape is settled.

    Generated with Muse AI 🤖

  3. mrramam commented on Oct 28, 2026

    @mrramam
    Collaborator

    Agreed on all of it, including the #339 correction. Go ahead with the PR. Two things to settle first:

    1. The event carries no key. A per-plugin counter only says that something in the plugin's storage changed, so the frame gets plugin.storage.changed with no key and reloads what it uses. That matches feat: keep every display in step with one revision counter per kind of data (#325) #339's coarse-on-purpose design. Please say so in the plugin guide, so authors don't expect a key.
    2. The counter list is no longer fixed. DOMAINS in householdChanges.js is a fixed list of five, and the ETag and GET /api/changes are built from it. Per-plugin counters make it grow and shrink as plugins are installed and removed. Displays can get every plugin's counter, since that's core code. Isolation applies only to what a display forwards into a frame.

    Tests for each write path, and for a frame not hearing another plugin's writes, would be great.

  4. adamecker commented on Oct 31, 2026

    @adamecker
    ContributorAuthor

    Agreed on both, and both are in the implementation now, on pr/plugin-storage-push in my fork. I'll open the PR once the branch has been tested.

    1. The event carries no key. The frame receives plugin.storage.changed with { pluginId } only — no key, no value — and reloads what it uses. The guide says so explicitly now: a new paragraph in §5 plus a catalog row, so authors don't expect a key. One precision note: the display-side stream carries the counter revision alongside the id, so the dashboard tracker can compare counters; that revision never reaches the frame, which gets the id alone. That matches feat: keep every display in step with one revision counter per kind of data (#325) #339's coarse-on-purpose design — the frame learns that its own storage changed, nothing about what.

    2. The counter list is no longer fixed. Counters live in the same revision map under plugin:<pluginId>, created on a plugin's first storage write and dropped when its data is purged on uninstall (kept without purge, since the data is kept). The ETag is built from the five fixed kinds plus the plugin kinds in sorted order, so it stays deterministic, and GET /api/changes returns the full map. household.changed payloads still carry household kinds only. As you say, displays get every plugin's counter — that's core code — and isolation lives in what a display forwards into a frame: the dashboard subscribes per plugin id, and a frame hears only its own plugin's events.

    On write paths: the counter moves on PUT, DELETE, and POST .../increment through the response hook (success only — reads, failed writes, and other plugin routes move nothing), on server-side reactions via an explicit bump, and uninstall with purge drops the counter. One guard worth naming: a reaction never fires on plugin.storage.changed itself, since the reaction writes plugin storage and would otherwise re-trigger itself in a loop.

    On tests: unit coverage for the routing, the household/plugin flush partitioning, ETag determinism, and the prune; real-server coverage for each write path moving only that plugin's counter, reads and failures moving nothing, a reaction moving the counter, the stream event carrying no key, purge dropping vs non-purge keeping the counter, and the ETag moving with a plugin write while 304 still holds; client tracker tests for a subscription hearing only its own plugin from both the check and the stream, the baseline rules, the prune on uninstall, and a reinstall reading as changed; and an end-to-end flow check with two plugins on a dashboard, where a write to one plugin's storage ran only that plugin's frame handler and moved only its counter.

    Generated with Muse AI 🤖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions