fix(discovery): render empty queue as [] not null (500 on empty prod)

Go's nil slice marshals as JSON null, not [], which crashed the Svelte
page's .length access on fresh installs where no discovery tick has
happened yet. Reproduced in production: /admin/discovery → 500 because
data.queue was null and {queue.length} dereferenced it.

Backend: initialize every returning slice in repository.go via
make([]T, 0) so zero rows serialize as [] consistently. Also applies to
PickStaleBuckets, ListSeriesByCity, and Stats.RecentErrors.

Web: coalesce data.queue / data.stats.recent_errors at the top of the
Svelte script with `?? []` so future nil-slice regressions don't take
the whole page down.
This commit is contained in:
2026-04-18 08:44:17 +02:00
parent 134cc9726b
commit 14e1a36622
2 changed files with 14 additions and 9 deletions

View File

@@ -57,7 +57,7 @@ LIMIT $2`
return nil, fmt.Errorf("pick buckets: %w", err)
}
defer rows.Close()
var out []Bucket
out := make([]Bucket, 0)
for rows.Next() {
var b Bucket
if err := rows.Scan(&b.ID, &b.Land, &b.Region, &b.YearMonth, &b.LastQueriedAt, &b.LastError, &b.CreatedAt); err != nil {
@@ -89,7 +89,7 @@ func (r *pgRepository) ListSeriesByCity(ctx context.Context, cityNormalized stri
return nil, err
}
defer rows.Close()
var out []SeriesCandidate
out := make([]SeriesCandidate, 0)
for rows.Next() {
var c SeriesCandidate
if err := rows.Scan(&c.ID, &c.Name, &c.City); err != nil {
@@ -155,7 +155,7 @@ LIMIT $2 OFFSET $3`, status, limit, offset)
return nil, err
}
defer rows.Close()
var out []DiscoveredMarket
out := make([]DiscoveredMarket, 0)
for rows.Next() {
var d DiscoveredMarket
if err := rows.Scan(
@@ -249,6 +249,7 @@ LIMIT $1`, recentErrorsLimit)
return Stats{}, fmt.Errorf("stats recent_errors: %w", err)
}
defer rows.Close()
s.RecentErrors = make([]BucketError, 0)
for rows.Next() {
var e BucketError
if err := rows.Scan(&e.Land, &e.Region, &e.YearMonth, &e.LastError, &e.LastQueriedAt); err != nil {

View File

@@ -2,6 +2,10 @@
import { enhance } from '$app/forms';
let { data } = $props();
// Coalesce nullable list fields (Go encodes nil slices as null).
const queue = $derived(data.queue ?? []);
const recentErrors = $derived(data.stats.recent_errors ?? []);
const lastTickLabel = $derived.by(() => {
if (!data.stats.last_tick_at) return 'nie';
const ts = new Date(data.stats.last_tick_at).getTime();
@@ -64,16 +68,16 @@
</div>
</div>
{#if data.stats.recent_errors && data.stats.recent_errors.length > 0}
{#if recentErrors.length > 0}
<details
class="mt-3 rounded border border-red-200 bg-red-50 p-2 text-sm dark:border-red-800 dark:bg-red-950"
>
<summary class="cursor-pointer text-red-700 dark:text-red-300">
{data.stats.recent_errors.length} bucket{data.stats.recent_errors.length === 1 ? '' : 's'}
{recentErrors.length} bucket{recentErrors.length === 1 ? '' : 's'}
with errors
</summary>
<ul class="mt-2 space-y-1 text-xs">
{#each data.stats.recent_errors as e}
{#each recentErrors as e}
<li class="font-mono">
<span class="text-stone-500">{e.last_queried_at?.slice(0, 16)}</span>
<span class="ml-2">{e.land} / {e.region} / {e.year_month}</span>
@@ -86,10 +90,10 @@
<h2 class="mt-8 text-lg font-semibold">Queue</h2>
<p class="mt-1 text-sm text-stone-500">
{data.queue.length} pending · showing from offset {data.offset}
{queue.length} pending · showing from offset {data.offset}
</p>
{#if data.queue.length === 0}
{#if queue.length === 0}
<p class="mt-8 rounded border border-stone-200 bg-stone-50 p-6 text-center text-stone-600">
Keine Einträge in der Warteschlange.
</p>
@@ -109,7 +113,7 @@
</tr>
</thead>
<tbody>
{#each data.queue as row (row.id)}
{#each queue as row (row.id)}
<tr class="border-b border-stone-100">
<td class="py-2">{row.land}</td>
<td>{row.bundesland}</td>