summaryrefslogtreecommitdiffhomepage
path: root/src/features/plugins/logic/view-model.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-04 00:51:47 +0900
committerAdam Malczewski <[email protected]>2026-09-04 00:51:47 +0900
commit60a726883be85c174e88ae832ef95f3c0f084ec9 (patch)
treea2fa46d35041cab5a0c27f0080c58d9fe8443a12 /src/features/plugins/logic/view-model.ts
parent3a4922a05930b0dc22740bf5621910803e8f275e (diff)
downloaddispatch-web-60a726883be85c174e88ae832ef95f3c0f084ec9.tar.gz
dispatch-web-60a726883be85c174e88ae832ef95f3c0f084ec9.zip
fix(plugins): make groupByTier duplicate-key-proof; normalize tiers at the boundary
PluginTable.svelte renders a keyed each ({#each groups as group (group.tier)}); groupByTier's old "rest" bucket for unknown tiers fell back to key "standard", producing a DUPLICATE standard group (and thus an each_key_duplicate crash in Svelte 5, blanking both manager tables) whenever tierless rows were present alongside canonical standard rows. groupByTier now buckets through a Map keyed by the NORMALIZED tier — each canonical tier can yield at most one group, so duplicate keys are impossible by construction, and no row is ever dropped. Unknown/missing tiers fold into the standard bucket rather than forming a separate trailing group. The normalization (normalizeTier, in types.ts next to tierLabel/TIER_ORDER) also runs at the pluginNodes boundary, so every node's tier is canonical before any tier-keyed consumer sees it — including tierLabel's exhaustive switch, which rendered an empty badge for out-of-contract values. Normalizing into an existing bucket (option a) was chosen over a synthetic "unknown" group because it needs no new UI vocabulary and keeps every consumer total. Tests: all-canonical order, unknown tier ("kernel"), and missing tier each assert no duplicate keys, no dropped rows, and the right group/row counts from the Loaded-table view; the boundary test pins pluginNodes' canonical output. Plugins-feature vitest green (95 tests); svelte-check, biome, and vite build green.
Diffstat (limited to 'src/features/plugins/logic/view-model.ts')
-rw-r--r--src/features/plugins/logic/view-model.ts40
1 files changed, 30 insertions, 10 deletions
diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts
index 23d57d9..20d7d68 100644
--- a/src/features/plugins/logic/view-model.ts
+++ b/src/features/plugins/logic/view-model.ts
@@ -1,5 +1,5 @@
import type { PluginListResponse } from "@dispatch/transport-contract";
-import { type PluginNode, type PluginSection, TIER_ORDER } from "./types";
+import { normalizeTier, type PluginNode, type PluginSection, TIER_ORDER } from "./types";
/**
* Normalize a `GET /plugins` response into the flat `PluginNode` list the
@@ -21,7 +21,10 @@ export function pluginNodes(response: PluginListResponse): readonly PluginNode[]
id: row.id,
name: row.name,
version: row.version,
- tier: row.tier,
+ // Normalize at the boundary: a missing/out-of-contract tier (backend
+ // bug, stale contract) folds into "standard" so every node's tier is
+ // canonical and the tier-keyed consumers below stay total.
+ tier: normalizeTier(row.tier),
trust: row.trust,
loaded: true,
locked: row.locked,
@@ -35,7 +38,7 @@ export function pluginNodes(response: PluginListResponse): readonly PluginNode[]
id: row.id,
name: row.name,
version: row.version,
- tier: row.tier,
+ tier: normalizeTier(row.tier),
trust: row.trust,
loaded: false,
locked: false,
@@ -49,7 +52,7 @@ export function pluginNodes(response: PluginListResponse): readonly PluginNode[]
id: row.id,
name: row.name,
version: row.version,
- tier: row.tier,
+ tier: normalizeTier(row.tier),
trust: row.trust,
loaded: false,
locked: false,
@@ -79,19 +82,36 @@ export function pluginNodes(response: PluginListResponse): readonly PluginNode[]
* Cluster nodes for one manager table: tier groups in display order (Core →
* Providers → Standard), each tier's rows sorted by id. Tiers with no rows
* are omitted. Used by the loaded table's tier grouping (plan 07 §7.2).
+ *
+ * Duplicate-key-proof BY CONSTRUCTION: nodes are bucketed through a Map keyed
+ * by the NORMALIZED tier, so each of the three canonical tiers can yield at
+ * most one group — `PluginTable.svelte` renders a keyed each
+ * (`{#each groups as group (group.tier)}`), and a duplicate key throws
+ * `each_key_duplicate` in Svelte 5, aborting the whole manager render. The
+ * normalization also means an unknown/missing tier (contract violation — the
+ * backend rejects tierless externals at boot and directory scans fail
+ * validation) folds into the `standard` bucket instead of forming a separate
+ * trailing group; the old fallback (`rest[0]?.tier ?? "standard"`) produced a
+ * DUPLICATE `standard` key when canonical standard rows were present.
+ * groupByTier normalizes on the way in even though `pluginNodes` already
+ * does at the boundary — it is exported and must be safe for any caller.
*/
export function groupByTier(
nodes: readonly PluginNode[],
): ReadonlyArray<{ readonly tier: PluginNode["tier"]; readonly rows: readonly PluginNode[] }> {
+ const byTier = new Map<PluginNode["tier"], PluginNode[]>();
+ for (const n of nodes) {
+ const tier = normalizeTier(n.tier);
+ const rows = byTier.get(tier);
+ if (rows) rows.push(n);
+ else byTier.set(tier, [n]);
+ }
const out: { tier: PluginNode["tier"]; rows: PluginNode[] }[] = [];
for (const tier of TIER_ORDER) {
- const rows = nodes.filter((n) => n.tier === tier).sort((a, b) => a.id.localeCompare(b.id));
- if (rows.length > 0) out.push({ tier, rows });
+ const rows = byTier.get(tier);
+ if (!rows || rows.length === 0) continue;
+ out.push({ tier, rows: rows.sort((a, b) => a.id.localeCompare(b.id)) });
}
- // Any tier outside TIER_ORDER (defensive — the contract pins three values).
- const known = new Set<PluginNode["tier"]>(TIER_ORDER);
- const rest = nodes.filter((n) => !known.has(n.tier)).sort((a, b) => a.id.localeCompare(b.id));
- if (rest.length > 0) out.push({ tier: rest[0]?.tier ?? "standard", rows: rest });
return out;
}