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
53 lines
2.1 KiB
JavaScript
53 lines
2.1 KiB
JavaScript
// Append-only shard event log. The WS ingest dispatcher calls append() for the
|
|
// notable kinds; the public/admin read endpoints call list(). The DB layer only
|
|
// sees an already-computed dedupe_key so INSERT IGNORE is idempotent across
|
|
// WS-reconnect backfill.
|
|
|
|
const crypto = require('crypto')
|
|
const db = require('./shardEvents.db')
|
|
|
|
const MAX_LIMIT = 1000
|
|
const DEFAULT_LIMIT = 100
|
|
|
|
// Stable stringify — keys sorted — so the dedupe hash is independent of the
|
|
// property order the sidecar happened to serialize with.
|
|
function stableStringify(value) {
|
|
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
const keys = Object.keys(value).sort()
|
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
|
}
|
|
|
|
// dedupe_key = sha1(kind + t + stable-json(payload)). Two identical events (same
|
|
// kind, same timestamp, same body) collapse to one row.
|
|
function dedupeKey(kind, t, payload) {
|
|
return crypto.createHash('sha1').update(`${kind}|${t}|${stableStringify(payload)}`).digest('hex')
|
|
}
|
|
|
|
// Append one event. Returns true if a new row was inserted (false = deduped).
|
|
async function append({ kind, t, bootId, payload }) {
|
|
return db.insertIgnore({ kind, t, bootId, payload, dedupeKey: dedupeKey(kind, t, payload) })
|
|
}
|
|
|
|
function normalizeLimit(limit) {
|
|
const n = Number(limit)
|
|
if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT
|
|
return Math.min(Math.floor(n), MAX_LIMIT)
|
|
}
|
|
|
|
// Recent events, newest first. Each row's JSON payload is parsed back to an object.
|
|
async function list({ kind, limit } = {}) {
|
|
const rows = await db.list({ kind, limit: normalizeLimit(limit) })
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
kind: row.kind,
|
|
t: row.t,
|
|
bootId: row.boot_id || null,
|
|
// mariadb returns JSON columns as strings on some versions; parse defensively.
|
|
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
|
|
createdAt: row.created_at,
|
|
}))
|
|
}
|
|
|
|
module.exports = { append, list, dedupeKey }
|