openvibers/openvibe.live Commits

Chat deploy notices: only unannounced commits, folded into one rolling message with real timestamps, replaced in place by id; legacy restart rows fold at render; silent on a restart with no new code

OP OpenVibers committed 18 Sept 2026 · fc253e6547 · 4 files, +224 −134


Changes

ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex cfffaacc..dbf8d1e9 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -3157,37 +3157,8 @@ function handleChatMessage(msg) {
_chatConnState('updating');
break;
case 'update': {
-- // Platform update notification with commit logs + expandable changelog
-- const summary = esc(msg.summary || 'New update deployed!');
-- const commits = Array.isArray(msg.commits) ? msg.commits : [];
-- const linkUrl = msg.url ? esc(msg.url) : null;
--
-- let html = `🚀 ${summary}`;
--
-- if (commits.length > 0) {
-- const changelogId = 'update-changelog-' + Date.now();
-- html += ` <a href="#" onclick="event.preventDefault();document.getElementById('${changelogId}').classList.toggle('open')" style="color:var(--accent);text-decoration:underline;cursor:pointer">View changelog ▾</a>`;
-- if (linkUrl) {
-- html += ` · <a href="${linkUrl}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:underline">Full patch notes →</a>`;
-- }
-- html += `<div id="${changelogId}" class="chat-changelog" style="display:none;margin-top:6px;padding:6px 8px;background:rgba(0,0,0,0.25);border-radius:6px;font-size:0.82rem;max-height:200px;overflow-y:auto;">`;
-- for (const c of commits) {
-- const short = esc(c.short || '');
-- const subject = esc(c.subject || '');
-- const commitUrl = c.hash ? `https://github.com/OpenVibers/OpenVibe.Live/commit/${esc(c.hash)}` : '#';
-- html += `<div style="padding:2px 0;display:flex;gap:6px;align-items:baseline;"><a href="${commitUrl}" target="_blank" rel="noopener" style="color:var(--accent);font-family:monospace;font-size:0.78rem;text-decoration:none;flex-shrink:0">${short}</a> <span style="opacity:0.85">${subject}</span></div>`;
-- }
-- html += '</div>';
-- // Auto-expand with a microtask so the DOM element exists
-- setTimeout(() => {
-- const el = document.getElementById(changelogId);
-- if (el) el.classList.add('open'), el.style.display = '';
-- }, 0);
-- } else if (linkUrl) {
-- html += ` <a href="${linkUrl}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:underline">View full patch notes →</a>`;
-- }
-- html += ' <button onclick="location.href=location.pathname+\'?_=\'+Date.now()" style="margin-left:8px;padding:2px 10px;border:1px solid var(--accent);background:var(--accent);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-size:0.8rem;font-family:var(--font)">Refresh Page</button>';
-- addRichSystemMessage(html, 'update');
++ // Deploy notice: one rolling card per run of deploys, replaced in place by id (never duplicated).
++ renderDeployCard({ id: msg.id, commits: msg.commits, deploys: msg.deploys, first_at: msg.first_at || msg.timestamp, updated_at: msg.updated_at || msg.timestamp, fresh: msg.fresh }, { live: true });
break;
}
case 'coin_earned':
@@@ -3790,6 +3761,87 @@ function addRichSystemMessage(html, style = 'info') {
if (_chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();
}
++/* ── Deploy notices ─────────────────────────────────────────────
++ * One card for a run of deploys: "🚀 5 updates shipped", the time range, the commit subjects (each
++ * listed once), a link to the full notes. Rendered from data (metadata.kind === 'deploy'), so times
++ * come from ISO timestamps and are always correct. Same card in global, channel, popout and widget
++ * chat because they all render through this file. Legacy one-line rows are folded on the way in. */
++function _deployCss() {
++ if (document.getElementById('chat-deploy-css')) return;
++ const st = document.createElement('style'); st.id = 'chat-deploy-css';
++ st.textContent = `.chat-deploy{margin:6px 0;padding:9px 11px;border-radius:10px;border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 30%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent);color:var(--text-secondary,#a8b3c4);font-style:normal;font-size:.84rem;line-height:1.45}
++.chat-deploy-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;color:var(--text-primary,#e6edf7)}.chat-deploy-h b{font-weight:700}.chat-deploy-t{font-size:.76rem;color:var(--text-muted,#7d8aa0)}
++.chat-deploy-a{margin-left:auto;display:flex;gap:10px;align-items:center}.chat-deploy a,.chat-deploy-more{color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none;font-size:.78rem;cursor:pointer;background:none;border:0;padding:0;font-family:inherit}
++.chat-deploy-reload{border:0;border-radius:6px;background:var(--accent,#3b82f6);color:var(--on-accent,#fff);font:600 .74rem/1 inherit;padding:4px 9px;cursor:pointer}
++.chat-deploy ul{list-style:none;margin:6px 0 0;padding:0;display:grid;gap:2px}.chat-deploy li{display:flex;gap:7px;align-items:baseline;min-width:0}.chat-deploy li.is-hidden{display:none}.chat-deploy.is-open li.is-hidden{display:flex}
++.chat-deploy li span{min-width:0;overflow-wrap:anywhere}.chat-deploy li a{font-family:ui-monospace,Menlo,monospace;font-size:.72rem;flex:none}.chat-deploy li.is-fresh span{color:var(--text-primary,#e6edf7)}`;
++ document.head.appendChild(st);
++}
++function _deployTime(d) {
++ const opts = chatSettings.timestampFormat === '24h' ? { hour: '2-digit', minute: '2-digit', hour12: false } : { hour: '2-digit', minute: '2-digit' };
++ const today = new Date().toDateString() === d.toDateString();
++ return (today ? '' : d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ') + d.toLocaleTimeString([], opts);
++}
++function renderDeployCard(data, opts = {}) {
++ const { messages: container } = getChatEl();
++ if (!container) return;
++ _deployCss();
++ const seen = new Set();
++ const commits = (Array.isArray(data.commits) ? data.commits : []).filter(c => c && c.subject && !seen.has(c.hash || c.subject) && seen.add(c.hash || c.subject));
++ if (!commits.length) return;
++ const fresh = new Set(data.fresh || []);
++ const first = _parseMsgTime(data.first_at) || new Date(), last = _parseMsgTime(data.updated_at) || first;
++ const SHOW = 3;
++ const id = data.id != null ? String(data.id) : '';
++ let el = id ? container.querySelector('.chat-deploy[data-deploy-id="' + id.replace(/[^\w-]/g, '') + '"]') : null;
++ const wasOpen = el && el.classList.contains('is-open');
++ if (!el) { el = document.createElement('div'); el.className = 'chat-msg system chat-deploy'; if (id) el.dataset.deployId = id.replace(/[^\w-]/g, ''); }
++ const range = first.getTime() === last.getTime() || Math.abs(last - first) < 60000 ? _deployTime(last) : _deployTime(first) + ' – ' + _deployTime(last);
++ const deploys = Number(data.deploys) || 1;
++ el.innerHTML = `<div class="chat-deploy-h"><b>🚀 ${commits.length} update${commits.length === 1 ? '' : 's'} shipped</b><span class="chat-deploy-t" title="${esc(last.toLocaleString())}">${esc(range)}${deploys > 1 ? ' · ' + deploys + ' deploys' : ''}</span>
++ <span class="chat-deploy-a">${commits.length > SHOW ? `<button type="button" class="chat-deploy-more">${wasOpen ? 'Show less' : 'Show all ' + commits.length}</button>` : ''}<a href="/updates" target="_blank" rel="noopener">Patch notes</a>${opts.live ? '<button type="button" class="chat-deploy-reload" title="Load the new version">Reload</button>' : ''}</span></div>
++ <ul>${commits.map((c, i) => `<li class="${i >= SHOW ? 'is-hidden' : ''}${fresh.has(c.hash) ? ' is-fresh' : ''}">${c.hash ? `<a href="https://github.com/OpenVibers/OpenVibe.Live/commit/${esc(c.hash)}" target="_blank" rel="noopener">${esc(c.short || String(c.hash).slice(0, 7))}</a>` : ''}<span>${esc(c.subject)}</span></li>`).join('')}</ul>`;
++ if (wasOpen) el.classList.add('is-open');
++ const more = el.querySelector('.chat-deploy-more');
++ if (more) more.addEventListener('click', () => { const open = el.classList.toggle('is-open'); more.textContent = open ? 'Show less' : 'Show all ' + commits.length; });
++ const reload = el.querySelector('.chat-deploy-reload');
++ if (reload) reload.addEventListener('click', () => { location.href = location.pathname + '?_=' + Date.now(); });
++ // A live update moves the card to the end (it IS the newest message); history keeps its position.
++ if (!el.isConnected || opts.live) container.appendChild(el);
++ scrollChat();
++ if (opts.live && _chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();
++}
++/** Old rows were prose: "🚀 Server restarted (abc1234) — a · b · c (2m ago)". Pull the subjects back out. */
++function _legacyDeploy(m) {
++ const t = String(m.message || '');
++ if (!/^🚀\s*Server restarted/.test(t)) return null;
++ const body = t.replace(/^🚀\s*Server restarted\s*(\([^)]*\)\s*)?—\s*/, '').replace(/\s*\((just now|\d+[mhd] ago)\)\s*$/, '');
++ const short = (/\(([0-9a-f]{6,12})\)/.exec(t) || [])[1] || '';
++ return body.split(' · ').map(x => x.trim()).filter(Boolean).map((subject, i) => ({ subject, short: i === 0 ? short : '', hash: '' }));
++}
++/** A deploy notice for history rows: structured rows as they are, consecutive legacy rows folded into one. */
++function _deployFromRow(m) {
++ let meta = null; try { meta = typeof m.metadata === 'string' ? JSON.parse(m.metadata) : m.metadata; } catch { meta = null; }
++ if (meta && meta.kind === 'deploy') return { id: m.id, commits: meta.commits, deploys: meta.deploys, first_at: meta.first_at, updated_at: meta.updated_at };
++ const legacy = _legacyDeploy(m);
++ return legacy ? { id: null, legacy: true, commits: legacy, deploys: 1, first_at: m.timestamp || m.created_at, updated_at: m.timestamp || m.created_at } : null;
++}
++function _foldDeployRows(msgs) {
++ const out = [];
++ for (const m of msgs) {
++ const d = m.message_type === 'system' ? _deployFromRow(m) : null;
++ const prev = out[out.length - 1];
++ if (d && prev && prev.__deploy) {
++ // Newest first inside the card; each subject once.
++ prev.__deploy.commits = d.commits.concat(prev.__deploy.commits);
++ prev.__deploy.deploys += d.deploys || 1;
++ prev.__deploy.updated_at = d.updated_at;
++ if (d.id != null) prev.__deploy.id = d.id;
++ } else out.push(d ? { __deploy: d } : m);
++ }
++ return out;
++}
++
function addDonationMessage(msg) {
const { messages: container } = getChatEl();
if (!container) return;
@@@ -4496,9 +4548,11 @@ async function loadGlobalChatHistory() {
function _renderGlobalHistoryMessages(msgs) {
chatLiveSlots = [];
chatChannel = null;
-- msgs.forEach(m => {
-- if (m.message_type === 'system') {
-- // Render system messages (update announcements, etc.) with system styling
++ _foldDeployRows(msgs).forEach(m => {
++ if (m.__deploy) {
++ renderDeployCard(m.__deploy);
++ } else if (m.message_type === 'system') {
++ // Other system messages keep the plain system styling
addRichSystemMessage(esc(m.message), 'update');
} else if (m.message_type === 'donation') {
// Donations carry their payload in metadata — render the rich card here
ddiff --git a/server/chat/deploy-notice.js b/server/chat/deploy-notice.js
nnew file mode 100644
iindex 00000000..07ef1942
---- /dev/null
++++ b/server/chat/deploy-notice.js
@@@ -0,0 +1,99 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Deploy notices in chat — one tidy, rolling message instead of a line per restart.
++//
++// What went wrong before: every boot inserted a chat row holding the latest three commit subjects
++// (which overlap from one restart to the next) with a relative time baked into the text ("2m ago"
++// forever). Fourteen deploys made fourteen rows repeating each other.
++//
++// Now:
++// • Only commits that were never announced are announced (site setting `deploy_last_announced`).
++// A restart with no new code says nothing.
++// • Consecutive deploys fold into ONE stored message: while the newest global chat row is a deploy
++// notice (nobody has spoken since) and it is under 12 hours old, that row is updated in place.
++// • The row stores data, not prose: metadata { kind: 'deploy', commits[], deploys, first_at,
++// updated_at }. Clients render times from ISO timestamps, so they are always right.
++// • The live broadcast carries the row id; clients replace the card with that id instead of
++// appending, so reconnects and repeats can never duplicate it.
++// ═══════════════════════════════════════════════════════════════
++const { execFile } = require('child_process');
++const path = require('path');
++
++const REPO_DIR = path.join(__dirname, '..', '..');
++const SETTING = 'deploy_last_announced';
++const FOLD_WINDOW_MS = 12 * 60 * 60 * 1000;
++const MAX_COMMITS = 40;
++const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart
++
++const git = (args, timeout = 5000) => new Promise((resolve) => {
++ execFile('git', args, { cwd: REPO_DIR, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 }, (err, out) => resolve(err ? '' : String(out || '')));
++});
++
++const parseLog = (raw) => raw.trim().split('\n').filter(Boolean).map((line) => {
++ const [hash, short, date, ...rest] = line.split('\x1f');
++ return { hash, short, date, subject: rest.join(' ').trim().slice(0, 200) };
++}).filter(c => /^[0-9a-f]{40}$/.test(c.hash || ''));
++
++/** Commits on HEAD that have not been announced yet (newest first). */
++async function newCommits(db) {
++ const head = (await git(['rev-parse', 'HEAD'], 3000)).trim();
++ if (!/^[0-9a-f]{40}$/.test(head)) return { head: '', commits: [] };
++ const last = String(db.getSetting(SETTING) || '').trim();
++ if (last === head) return { head, commits: [] };
++ const fmt = '--pretty=format:%H%x1f%h%x1f%aI%x1f%s';
++ let raw = '';
++ if (/^[0-9a-f]{40}$/.test(last)) raw = await git(['--no-pager', 'log', fmt, '-n', String(MAX_COMMITS), `${last}..${head}`]);
++ // First run, or history was rewritten: announce the current commit only, never a backlog.
++ if (!raw.trim()) raw = await git(['--no-pager', 'log', fmt, '-n', '1', head]);
++ return { head, commits: parseLog(raw) };
++}
++
++const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.length === 1 ? '' : 's'} shipped: ${meta.commits.slice(0, 3).map(c => c.subject).join(' · ')}${meta.commits.length > 3 ? ` · and ${meta.commits.length - 3} more` : ''}`;
++
++/** Insert a notice, or fold into the newest row when that row is itself a recent deploy notice. */
++function persist(db, commits) {
++ const nowIso = new Date().toISOString();
++ const newest = db.get('SELECT id, message_type, metadata, created_at FROM chat_messages WHERE is_global = 1 AND is_deleted = 0 ORDER BY id DESC LIMIT 1');
++ let prev = null;
++ if (newest && newest.message_type === 'system' && newest.metadata) {
++ try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }
++ }
++ if (prev) {
++ const seen = new Set(commits.map(c => c.hash));
++ const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);
++ const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };
++ db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);
++ return { id: newest.id, meta };
++ }
++ const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };
++ const res = db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });
++ return { id: res && (res.lastInsertRowid || res.lastID || res.id) || null, meta };
++}
++
++/**
++ * Announce this boot's new commits. Safe to call once after the chat server is up.
++ * @returns {Promise<{ announced: number }>}
++ */
++async function announce({ db, chatServer, log = console }) {
++ const { head, commits } = await newCommits(db);
++ if (!head || !commits.length) return { announced: 0 };
++
++ let saved;
++ try { saved = persist(db, commits); db.setSetting(SETTING, head); }
++ catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }
++
++ const payload = JSON.stringify({ type: 'update', kind: 'deploy', id: saved.id, fresh: commits.map(c => c.hash), url: '/updates', timestamp: saved.meta.updated_at, ...saved.meta });
++ const sent = new WeakSet();
++ const push = () => {
++ let n = 0;
++ for (const [ws] of chatServer.clients) {
++ if (sent.has(ws) || ws.readyState !== 1 || ws.bufferedAmount > 256 * 1024) continue;
++ try { ws.send(payload); sent.add(ws); n++; } catch { /* socket went away */ }
++ }
++ return n;
++ };
++ ATTEMPTS_MS.forEach((ms, i) => { const t = setTimeout(() => { const n = push(); if (n) log.log(`[Deploy notice] ${commits.length} commit(s) → ${n} client(s) (pass ${i + 1})`); }, ms); if (t.unref) t.unref(); });
++ return { announced: commits.length };
++}
++
++module.exports = { announce, newCommits, persist, plainText, parseLog, SETTING };
ddiff --git a/server/index.js b/server/index.js
iindex 187e5b3f..825dddf5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1351,106 +1351,9 @@ async function start() {
const _pcT = setInterval(_pcClean, 24 * 60 * 60 * 1000); if (_pcT.unref) _pcT.unref();
} catch { /* */ }
-- // Broadcast recent git changes to all chat clients after startup.
-- // Uses a retry loop — clients reconnect with backoff after a restart,
-- // so a single 5s broadcast misses most of them. We broadcast at 5s,
-- // 15s, and 30s intervals, tracking which clients already received it.
-- // The message is also persisted to chat_messages so it shows in history.
-- const _changelogSentTo = new Set();
-- let _changelogBroadcasts = 0;
-- const _changelogMaxBroadcasts = 3;
-- const _changelogDelays = [5000, 15000, 30000];
--
-- /** Promise wrapper around execFile — git, argv-style, never through a shell. */
-- function _git(args, timeout) {
-- return new Promise((resolve) => {
-- execFile('git', args, { cwd: REPO_DIR, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 },
-- (err, stdout) => resolve(err ? '' : String(stdout || '')));
-- });
-- }
--
-- // These two git calls used to be execSync. At boot that blocks the event loop while the
-- // server is meant to be accepting its first requests, and the whole reason this runs on a
-- // 5/15/30s delay is to stay out of the way of startup. execFile keeps it off the loop.
-- async function broadcastChangelog() {
-- try {
-- const versionTag = (await _git(['describe', '--always', '--tags'], 3000)).trim();
--
-- const raw = await _git(['--no-pager', 'log', '--pretty=format:%H||%h||%s||%an||%aI', '-10'], 5000);
-- if (!raw.trim()) return;
-- const commits = raw.trim().split('\n').filter(Boolean).map(line => {
-- const [hash, short, subject, author, date] = line.split('||');
-- return { hash, short, subject, author, date };
-- });
-- if (commits.length === 0) return;
--
-- // Build summary: use version tag if available, otherwise top 3 subjects
-- const top3 = commits.slice(0, 3).map(c => c.subject).join(' · ');
-- const versionPrefix = versionTag ? `(${versionTag}) ` : '';
-- // Include relative time since the most recent commit
-- const latestDate = commits[0].date ? new Date(commits[0].date) : null;
-- let timeSuffix = '';
-- if (latestDate) {
-- const ago = Date.now() - latestDate.getTime();
-- if (ago < 60_000) timeSuffix = ' (just now)';
-- else if (ago < 3_600_000) timeSuffix = ` (${Math.round(ago / 60_000)}m ago)`;
-- else if (ago < 86_400_000) timeSuffix = ` (${Math.round(ago / 3_600_000)}h ago)`;
-- else timeSuffix = ` (${Math.round(ago / 86_400_000)}d ago)`;
-- }
--
-- const payload = {
-- type: 'update',
-- summary: `Server restarted ${versionPrefix}— ${top3}${timeSuffix}`,
-- commits,
-- url: '/updates',
-- timestamp: new Date().toISOString(),
-- };
-- const msg = JSON.stringify(payload);
--
-- // Persist to chat_messages on the FIRST broadcast only
-- if (_changelogBroadcasts === 0) {
-- try {
-- const summaryText = `🚀 Server restarted ${versionPrefix}— ${top3}${timeSuffix}`;
-- db.saveChatMessage({
-- stream_id: null,
-- user_id: null,
-- anon_id: null,
-- username: 'OpenVibe.Live',
-- message: summaryText,
-- message_type: 'system',
-- is_global: true,
-- });
-- } catch (dbErr) {
-- console.warn('[Server] Failed to persist changelog to chat:', dbErr.message);
-- }
-- }
--
-- // Send only to clients that haven't received it yet
-- let newRecipients = 0;
-- for (const [ws, client] of chatServer.clients) {
-- const clientKey = client.user?.id ? `u:${client.user.id}` : `a:${client.anonId || ws._socket?.remoteAddress}`;
-- if (_changelogSentTo.has(clientKey)) continue;
-- if (ws.readyState === 1 /* WebSocket.OPEN */ && ws.bufferedAmount <= 256 * 1024) {
-- ws.send(msg);
-- _changelogSentTo.add(clientKey);
-- newRecipients++;
-- }
-- }
--
-- _changelogBroadcasts++;
-- console.log(`[Server] Changelog broadcast #${_changelogBroadcasts}: ${newRecipients} new recipients (${_changelogSentTo.size} total, ${chatServer.getTotalConnections()} connected)`);
--
-- // Schedule next broadcast if we haven't hit the max
-- if (_changelogBroadcasts < _changelogMaxBroadcasts) {
-- const nextDelay = (_changelogDelays[_changelogBroadcasts] || 30000) - (_changelogDelays[_changelogBroadcasts - 1] || 0);
-- setTimeout(broadcastChangelog, nextDelay);
-- }
-- } catch (err) {
-- console.warn('[Server] Failed to broadcast startup changelog:', err.message);
-- }
-- }
--
-- setTimeout(broadcastChangelog, _changelogDelays[0]);
++ // Deploy notice in chat: only commits not announced before, folded into one rolling message
++ // (server/chat/deploy-notice.js). A restart with no new code says nothing.
++ require('./chat/deploy-notice').announce({ db, chatServer }).catch((err) => console.warn('[Deploy notice] failed:', err.message));
});
// 8. Start stale stream heartbeat cleanup (every 60 seconds)
ddiff --git a/test/deploy-notice.test.js b/test/deploy-notice.test.js
nnew file mode 100644
iindex 00000000..aa45e485
---- /dev/null
++++ b/test/deploy-notice.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++// Deploy notices fold into one rolling row, never repeat a commit, and stay silent without new code.
++const assert = require('assert');
++const dn = require('../server/chat/deploy-notice');
++
++const rows = []; const settings = {};
++const db = {
++ getSetting: (k) => settings[k], setSetting: (k, v) => { settings[k] = v; },
++ get: () => rows.filter(r => r.is_global && !r.is_deleted).slice(-1)[0] || undefined,
++ run: (_sql, [message, metadata, id]) => { const r = rows.find(x => x.id === id); r.message = message; r.metadata = metadata; },
++ saveChatMessage: (m) => { const r = { id: rows.length + 1, is_global: 1, is_deleted: 0, message_type: m.message_type, message: m.message, metadata: m.metadata ? JSON.stringify(m.metadata) : null }; rows.push(r); return { lastInsertRowid: r.id }; },
++};
++const c = (n) => ({ hash: String(n).repeat(40).slice(0, 40).replace(/[^0-9a-f]/g, 'a'), short: 'c' + n, subject: 'change ' + n, date: new Date().toISOString() });
++
++const first = dn.persist(db, [c(1)]);
++assert.equal(rows.length, 1); assert.equal(first.meta.deploys, 1);
++const second = dn.persist(db, [c(2), c(1)]); // overlap with what is already there
++assert.equal(rows.length, 1, 'a second deploy updates the same row');
++assert.equal(second.id, first.id);
++assert.deepEqual(second.meta.commits.map(x => x.short), ['c2', 'c1'], 'each commit once, newest first');
++assert.equal(second.meta.deploys, 2);
++assert.ok(/^🚀 2 updates shipped/.test(rows[0].message));
++
++rows.push({ id: 2, is_global: 1, is_deleted: 0, message_type: 'chat', message: 'hello', metadata: null }); // someone spoke
++const third = dn.persist(db, [c(3)]);
++assert.equal(rows.length, 3, 'after real chat, the next deploy starts a new notice');
++assert.equal(third.meta.deploys, 1);
++
++const old = JSON.parse(rows[2].metadata); old.first_at = new Date(Date.now() - 13 * 3600 * 1000).toISOString(); rows[2].metadata = JSON.stringify(old);
++dn.persist(db, [c(4)]);
++assert.equal(rows.length, 4, 'a notice older than the fold window is not reopened');
++
++assert.deepEqual(dn.parseLog('zz\x1fab\x1f2026\x1fnope'), [], 'malformed git lines are dropped');
++console.log('deploy notice: all checks passed');