// 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() const entries = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`) return `{${entries.join(',')}}` } // dedupe_key = sha256(kind + t + stable-json(payload)), truncated to 40 hex chars. // This is a content fingerprint for idempotent INSERT IGNORE, not a security value, // but we use SHA-256 rather than SHA-1 anyway; the truncation keeps it inside the // CHAR(40) column (160 bits is ample collision resistance for dedupe). Two identical // events (same kind, same timestamp, same body) collapse to one row. function dedupeKey(kind, t, payload) { return crypto .createHash('sha256') .update(`${kind}|${t}|${stableStringify(payload)}`) .digest('hex') .slice(0, 40) } // 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. `kinds` (array) restricts to an allowlist; `kind` filters a single kind. async function list({ kind, kinds, limit } = {}) { const rows = await db.list({ kind, kinds, 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 }