feat(web): warm-seed cache for triggers/[id] (last detail page)
Build / build (push) Successful in 11m31s

Trigger detail re-fetched on every visit, flashing a skeleton. Warm-seed
the trigger + config form from triggerDetailCache so the hero + form
render instantly on revisit; gate the webhook + bindings panels on a
detailsLoaded flag (loading row until their data lands) so they never
flash a wrong "OFF"/"empty" state.

The rotated webhook secret lives in the separate (uncached) webhook
object and is always fetched fresh — never persisted.

Hardened against reused-component nav races (review): a monotonic
loadSeq token makes the latest load() the sole writer (fixes A→B→A
same-id concurrency), and each cache-writing mutation handler pins the
id for its round-trip so a mid-mutation nav can't poison another id's
cache entry or surface its secret.
This commit is contained in:
2026-06-08 16:06:37 +03:00
parent 192204a51c
commit ec8c0cd891
+115 -31
View File
@@ -2,6 +2,7 @@
import { onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import * as api from '$lib/api';
import type {
RedeployTrigger,
@@ -9,6 +10,7 @@
TriggerInput,
TriggerWebhook
} from '$lib/api';
import { triggerDetailCache } from '$lib/stores/caches';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@@ -37,10 +39,16 @@
return d.toLocaleString();
}
let trigger = $state<RedeployTrigger | null>(null);
// Warm-seed the trigger from the per-id cache so the hero + config form
// render instantly on revisit; `loading` clears as soon as the trigger is
// ready. The webhook/bindings panels gate on `detailsLoaded` instead, since
// the webhook carries a rotated secret and is never cached.
const _seed = triggerDetailCache.peek(get(page).params.id ?? '').value;
let trigger = $state<RedeployTrigger | null>(_seed);
let webhook = $state<TriggerWebhook | null>(null);
let bindings = $state<TriggerBinding[]>([]);
let loading = $state(true);
let loading = $state(_seed === null);
let detailsLoaded = $state(false);
let saving = $state(false);
let error = $state('');
@@ -60,35 +68,77 @@
// kind tag when showKindPicker=false.
let formState = $state(createTriggerKindFormState());
// Seed the editable form ONCE per id (plain, non-reactive guard so the load
// effect tracks only `id`). On a warm fresh mount, seed from the cached
// trigger right away; load() handles cold + reused-component (A→B) nav.
let seededKey: string | null = null;
function seedFormFrom(tr: RedeployTrigger): void {
seedTriggerKindFormState(
formState,
tr.kind,
tr.name,
tr.config,
tr.webhook_enabled,
tr.webhook_require_signature
);
}
if (_seed) {
seedFormFrom(_seed);
seededKey = get(page).params.id ?? '';
}
// Monotonic load token — each invocation claims the latest seq, so an
// earlier in-flight load (even for the same id on A→B→A nav, where the
// id-only guard can't tell two same-id loads apart) is stale and cannot
// write trigger / bindings / webhook / detailsLoaded.
let loadSeq = 0;
async function load(): Promise<void> {
loading = true;
const k = id;
const my = ++loadSeq;
// New id: its secondary panels (bindings/webhook) aren't loaded yet.
detailsLoaded = false;
const cached = triggerDetailCache.peek(k);
if (cached.value) {
trigger = cached.value;
if (seededKey !== k) {
seedFormFrom(cached.value);
seededKey = k;
}
loading = false; // warm: render the hero + config form immediately
} else {
loading = true;
}
error = '';
try {
const tr = await api.getTrigger(id);
if (my !== loadSeq) return; // a newer load owns the state now
trigger = tr;
seedTriggerKindFormState(
formState,
tr.kind,
tr.name,
tr.config,
tr.webhook_enabled,
tr.webhook_require_signature
);
// Fetch the webhook info only when ingress is enabled —
// otherwise the secret/url panel stays in the disabled
// state. Bindings always load.
const tasks: Array<Promise<unknown>> = [
api.listBindingsForTrigger(id).then((b) => (bindings = b))
];
if (tr.webhook_enabled) {
tasks.push(api.getTriggerWebhook(id).then((w) => (webhook = w)));
} else {
webhook = null;
triggerDetailCache.set(k, tr);
if (seededKey !== k) {
seedFormFrom(tr);
seededKey = k;
}
await Promise.all(tasks);
loading = false;
// Secondary panels. The webhook carries the rotated secret, so it is
// fetched fresh every time and NEVER cached. Reset both first so a
// reused-component warm nav (A→B) can't show A's data under B.
webhook = null;
bindings = [];
const [b, w] = await Promise.all([
api.listBindingsForTrigger(id),
tr.webhook_enabled ? api.getTriggerWebhook(id) : Promise.resolve(null)
]);
if (my !== loadSeq) return;
bindings = b;
webhook = w;
detailsLoaded = true;
} catch (e) {
if (my !== loadSeq) return;
// Clear so a 404 (e.g. a deleted trigger revisited from cache) shows
// the error state instead of a stale, interactive phantom.
trigger = null;
error = e instanceof Error ? e.message : 'Failed to load trigger';
} finally {
loading = false;
}
}
@@ -98,6 +148,9 @@
async function save(e?: Event): Promise<void> {
e?.preventDefault();
if (!trigger || saving || !canSave) return;
// Pin the id for the round-trip so a mid-save nav can't write another
// trigger's state or poison its cache entry (the component is reused).
const k = id;
saving = true;
error = '';
try {
@@ -110,17 +163,22 @@
...buildTriggerInput(formState),
kind: trigger.kind
};
const updated = await api.updateTrigger(id, body);
const updated = await api.updateTrigger(k, body);
if (id !== k) return;
trigger = updated;
triggerDetailCache.set(k, updated);
// Webhook info comes/goes with the toggle. Keep state in
// sync so the panel doesn't show stale secrets after
// turning ingress off-then-on.
if (updated.webhook_enabled) {
webhook = await api.getTriggerWebhook(id);
const w = await api.getTriggerWebhook(k);
if (id !== k) return;
webhook = w;
} else {
webhook = null;
}
} catch (e) {
if (id !== k) return;
error = e instanceof Error ? e.message : 'Save failed';
} finally {
saving = false;
@@ -132,6 +190,9 @@
error = '';
try {
await api.deleteTrigger(id);
// Drop the cached entry so a back-nav to this deleted id doesn't
// warm-seed a phantom detail page.
triggerDetailCache.remove(id);
goto('/triggers');
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
@@ -164,15 +225,22 @@
async function doFireNow(): Promise<void> {
if (!trigger) return;
const k = id;
const tid = trigger.id;
firing = true;
error = '';
try {
const res = await api.fireTriggerNow(trigger.id);
const res = await api.fireTriggerNow(tid);
if (id !== k) return;
fireResult = { deployed: res.deployed, errored: res.errored, bindings: res.bindings };
scheduleFireFlashClear();
// Refresh the trigger so the "last fired" row reflects the new ts.
trigger = await api.getTrigger(trigger.id);
const fresh = await api.getTrigger(tid);
if (id !== k) return;
trigger = fresh;
triggerDetailCache.set(k, fresh);
} catch (e) {
if (id !== k) return;
error = e instanceof Error ? e.message : 'Fire failed';
} finally {
firing = false;
@@ -181,10 +249,14 @@
}
async function doRotate(): Promise<void> {
const k = id;
rotating = true;
error = '';
try {
const res = await api.regenerateTriggerWebhook(id);
const res = await api.regenerateTriggerWebhook(k);
// Don't surface this trigger's new secret if we've navigated to
// another trigger while the rotate was in flight.
if (id !== k) return;
// regenerate returns the new url+secret but no signing
// flag — preserve the current toggle state.
webhook = {
@@ -194,6 +266,7 @@
webhook?.webhook_require_signature ?? formState.webhookRequireSig
};
} catch (e) {
if (id !== k) return;
error = e instanceof Error ? e.message : 'Rotate failed';
} finally {
rotating = false;
@@ -202,10 +275,13 @@
}
async function toggleBinding(b: TriggerBinding, next: boolean): Promise<void> {
const k = id;
try {
const updated = await api.updateBinding(b.id, { enabled: next });
if (id !== k) return;
bindings = bindings.map((x) => (x.id === b.id ? { ...x, enabled: updated.enabled } : x));
} catch (e) {
if (id !== k) return;
error = e instanceof Error ? e.message : 'Update failed';
}
}
@@ -213,14 +289,18 @@
async function doUnbind(): Promise<void> {
if (!confirmUnbindId) return;
const bid = confirmUnbindId;
const k = id;
try {
await api.deleteBinding(bid);
if (id !== k) return;
bindings = bindings.filter((b) => b.id !== bid);
// Reflect the new binding count in the hero.
if (trigger) {
trigger = { ...trigger, binding_count: Math.max(0, trigger.binding_count - 1) };
triggerDetailCache.set(k, trigger);
}
} catch (e) {
if (id !== k) return;
error = e instanceof Error ? e.message : 'Unbind failed';
} finally {
confirmUnbindId = null;
@@ -270,7 +350,7 @@
<title>{trigger?.name ?? $t('redeployTriggers.titleSingular')} · Tinyforge</title>
</svelte:head>
<div class="forge" aria-busy={loading}>
<div class="forge" aria-busy={loading || !detailsLoaded}>
{#snippet stats()}
<div>
<dt>KIND</dt>
@@ -390,7 +470,9 @@
<span class="panel-sub">{$t('redeployTriggers.detail.webhookSub')}</span>
</header>
{#if webhook && trigger.webhook_enabled}
{#if !detailsLoaded}
<div class="skeleton-rows" aria-busy="true"><div class="skeleton-row"></div></div>
{:else if webhook && trigger.webhook_enabled}
<div class="field">
<span class="sub-label">{$t('redeployTriggers.detail.webhookUrlLabel')}</span>
<div class="url-box">
@@ -460,7 +542,9 @@
<span class="panel-sub">{$t('redeployTriggers.detail.bindingsSub')}</span>
</header>
{#if bindings.length === 0}
{#if !detailsLoaded}
<div class="skeleton-rows" aria-busy="true"><div class="skeleton-row"></div></div>
{:else if bindings.length === 0}
<div class="note muted-note">
<span class="note-tag"></span>
<p>{$t('redeployTriggers.detail.bindingsEmpty')}</p>