Files
homefeed/backend/src/widgets/poe2/db.ts
T
Claude bf0cb11070 Add pluggable widget system; migrate PoE2 as the pilot implementation
Widgets are no longer hand-wired per-feature across scheduler.ts, settings.ts,
and the API routes. A new WidgetPlugin interface (backend/src/widgets/types.ts)
lets a widget declare its own schema migration, poll interval, routes, and
uninstall hook; an installed_widgets registry table replaces the closed
widgets/widgetOrder unions on global_settings, and the scheduler/route
registration now iterate loaded widgets generically instead of one hardcoded
block per widget.

PoE2 moves into backend/src/widgets/poe2/ as the first real plugin (its tables
renamed to the widget_poe2_ convention, league cache moved into a new generic
widget_kv store). Weather/Stocks/Bookmarks get thin wrapper plugins so they
share the same dispatch loop without migrating their schema.

New admin routes let a widget be uploaded live (POST /api/admin/widgets, JSON
body with inline file contents — no archive dependency needed) and removed
with full data pruning (DELETE /api/admin/widgets/:id): a host-side safety-net
sweep drops any widget_<id>_* table and widget_kv rows regardless of whether
the widget's own uninstall() hook runs, so deleted widgets don't leave dead
data behind. Built-in widgets can't be deleted through this route.

Verified against a copy of the real dev DB: old poe2_watchlist data survives
the rename, the migration is idempotent on a second boot, and a live-uploaded
test widget was polled, queried, and fully deleted (table/kv/on-disk directory
all gone) without a restart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-27 00:27:27 +00:00

99 lines
3.8 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { db } from '../../storage/db/index.js';
import type { Poe2WatchlistEntry } from '../../storage/db/types.js';
interface CurrencyRef {
currencyId: string;
name: string;
}
function rowToEntry(row: any): Poe2WatchlistEntry {
return {
id: row.id,
baseCurrencyId: row.base_currency_id,
baseName: row.base_name,
quoteCurrencyId: row.quote_currency_id,
quoteName: row.quote_name,
priorityRank: row.priority_rank,
lastRate: row.last_rate,
lastChange24h: row.last_change_24h,
lastPolledAt: row.last_polled_at,
lastError: row.last_error,
createdAt: row.created_at
};
}
export function listWatchlist(): Poe2WatchlistEntry[] {
const rows = db.prepare('SELECT * FROM widget_poe2_watchlist ORDER BY priority_rank').all();
return rows.map(rowToEntry);
}
// No update() — currencies are picked from a live browse list (see widgets/poe2/client.ts's
// browseCurrencies), not typed, so there's nothing to edit; remove and re-add covers the
// rare "picked the wrong one" case.
export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2WatchlistEntry {
const id = `poe2-${base.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${quote.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${randomUUID().slice(0, 6)}`
.replace(/-+/g, '-')
.replace(/(^-|-$)/g, '');
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_poe2_watchlist').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
`INSERT INTO widget_poe2_watchlist
(id, base_currency_id, base_name, quote_currency_id, quote_name, priority_rank, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(id, base.currencyId, base.name, quote.currencyId, quote.name, maxRank.m + 1, createdAt);
return {
id,
baseCurrencyId: base.currencyId,
baseName: base.name,
quoteCurrencyId: quote.currencyId,
quoteName: quote.name,
priorityRank: maxRank.m + 1,
lastRate: null,
lastChange24h: null,
lastPolledAt: null,
lastError: null,
createdAt
};
}
export function removeWatchlistEntry(id: string) {
db.prepare('DELETE FROM widget_poe2_rate_history WHERE watchlist_id = ?').run(id);
db.prepare('DELETE FROM widget_poe2_watchlist WHERE id = ?').run(id);
}
// One snapshot per poll (see widgets/poe2/poll.ts) — the raw material 24h change is computed
// from, since poe.ninja itself doesn't expose per-pair rates or a matching change window.
export function recordRate(watchlistId: string, rate: number, recordedAt: string) {
db.prepare('INSERT INTO widget_poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run(
watchlistId,
rate,
recordedAt
);
}
// The closest snapshot at-or-before cutoffIso — null if there isn't one yet (e.g. a
// pair added less than 24h ago), which the poller treats as "no change data yet" rather
// than fabricating a 0% figure.
export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | null {
const row = db
.prepare('SELECT rate FROM widget_poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1')
.get(watchlistId, cutoffIso) as { rate: number } | undefined;
return row ? row.rate : null;
}
export function markPolled(id: string, rate: number | null, change24h: number | null, error: string | null) {
db.prepare(
`UPDATE widget_poe2_watchlist
SET last_rate = ?, last_change_24h = ?, last_polled_at = ?, last_error = ?
WHERE id = ?`
).run(rate, change24h, new Date().toISOString(), error, id);
}
// Keeps history bounded — a day or two of slack past the 24h window is plenty for a
// poll to be briefly late without losing the data point it needs.
export function pruneOldHistory() {
const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
db.prepare('DELETE FROM widget_poe2_rate_history WHERE recorded_at < ?').run(cutoff);
}