openvibers/openvibe.live Commits
Drop Live emotes table after Chat handoff
Changes
@@@ -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'schat-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@@@ -46,7 +46,7 @@ const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dhconst 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 (@@@ -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.Mediastreamers: 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'),@@@ -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'),++ },];/**@@@ -1,9 +1,6 @@-- OpenVibe.Live Database Schema-- SQLite3---- UsersCREATE 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, contenCREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id);CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);--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);---- Per-channel viewer-uploadable sound commands (triggered by !name in chat)CREATE TABLE IF NOT EXISTS channel_sounds (id INTEGER PRIMARY KEY AUTOINCREMENT,@@@ -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');@@@ -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);@@@ -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" }]}@@@ -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 ──────────────────────────────────────────────────────────────────
@@ -39,7 +39,7 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
@@ -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)).
`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
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
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
## Features
@@ -46,7 +46,7 @@ const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dh
@@ -46,7 +46,7 @@ const SECRET_COL = /(^|_)(token|tokens|secret|hash|password|code|key|keys|p256dh
const ROW_LIMIT = 2000;
const ROW_LIMIT = 2000;
const PART_BUDGET = 18 * 1024 * 1024;
const PART_BUDGET = 18 * 1024 * 1024;
// OpenVibe.Chat owns these and erases them itself (roadmap T3); Live must not touch them.
// 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) {
function ensureSchema(d) {
d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
@@ -1567,56 +1567,6 @@ function initDb() {
@@ -1567,56 +1567,6 @@ function initDb() {
}
}
} catch (e) { console.warn('[DB] Role migration:', e.message); }
} 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 {
try {
database.exec(`CREATE TABLE IF NOT EXISTS channel_sounds (
database.exec(`CREATE TABLE IF NOT EXISTS channel_sounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -3864,7 +3814,7 @@ const HOME_SERIES = {
@@ -3864,7 +3814,7 @@ const HOME_SERIES = {
points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount), 0)', where: 'amount > 0' },
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' },
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')" },
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).
// 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'];
const HOME_SERIES_KEYS = [...Object.keys(HOME_SERIES), 'liveNow', 'viewersNow'];
@@ -4054,7 +4004,7 @@ function _computeHomeStats() {
@@ -4054,7 +4004,7 @@ function _computeHomeStats() {
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
hours: null, // OpenVibe.Media
hours: null, // OpenVibe.Media
streamers: streamersWin(),
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.
// 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 },
emotes: { d: 0, w: 0, m: 0, pw: 0 },
goals: winCount('donation_goals', 'created_at'),
goals: winCount('donation_goals', 'created_at'),
@@ -106,10 +106,8 @@ const MIGRATIONS = [
@@ -106,10 +106,8 @@ const MIGRATIONS = [
{
{
id: '005_drop_chat_staged_tables',
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
// 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',
adopt: (db) => ['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']
.every((t) => !tableExists(db, t)),
.every((t) => !tableExists(db, t)),
@@ -131,6 +129,12 @@ const MIGRATIONS = [
@@ -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'),
+ },
];
];
/**
/**
@@ -1,9 +1,6 @@
@@ -1,9 +1,6 @@
-- OpenVibe.Live Database Schema
-- OpenVibe.Live Database Schema
-- SQLite3
-- SQLite3
-
-- Users
-- Users
CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -547,28 +544,6 @@ CREATE INDEX IF NOT EXISTS idx_comments_content ON comments(content_type, conten
@@ -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_user ON comments(user_id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
-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);
-
-- Per-channel viewer-uploadable sound commands (triggered by !name in chat)
-- Per-channel viewer-uploadable sound commands (triggered by !name in chat)
CREATE TABLE IF NOT EXISTS channel_sounds (
CREATE TABLE IF NOT EXISTS channel_sounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -67,6 +67,11 @@ const ulid = () => ids.ulid();
@@ -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');
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);
const cols = accountData.personColumns(d);
for (const t of accountData.FROZEN) assert.ok(!cols.has(t), `${t} is never touched`);
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 ──
// ── 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() });
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();
@@ -76,6 +81,10 @@ const ulid = () => ids.ulid();
const part = sent[0].body;
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));
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 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.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.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');
assert.ok(byName['transactions.json'] && byName['payment_orders.json'], 'money history is theirs to see');
@@ -1,40 +1,27 @@
@@ -1,40 +1,27 @@
'use strict';
'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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
+const Database = require('better-sqlite3');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
-// Dropped by 005_drop_chat_staged_tables. `emotes` is deliberately not here (kept one release).
const DROPPED = [
const DROPPED = [
'channel_moderators', 'channel_moderation_settings', 'user_tags',
'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 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 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_SQL = new RegExp(`\\b(FROM|JOIN)\\s+[\`"']?(?:main\\.)?(${T})\\b`, 'gi');
const READ_REGISTRY = new RegExp(`\\btable\\s*:\\s*[\`"'](${T})[\`"']`, 'g');
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 = []) {
function walk(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -64,14 +51,14 @@ function findReads(src, regexes) {
@@ -64,14 +51,14 @@ function findReads(src, regexes) {
}
}
// The scanner itself: it has to catch the shapes the deleted machinery used, and leave schema
// 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 [
for (const [src, regexes, n] of [
["db.get('SELECT * FROM chat_ai_summaries WHERE scope = ?', [scope])", [READ_SQL, READ_REGISTRY], 1],
["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],
["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 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 (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],
["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('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],
["database.exec('DROP TABLE user_tags');", [READ_SQL, READ_REGISTRY], 0],
@@ -79,44 +66,42 @@ for (const [src, regexes, n] of [
@@ -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],
["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}`);
]) 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-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-staged-'));
process.env.DB_PATH = path.join(tmp, 'live.db');
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');
const db = require('../server/db/database');
db.initDb();
db.initDb();
const d = db.getDb();
const d = db.getDb();
for (const t of DROPPED) {
for (const t of DROPPED) {
assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t),
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');
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.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 writes = [];
const reads = [];
const reads = [];
-const emoteReads = [];
-const usedAllowances = new Set();
+const schemaAccess = [];
for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
const rel = path.relative(ROOT, file);
const rel = path.relative(ROOT, file);
const src = fs.readFileSync(file, 'utf8');
const src = fs.readFileSync(file, 'utf8');
src.split('\n').forEach((line, i) => {
src.split('\n').forEach((line, i) => {
if (WRITE.test(line) && !isCommentLine(line)) writes.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
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, [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(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(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);
process.exit(0);
@@ -5,8 +5,6 @@
@@ -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": "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": "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": "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": "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": "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" },
{ "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 @@
@@ -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_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_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_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" }
]
]
}
}
@@ -21,6 +21,8 @@ const path = require('path');
@@ -21,6 +21,8 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const FROZEN = ['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments'];
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('|');
const T = FROZEN.join('|');
// ── writes ──────────────────────────────────────────────────────────────────
// ── writes ──────────────────────────────────────────────────────────────────