openvibers/openvibe.live Commits

Drop Live emotes table after Chat handoff

OP OpenVibers committed 1 Oct 2026 · 185b4d7b57 · 9 files, +49 −125


Changes

ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 1512ef54..60788a3b 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -39,7 +39,7 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
`playAlertSound [streamerId, streamId, kind]` ([server/monetization/alerts.js](../server/monetization/alerts.js)).
Live's staged-table machinery (the write relay, dual read and handoff in `chat-tables*.js`), Live's
chat-AI summary job and the old emote/channel-moderation routes were deleted in the N+1 release; Live's
-- copies of the seven supporting tables were dropped in N+2, and `emotes` is unread and dropped next (ADR-016).
++ copies of the seven supporting tables were dropped in N+2, and the unread `emotes` copy in N+3 (ADR-016).
## Features
ddiff --git a/server/auth/account-data.js b/server/auth/account-data.js
iindex 1f98eedf..799e369b 100644
---- a/server/auth/account-data.js
++++ b/server/auth/account-data.js
@@@ -46,7 +46,7 @@ const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dh
const ROW_LIMIT = 2000;
const PART_BUDGET = 18 * 1024 * 1024;
// OpenVibe.Chat owns these and erases them itself (roadmap T3); Live must not touch them.
--const CHAT_TABLES = new Set(['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events']);
++const CHAT_TABLES = new Set(['channel_moderators', 'channel_moderation_settings', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events']);
function ensureSchema(d) {
d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
ddiff --git a/server/db/database.js b/server/db/database.js
iindex a77cb8f2..bd94bb39 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1567,56 +1567,6 @@ function initDb() {
}
} catch (e) { console.warn('[DB] Role migration:', e.message); }
-- // Migrate: add channel_owner_id to emotes (viewer uploads targeting a channel) + channel_sounds table
-- try {
-- const emoteCols = database.prepare('PRAGMA table_info(emotes)').all().map((c) => c.name);
-- if (!emoteCols.includes('channel_owner_id')) {
-- database.exec('ALTER TABLE emotes ADD COLUMN channel_owner_id INTEGER');
-- database.exec('CREATE INDEX IF NOT EXISTS idx_emotes_channel_owner ON emotes(channel_owner_id)');
-- }
-- // Per-emote display size (percent of base, 100 = default). Clamped to the channel's min/max.
-- if (!emoteCols.includes('size')) database.exec('ALTER TABLE emotes ADD COLUMN size INTEGER DEFAULT 100');
-- } catch (e) { console.warn('[DB] emotes size/channel_owner_id migration:', e.message); }
--
-- // Migrate: emote code uniqueness is PER-CHANNEL, not per-user. The old UNIQUE(user_id, code)
-- // wrongly blocked a user from reusing a code on a different channel. Rebuild the table without
-- // that constraint and enforce uniqueness on (effective channel, code) via an index, where the
-- // effective channel = channel_owner_id (viewer upload) or the user's own id (own channel).
-- try {
-- const tbl = get("SELECT sql FROM sqlite_master WHERE type='table' AND name='emotes'");
-- if (tbl && /UNIQUE\s*\(\s*user_id\s*,\s*code\s*\)/i.test(tbl.sql)) {
-- const rebuild = database.transaction(() => {
-- database.exec(`CREATE TABLE emotes_new (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- code TEXT NOT NULL,
-- url TEXT NOT NULL,
-- animated INTEGER DEFAULT 0,
-- width INTEGER DEFAULT 28,
-- height INTEGER DEFAULT 28,
-- is_global INTEGER DEFAULT 0,
-- is_approved INTEGER DEFAULT 1,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- channel_owner_id INTEGER,
-- size INTEGER DEFAULT 100,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- )`);
-- database.exec(`INSERT INTO emotes_new (id, user_id, code, url, animated, width, height, is_global, is_approved, created_at, channel_owner_id, size)
-- SELECT id, user_id, code, url, animated, width, height, is_global, is_approved, created_at, channel_owner_id, size FROM emotes`);
-- database.exec('DROP TABLE emotes');
-- database.exec('ALTER TABLE emotes_new RENAME TO emotes');
-- database.exec('CREATE INDEX idx_emotes_user ON emotes(user_id)');
-- database.exec('CREATE INDEX idx_emotes_global ON emotes(is_global)');
-- database.exec('CREATE INDEX idx_emotes_code ON emotes(code)');
-- database.exec('CREATE INDEX idx_emotes_channel_owner ON emotes(channel_owner_id)');
-- // Per-channel code uniqueness (channel = channel_owner_id, else the owner's own id).
-- database.exec('CREATE UNIQUE INDEX idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code)');
-- });
-- rebuild();
-- console.log('[DB] Rebuilt emotes table: code uniqueness is now per-channel');
-- }
-- } catch (e) { console.warn('[DB] emotes per-channel uniqueness migration:', e.message); }
--
try {
database.exec(`CREATE TABLE IF NOT EXISTS channel_sounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@@ -3864,7 +3814,7 @@ const HOME_SERIES = {
points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount), 0)', where: 'amount > 0' },
pointsSpent: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(-SUM(amount), 0)', where: 'amount < 0' },
redemptions: { table: 'coin_redemptions', ts: 'created_at', agg: 'COUNT(*)', where: "status NOT IN ('rejected', 'refunded')" },
-- // No `emotes` series: OpenVibe.Chat owns that table (roadmap T3) and Live's copy is unread
++ // No `emotes` series: OpenVibe.Chat owns that table (roadmap T3) and Live's copy is dropped
// since N+2 (unlike the table itself, which N-1 still prepares against — see schema.sql).
};
const HOME_SERIES_KEYS = [...Object.keys(HOME_SERIES), 'liveNow', 'viewersNow'];
@@@ -4054,7 +4004,7 @@ function _computeHomeStats() {
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
hours: null, // OpenVibe.Media
streamers: streamersWin(),
-- // Live's `emotes` copy is unread since N+2 (the series is gone; stats.emotes is null), so the
++ // Live's `emotes` copy was unread since N+2 and is now dropped, so the
// deltas are zero. The key stays for the N-1 client, which reads stats.recent.emotes.
emotes: { d: 0, w: 0, m: 0, pw: 0 },
goals: winCount('donation_goals', 'created_at'),
ddiff --git a/server/db/migrations.js b/server/db/migrations.js
iindex 32d74cfd..641b61d3 100644
---- a/server/db/migrations.js
++++ b/server/db/migrations.js
@@@ -106,10 +106,8 @@ const MIGRATIONS = [
{
id: '005_drop_chat_staged_tables',
// Roadmap T3 N+2. OpenVibe.Chat owns these seven and has since 14:46 UTC; Live's copies are
-- // unread since N+1 (8654ef6, recorded in test/fixtures/n-1/worker.json) and are dropped here.
-- // `emotes` is deliberately left: N-1 still prepares two COUNT(*) against it, so it goes next
-- // release (test/chat-staged-tables.test.js). On a fresh database none of the seven exists and
-- // the migration is adopted.
++ // unread since N+1 (8654ef6) and are dropped here.
++ // On a fresh database none of the seven exists and the migration is adopted.
adopt: (db) => ['channel_moderators', 'channel_moderation_settings', 'user_tags',
'chat_ai_summaries', 'chat_timeline_events', 'chat_staged_outbox', 'chat_dual_read_stats']
.every((t) => !tableExists(db, t)),
@@@ -131,6 +129,12 @@ const MIGRATIONS = [
`);
},
},
++ {
++ id: '006_drop_emotes',
++ // T3 N+3: Chat owns emotes. The production N-1 release fb3957f no longer reads Live's copy.
++ adopt: (db) => !tableExists(db, 'emotes'),
++ up: (db) => db.exec('DROP TABLE IF EXISTS emotes'),
++ },
];
/**
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex c42ee336..a357c8a4 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -1,9 +1,6 @@
-- OpenVibe.Live Database Schema
-- SQLite3
---- emotes: unread since T3 N+2; dropped in the next release (OpenVibe.Chat owns it; N-1 still
---- prepares two COUNT(*) against it — see test/chat-staged-tables.test.js).
--
-- Users
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@@ -547,28 +544,6 @@ CREATE INDEX IF NOT EXISTS idx_comments_content ON comments(content_type, conten
CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
---- Custom emotes (per-channel or global)
--CREATE TABLE IF NOT EXISTS emotes (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL, -- owner / uploader
-- code TEXT NOT NULL, -- trigger word, e.g. 'openvibeWave'
-- url TEXT NOT NULL, -- served path or external URL
-- animated INTEGER DEFAULT 0,
-- width INTEGER DEFAULT 28,
-- height INTEGER DEFAULT 28,
-- is_global INTEGER DEFAULT 0, -- admin-uploaded global emotes
-- is_approved INTEGER DEFAULT 1,
-- channel_owner_id INTEGER, -- streamer whose channel this emote belongs to (NULL = uploader's own channel)
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- UNIQUE(user_id, code),
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
--);
--CREATE INDEX IF NOT EXISTS idx_emotes_user ON emotes(user_id);
--CREATE INDEX IF NOT EXISTS idx_emotes_global ON emotes(is_global);
--CREATE INDEX IF NOT EXISTS idx_emotes_code ON emotes(code);
---- NOTE: idx_emotes_channel_owner is created by the runtime migration in database.js,
---- after the channel_owner_id column is ALTER-added on pre-existing databases.
--
-- Per-channel viewer-uploadable sound commands (triggered by !name in chat)
CREATE TABLE IF NOT EXISTS channel_sounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex cee9bd7a..70e58a9d 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -67,6 +67,11 @@ const ulid = () => ids.ulid();
assert.ok(NETWORK_TOPICS.includes('network.account.export_requested') && NETWORK_TOPICS.includes('network.account.deleted'), 'the subscription asks for both');
const cols = accountData.personColumns(d);
for (const t of accountData.FROZEN) assert.ok(!cols.has(t), `${t} is never touched`);
++ const existing = new Set(d.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
++ for (const t of cols.keys()) assert.ok(existing.has(t), `account export/deletion references missing table ${t}`);
++ assert.ok(!existing.has('emotes'), 'Live has dropped its emotes table');
++ assert.ok(!fs.readFileSync(path.join(__dirname, '..', 'server', 'auth', 'account-data.js'), 'utf8').match(/CHAT_TABLES\s*=\s*new Set\([^)]*['"]emotes['"]/s),
++ 'account deletion must not list the dropped emotes table');
// ── Export ──
const exp = envelope('network.account.export_requested', { export_id: `exp_${ulid()}`, subject: DANA, requested_at: new Date().toISOString(), deadline: new Date(Date.now() + 1800000).toISOString() });
@@@ -76,6 +81,10 @@ const ulid = () => ids.ulid();
const part = sent[0].body;
assert.ok(validate('network.account-export-part@1', part).valid, JSON.stringify(validate('network.account-export-part@1', part).errors));
const byName = Object.fromEntries(part.files.map((f) => [f.name, f.content]));
++ const exportTables = new Set(d.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
++ for (const name of Object.keys(byName)) if (name !== 'profile.json' && name !== 'other.json') {
++ assert.ok(exportTables.has(name.replace(/\.json$/, '')), `account export references missing table ${name}`);
++ }
assert.strictEqual(byName['profile.json'].username, 'dana');
assert.deepStrictEqual(byName['follows.json'].map((f) => [f.follower_id, f.streamer_id]).sort(), [[20, 21], [22, 20]]);
assert.ok(byName['transactions.json'] && byName['payment_orders.json'], 'money history is theirs to see');
ddiff --git a/test/chat-staged-tables.test.js b/test/chat-staged-tables.test.js
iindex eeb6b9f3..9f9265c2 100644
---- a/test/chat-staged-tables.test.js
++++ b/test/chat-staged-tables.test.js
@@@ -1,40 +1,27 @@
'use strict';
--// The staged chat tables are OpenVibe.Chat's (roadmap T3). This release (N+2) dropped Live's copies
--// of seven of them — channel_moderators, channel_moderation_settings, user_tags, chat_ai_summaries,
--// chat_timeline_events, chat_staged_outbox and chat_dual_read_stats — in the boot migration
--// 005_drop_chat_staged_tables (server/db/migrations.js). `emotes` stays one more release because the
--// N-1 release's SQL still prepares two COUNT(*) against it (test/fixtures/n-1/worker.json), but
--// nothing reads it: the home hero's "emotes created" series is gone and its recent figures are zero
--// (kept for the N-1 client). Like the frozen vods/clips tables (test/frozen-tables.test.js), no
--// server/ or scripts/ code may READ or WRITE any of the dropped tables; the one allowed read is the
--// emotes rebuild that is schema upkeep and goes with the table next release.
++// The staged chat tables are OpenVibe.Chat's (roadmap T3). Migration 005 dropped seven Live copies
++// in N+2; migration 006 drops the unread emotes copy in N+3. No server/ or scripts/ code may read
++// or write any of them. The N-1 release fb3957f no longer reads Live's emotes table.
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
++const Database = require('better-sqlite3');
const ROOT = path.join(__dirname, '..');
--// Dropped by 005_drop_chat_staged_tables. `emotes` is deliberately not here (kept one release).
const DROPPED = [
'channel_moderators', 'channel_moderation_settings', 'user_tags',
-- 'chat_ai_summaries', 'chat_timeline_events', 'chat_staged_outbox', 'chat_dual_read_stats',
++ 'chat_ai_summaries', 'chat_timeline_events', 'chat_staged_outbox', 'chat_dual_read_stats', 'emotes',
];
const T = DROPPED.join('|');
const WRITE = new RegExp(`\\b(INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|UPDATE|DELETE\\s+FROM|REPLACE\\s+INTO)\\s+(?:main\\.)?(${T})\\b`, 'i');
const READ_SQL = new RegExp(`\\b(FROM|JOIN)\\s+[\`"']?(?:main\\.)?(${T})\\b`, 'gi');
const READ_REGISTRY = new RegExp(`\\btable\\s*:\\s*[\`"'](${T})[\`"']`, 'g');
--
--// Reads of the one kept table under server/: only the schema upkeep must remain.
--const EMOTES_READ_SQL = /\b(FROM|JOIN)\s+[`"']?(?:main\.)?emotes\b/gi;
--const EMOTES_READ_REGISTRY = /\btable\s*:\s*[`"']emotes[`"']/g;
--// { file, snippet, reason }: each must be a real, current read (a stale entry fails the test).
--const ALLOWED_EMOTES_READS = [
-- { file: path.join('server', 'db', 'database.js'), snippet: 'FROM emotes', reason: 'the one-time pre-existing-database emotes rebuild (per-channel code uniqueness) is schema upkeep; it goes with the table next release' },
--];
++const EMOTES_SCHEMA_ACCESS = /\b(?:CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?|ALTER\s+TABLE\s+|PRAGMA\s+table_info\s*\(\s*)[`"']?emotes\b/i;
function walk(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
@@@ -64,14 +51,14 @@ function findReads(src, regexes) {
}
// The scanner itself: it has to catch the shapes the deleted machinery used, and leave schema
--// upkeep (CREATE/DROP/ALTER) and comments alone.
++// upkeep (DROP) and comments alone.
for (const [src, regexes, n] of [
["db.get('SELECT * FROM chat_ai_summaries WHERE scope = ?', [scope])", [READ_SQL, READ_REGISTRY], 1],
["db.all(`SELECT cm.id FROM chat_messages cm LEFT JOIN channel_moderators cm2 ON cm2.channel_id = ?`)", [READ_SQL, READ_REGISTRY], 1],
-- ["const S = { emotes: { table: 'emotes', ts: 'created_at', agg: 'COUNT(*)' } };", [EMOTES_READ_SQL, EMOTES_READ_REGISTRY], 1],
++ ["const S = { emotes: { table: 'emotes', ts: 'created_at', agg: 'COUNT(*)' } };", [READ_SQL, READ_REGISTRY], 1],
["db.run('INSERT INTO emote_media (id) VALUES (1)')", [READ_SQL, READ_REGISTRY], 0],
["db.run('INSERT INTO emotes (id, code) VALUES (1, \\'x\\')')", [READ_SQL, READ_REGISTRY], 0],
-- ["db.run('INSERT INTO emotes_new (id, code) SELECT id, code FROM emotes')", [EMOTES_READ_SQL, EMOTES_READ_REGISTRY], 1],
++ ["db.run('INSERT INTO emotes_new (id, code) SELECT id, code FROM emotes')", [READ_SQL, READ_REGISTRY], 1],
["db.run('UPDATE emotes SET code = ? WHERE id = ?')", [READ_SQL, READ_REGISTRY], 0],
["database.exec('CREATE TABLE IF NOT EXISTS chat_staged_outbox (seq INTEGER)');", [READ_SQL, READ_REGISTRY], 0],
["database.exec('DROP TABLE user_tags');", [READ_SQL, READ_REGISTRY], 0],
@@@ -79,44 +66,42 @@ for (const [src, regexes, n] of [
["db.all('SELECT * FROM stream_timeline_events WHERE stream_id = ?')", [READ_SQL, READ_REGISTRY], 0],
]) assert.strictEqual(findReads(src, regexes).length, n, `scanner on: ${src}`);
--// After boot the seven are gone, the migration is recorded, and `emotes` is still there for N-1.
++// Start with the old table so boot must drop it. A separate in-memory schema check covers fresh DBs.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-staged-'));
process.env.DB_PATH = path.join(tmp, 'live.db');
++const legacy = new Database(process.env.DB_PATH);
++legacy.exec('CREATE TABLE emotes (id INTEGER PRIMARY KEY, code TEXT)');
++legacy.close();
const db = require('../server/db/database');
db.initDb();
const d = db.getDb();
for (const t of DROPPED) {
assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t),
-- `${t} still exists after boot — 005_drop_chat_staged_tables did not drop it`);
++ `${t} still exists after boot`);
}
--assert.ok(d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'emotes'").get(),
-- 'emotes is kept one more release (N-1 still prepares two COUNT(*) against it)');
const rec = d.prepare('SELECT mode FROM schema_migrations WHERE id = ?').get('005_drop_chat_staged_tables');
assert.ok(rec, 'the 005_drop_chat_staged_tables migration is not recorded');
++assert.strictEqual(d.prepare('SELECT mode FROM schema_migrations WHERE id = ?').get('006_drop_emotes').mode, 'applied');
++const fresh = new Database(':memory:');
++fresh.exec(fs.readFileSync(path.join(ROOT, 'server', 'db', 'schema.sql'), 'utf8'));
++assert.ok(!fresh.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'emotes'").get(),
++ 'fresh schema must not create emotes');
++fresh.close();
const writes = [];
const reads = [];
--const emoteReads = [];
--const usedAllowances = new Set();
++const schemaAccess = [];
for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
const rel = path.relative(ROOT, file);
const src = fs.readFileSync(file, 'utf8');
src.split('\n').forEach((line, i) => {
if (WRITE.test(line) && !isCommentLine(line)) writes.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
++ if (EMOTES_SCHEMA_ACCESS.test(line) && !isCommentLine(line)) schemaAccess.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
});
for (const hit of findReads(src, [READ_SQL, READ_REGISTRY])) reads.push(`${rel}:${hit.line}: ${hit.text.slice(0, 120)}`);
-- for (const hit of findReads(src, [EMOTES_READ_SQL, EMOTES_READ_REGISTRY])) {
-- const ok = ALLOWED_EMOTES_READS.find((a) => a.file === rel && hit.text.includes(a.snippet));
-- if (ok) { usedAllowances.add(ok); continue; }
-- emoteReads.push(`${rel}:${hit.line}: ${hit.text.slice(0, 120)}`);
-- }
}
assert.deepStrictEqual(writes, [], `writes to Chat's dropped tables (OpenVibe.Chat owns them; write through Chat's API instead):\n${writes.join('\n')}`);
assert.deepStrictEqual(reads, [], `reads of Chat's dropped tables (read them through server/chat/moderation-client.js, or Chat's own API):\n${reads.join('\n')}`);
--assert.deepStrictEqual(emoteReads, [], `reads of \`emotes\` (only the schema upkeep may read it; it goes next release):\n${emoteReads.join('\n')}`);
--for (const a of ALLOWED_EMOTES_READS) {
-- assert.ok(a.reason && a.reason.length > 20, `allowed read without a reason: ${a.file}`);
-- assert.ok(usedAllowances.has(a), `stale allow-list entry (the read is gone, remove it): ${a.file}: ${a.snippet}`);
--}
--console.log(`chat staged tables: 005_drop_chat_staged_tables recorded (${rec.mode}) — ${DROPPED.length} tables dropped, emotes kept, ${ALLOWED_EMOTES_READS.length} allowed read(s)`);
++assert.deepStrictEqual(schemaAccess, [], `schema access to dropped emotes:\n${schemaAccess.join('\n')}`);
++console.log(`chat staged tables: 005 recorded (${rec.mode}), 006 applied — ${DROPPED.length} tables absent; no reads or writes`);
process.exit(0);
ddiff --git a/test/fixtures/destructive-migrations.json b/test/fixtures/destructive-migrations.json
iindex 3362c829..d8bdb52c 100644
---- a/test/fixtures/destructive-migrations.json
++++ b/test/fixtures/destructive-migrations.json
@@@ -5,8 +5,6 @@
{ "file": "server/db/database.js", "statement": "RENAME TO CHAT_MESSAGES", "count": 2, "contract": "the same rebuild" },
{ "file": "server/db/database.js", "statement": "DROP TABLE ROBOTSTREAMER_INTEGRATIONS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
{ "file": "server/db/database.js", "statement": "RENAME TO ROBOTSTREAMER_INTEGRATIONS", "count": 1, "contract": "the same rebuild" },
-- { "file": "server/db/database.js", "statement": "DROP TABLE EMOTES", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
-- { "file": "server/db/database.js", "statement": "RENAME TO EMOTES", "count": 1, "contract": "the same rebuild" },
{ "file": "server/db/database.js", "statement": "DROP TABLE CHANNEL_SOUNDS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
{ "file": "server/db/database.js", "statement": "RENAME TO CHANNEL_SOUNDS", "count": 1, "contract": "the same rebuild" },
{ "file": "server/db/database.js", "statement": "DROP TABLE STREAM_CONTROLS", "count": 1, "contract": "pre-ADR table rebuild keeping every row; guarded, one-time" },
@@@ -17,6 +15,7 @@
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_AI_SUMMARIES", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): OpenVibe.Chat owns the table and N-1 (8654ef6) no longer reads it (test/n-1.test.js); backups kept operator-side" },
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_TIMELINE_EVENTS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): OpenVibe.Chat owns the table and N-1 (8654ef6) no longer reads it (test/n-1.test.js); backups kept operator-side" },
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_STAGED_OUTBOX", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the staged-table relay was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_DUAL_READ_STATS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the dual-read machinery was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" }
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_DUAL_READ_STATS", "count": 1, "contract": "roadmap T3 N+2 (005_drop_chat_staged_tables): the dual-read machinery was deleted in N+1 and N-1 (8654ef6) no longer uses it; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS EMOTES", "count": 1, "contract": "roadmap T3 N+3 (006_drop_emotes): Chat owns emotes; N-1 fb3957f does not read its Live copy; backups kept operator-side" }
]
}
ddiff --git a/test/frozen-tables.test.js b/test/frozen-tables.test.js
iindex c58bd50d..9ac7cf81 100644
---- a/test/frozen-tables.test.js
++++ b/test/frozen-tables.test.js
@@@ -21,6 +21,8 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
const FROZEN = ['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments'];
++assert.ok(!/\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?emotes\b/i.test(fs.readFileSync(path.join(ROOT, 'server', 'db', 'schema.sql'), 'utf8')),
++ 'Chat-owned emotes must not return to Live schema');
const T = FROZEN.join('|');
// ── writes ──────────────────────────────────────────────────────────────────