Add uo-link WS ingest, storage tables and SSE broadcaster (phase 1)

The site now ingests the sidecar's live WebSocket feed and persists it to its
own MariaDB, and re-broadcasts curated events to browsers over SSE.

- schema: shard_events (append-only notable-kind log, sha1 dedupe_key +
  INSERT IGNORE for idempotent reconnect backfill), shard_online (current
  players, upsert/refresh/remove), shard_economy (gold-supply series),
  shard_houses (per-house decay stage + derived is_idoc).
- model/shardEvents + model/shardState: the .db.js/.model.js split; writes
  take camelCase event data, reads are shaped; online upsert uses COALESCE so
  a partial char.vitals refresh never blanks login fields.
- utils/shardIngest: single dispatcher routing each kind to state writes
  and/or the event log, then the broadcaster. High-frequency kinds
  (char.vitals, economy.supply) update state only. A changed server.hello
  bootId clears the stale online roster. Deps are injected for unit testing.
- utils/uoLinkSocket: the server's first outbound WS client (ws dep). Verifies
  the ws.hello protocol, backfills via /history + /economy on every
  (re)connect (dedupe handles overlap), reconnects with capped backoff, and
  mirrors connection state into uo_link_config. Self-guards: only connects when
  the integration is enabled with a token.
- utils/shardBroadcast: SSE fan-out with public (safe kinds only) and admin
  (all) channels, keepalive pings, per-client cleanup.
- server.js: start the ingest socket on boot (no-op until configured) and stop
  it + close SSE streams on graceful shutdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
2026-07-11 02:08:56 -05:00
parent ab647756f0
commit 9d9f5aac28
11 changed files with 923 additions and 2 deletions

View File

@@ -0,0 +1,31 @@
const { query } = require('../../utils/db')
// INSERT IGNORE on the UNIQUE dedupe_key — a re-ingested event (WS-reconnect
// backfill overlap) is silently skipped rather than duplicated. Returns true if
// a new row was actually inserted.
async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
const res = await query(
`INSERT IGNORE INTO shard_events (kind, t, boot_id, payload, dedupe_key)
VALUES (?, ?, ?, ?, ?)`,
[kind, t, bootId || null, JSON.stringify(payload), dedupeKey],
)
return res.affectedRows > 0
}
// Recent events, newest first. Optional kind filter; limit is clamped by the model.
async function list({ kind, limit }) {
if (kind) {
return query(
`SELECT id, kind, t, boot_id, payload, created_at
FROM shard_events WHERE kind = ? ORDER BY t DESC LIMIT ?`,
[kind, limit],
)
}
return query(
`SELECT id, kind, t, boot_id, payload, created_at
FROM shard_events ORDER BY t DESC LIMIT ?`,
[limit],
)
}
module.exports = { insertIgnore, list }