openvibers/openvibe.live Commits

Drop Live's twelve legacy chat tables at boot (007_drop_chat_tables, owner-approved); N-1 fixtures re-recorded from 1d49484e, the release now in production (#34)

OP OpenVibers committed 5 Oct 2026 · 2176be56a7 · 16 files, +174 −423

## Changes (file:line)

- `server/db/migrations.js:139` — new boot migration `007_drop_chat_tables` (modeled on 005/006): `adopt` when none of the twelve exist (`:149`), `up` drops their indexes then the tables in FK order (`dm_participants`, `dm_messages` before `dm_conversations`).
- `server/db/migrations.js:212` — removed the superseded operator `op_003_drop_chat_tables`; `OPERATOR_MIGRATIONS` now ends at `op_002`. **Removed, not kept as a no-op:** the ledger has no invariant tying ids to the current list — `run()`/`getStatus()` only walk the passed list (an old `op_003` ledger row is simply ignored) and `runOperator()` throws only for an id not in the list, which nothing calls once the script is gone.
- `scripts/chat-tables-drop.js` — deleted (it only wrapped op_003).
- `server/auth/account-data.js:36` — `moderation_actions` removed from `RETAIN`; `:40` — `chat_messages.user_id` removed from `OVERRIDE_DELETE`; `:48` — `LEGACY_CHAT_COPIES` constant and its only use in `exportPart` removed.
- `test/chat-tables-dropped.test.js:17,85–108` — rewritten: static guard (nothing under `server/` names the twelve except the drop's own `DROP` lines), fresh schema has none, boot migration drops all twelve seeded in the test (`applied`), is adopted on a fresh database, and op_003 is gone.
- `test/account-data.test.js` — removed the legacy-copy table setup and the `chat_messages`/`pending_ip_messages` erasure assertions.
- `test/fixtures/destructive-migrations.json:17–28` — the twelve `DROP TABLE IF EXISTS` contracts now name `007_drop_chat_tables` and say the drop runs at boot.
- `docs/cutover-chat-tables-003.md` — rewritten: drop now runs at boot via `007_drop_chat_tables`; op_003/script removal explained; backup, verification, rollback, rehearsal sections kept.
- `docs/chat-system.md:78–82`, `README.md:78`, `AGENTS.md:7` — updated to say the twelve are dropped at boot by `007_drop_chat_tables` (script gone).
- Comment-only id updates: `server/chat/chat-reads.js:9`, `server/chat/chat-authority.js:15`, `test/chat-context.test.js:232`, `test/chat-history-store.test.js:5`.
- `test/fixtures/n-1/{client,worker}.json` — re-recorded from HEAD `1d49484e` (the production release); previous was `2d0fa0e`.

## What the re-recorded fixtures changed
- `worker.json` release `2d0fa0e` → `1d49484e`; schema objects 230 → 197, statements 931 → 819, lazy unchanged at 18.
- Statements naming one of the twelve: **91 → 0**; schema objects naming them: **12 → 0**. `client.json` still 461 calls, and now 0 references (it already had none).

## Test results
`nice -n 15 ov test --no-cache <files>` (Node 22.23.3, 3 shards, 14 s):
- ✓ `test/n-1.test.js`, ✓ `test/destructive-migrations.test.js`, ✓ `test/chat-tables-dropped.test.js`, ✓ `test/account-data.test.js`, ✓ `test/frozen-tables.test.js`, ✓ `test/chat-staged-tables.test.js`, ✓ `test/migrations.test.js`
- ✗ `test/drill-mode.test.js` — **fails on origin/main too** (the harness confirmed it); the failing check is the unrelated `refuses a symlink that leads into the checkout`, pre-existing.

No commits or pushes made.

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex f1189ebe..ecfcbf2e 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -4,7 +4,7 @@
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, SQLite (better-sqlite3). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
--**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped by the operator migration `op_003_drop_chat_tables` (`scripts/chat-tables-drop.js`) once the release before is out of rollback range (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
++**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (`MEDIA_EVENTS_AUTHORITY`; the older `POST /internal/media-webhook` stays during the transition). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the local `comments` table is READ-ONLY too; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes.
## Commands
ddiff --git a/README.md b/README.md
iindex 67941bb5..0e2006b6 100644
---- a/README.md
++++ b/README.md
@@@ -75,7 +75,7 @@ The media subsystem lives in **OpenVibe.Media**:
## Does not own
- **Identity, sessions, follows, blocks, notifications, OpenCoins**: OpenVibe.Network. Live verifies its RS256 tokens offline and follows `network.user.*`, `network.follow.*` and deletion, export and merge events.
--- **Chat, its messages and every chat table**: OpenVibe.Chat (since 2026-09-23). Live keeps no copy: reads and writes go through `server/chat/chat-reads.js` and `chat-delivery.js`, and the twelve tables Live still held are dropped by the operator migration `op_003_drop_chat_tables` (`scripts/chat-tables-drop.js`).
++- **Chat, its messages and every chat table**: OpenVibe.Chat (since 2026-09-23). Live keeps no copy: reads and writes go through `server/chat/chat-reads.js` and `chat-delivery.js`, and the twelve tables it still held are dropped at boot by migration `007_drop_chat_tables`.
- **Recordings, clips, thumbnails, files**: OpenVibe.Media. The local `vods`/`clips`/`pastes` tables are frozen (`test/frozen-tables.test.js`).
- **Pastes and comments**: OpenVibe.Community.
- **AI**: OpenVibe.AI. Live calls no model provider itself. Every prompt is an AI template; streamers' own keys are stored there; VOD and clip transcripts run there. Only live-stream captions still use Live's whisper.cpp.
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 172131a0..72ec58f9 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -76,10 +76,10 @@ path are deleted.
`dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`, `tts_voice_overrides`,
`channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`
and `moderation_actions`. Chat imported them with ids kept at the cutover and has been their only
-- writer since; Live's readers and writers are gone (#28–#32 and this release), and it no longer creates
-- them. The drop itself is the operator migration `op_003_drop_chat_tables` via
-- [scripts/chat-tables-drop.js](../scripts/chat-tables-drop.js) (dry run, then `--apply`: backup, drop),
-- run once the release before is out of rollback range (ADR-028) — see
++ writer since; Live's readers and writers are gone (#28–#32 and the release that carries this, #33)
++ and it no longer creates them. #33 is in production and its N-1 fixtures no longer run SQL over the
++ tables ([test/n-1.test.js](../test/n-1.test.js)), so the drop is boot migration `007_drop_chat_tables`
++ ([server/db/migrations.js](../server/db/migrations.js)) (ADR-028) — see
[docs/cutover-chat-tables-003.md](cutover-chat-tables-003.md). The deployment notice is Chat's too: Live
queues `live.release.deployed` and Chat folds and stores the rolling card. `test/chat-tables-dropped.test.js`
guards that nothing under `server/` names them.
ddiff --git a/docs/cutover-chat-tables-003.md b/docs/cutover-chat-tables-003.md
iindex 80886c45..53888ba9 100644
---- a/docs/cutover-chat-tables-003.md
++++ b/docs/cutover-chat-tables-003.md
@@@ -1,70 +1,71 @@
--# Cutover: drop Live's copies of OpenVibe.Chat's chat tables (T3 final step, operator migration)
++# Cutover: drop Live's copies of OpenVibe.Chat's chat tables (T3 final step, boot migration)
--This change removes Live's last references to the twelve chat tables OpenVibe.Chat owns —
--`chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`,
--`tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`,
--`stream_first_chats` and `moderation_actions` — stops creating them, and adds the operator migration
--**`op_003_drop_chat_tables`** ([server/db/migrations.js](../server/db/migrations.js)) plus the script
--that runs it, [scripts/chat-tables-drop.js](../scripts/chat-tables-drop.js).
++Live keeps no references to the twelve chat tables OpenVibe.Chat owns — `chat_messages`,
++`dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`, `tts_voice_overrides`,
++`channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`
++and `moderation_actions` — and does not create them. Boot migration **`007_drop_chat_tables`**
++([server/db/migrations.js](../server/db/migrations.js)) drops them, with their indexes, on the next
++boot after deploy.
Chat imported these tables with ids kept at the 2026-09-23 cutover and has been their only writer
--since; Live's readers and writers are gone (PRs #28–#32 and the release that carries this migration).
--Unlike the boot migrations, `op_003` is **not** applied on boot: the release before this one still runs
--SQL over the tables (test/n-1.test.js), so they may only be dropped once that release is out of
--rollback range (ADR-028, `test/fixtures/destructive-migrations.json`). The tables are Live's own
--SQLite tables in `live.db`, not the shared PostgreSQL.
++since; Live's readers and writers are gone (PRs #28–#32 and the release that carries this migration,
++#33). #33 is the production release, and its N-1 fixtures no longer run SQL over the tables
++([test/n-1.test.js](../test/n-1.test.js)), so — unlike the earlier operator step `op_003` — the drop
++is an ordinary boot migration (ADR-028, `test/fixtures/destructive-migrations.json`). The tables are
++Live's own SQLite tables in `live.db`, not the shared PostgreSQL.
++
++The previous operator migration `op_003_drop_chat_tables` and its script
++[scripts/chat-tables-drop.js](../scripts/chat-tables-drop.js) are removed: they existed only while
++the release before still read the tables. An operator who already ran `op_003` needs nothing — the
++tables are gone, so `007` is adopted; a database that never ran it drops them on the next boot. An
++`op_003_drop_chat_tables` row left in `schema_migrations` is harmless (the ledger ignores ids no
++current migration uses).
## Order
--1. Confirm this release is the running one and has been through the rollback window:
-- `ov access run openvibe-ovh health live` answers `ready` and the release list shows it as current
-- (the previous release no longer in rollback range).
--2. From the release directory as the service user, dry run: `node scripts/chat-tables-drop.js` — it
-- prints which of the twelve tables exist and how many rows each holds; changes nothing.
--3. `node scripts/chat-tables-drop.js --apply` — writes
-- `<database dir>/backups/live-pre-chat-tables-drop-<time>.db` (mode 0600) and runs `op_003`, which
-- drops the twelve tables and their indexes. The ledger outcome is printed (`applied`, or `already`
-- on a repeat).
++1. Deploy this release. `initDb()` runs `007_drop_chat_tables` on the first boot and records
++ `applied` (or `adopted` when none of the twelve exists, e.g. a fresh database or one where
++ `op_003` already ran).
++2. `ov access run openvibe-ovh health live` answers `ready` and a restart prints no `no such table`
++ for any of the twelve. No operator action is needed; there is no script to run.
## Backup
--- The script takes its own `live.db` backup before it drops anything and prints the path. Restoring
-- that file is the way back to a database that still holds the rows.
--- The host's wider snapshots (`ov access run openvibe-ovh db-backup`) are an alternative. The rows
-- themselves live in OpenVibe.Chat, which imported them with ids kept — the backup is the operator's
-- safety net, not their home.
++- The host's snapshots (`ov access run openvibe-ovh db-backup`) taken before the deploy are the way
++ back to a database that still holds the rows. Taking one before deploying this release is
++ recommended, as for any contract step.
++- The rows themselves live in OpenVibe.Chat, which imported them with ids kept — the backup is the
++ operator's safety net, not their home.
## How to check success
--- **Drop applied.** `--apply` prints `op_003_drop_chat_tables: applied`, and the read-only check on the
-- host prints nothing for the tables and `applied` for the ledger row:
++- **Drop applied.** The read-only check on the host prints nothing for the tables and `applied` for
++ the ledger row:
```sh
sqlite3 -readonly <Live's data dir>/live.db \
"SELECT name FROM sqlite_master WHERE name IN ('chat_messages','dm_conversations','dm_participants','dm_messages','dm_blocks','tts_voice_overrides','channel_sounds','relay_users','hidden_relay_users','pending_ip_messages','stream_first_chats','moderation_actions')" # → nothing
sqlite3 -readonly <Live's data dir>/live.db \
-- "SELECT id, mode FROM schema_migrations WHERE id = 'op_003_drop_chat_tables'" # → op_003_…|applied
++ "SELECT id, mode FROM schema_migrations WHERE id = '007_drop_chat_tables'" # → 007_drop_chat_tables|applied
```
--- **Nothing else broke.** `ov access run openvibe-ovh health live` answers `ready`; a restart prints no
-- `no such table` for any of the twelve, home stats and the admin chat console still answer (from
-- Chat), and a chat line still reaches the watch page.
--- **Idempotent.** Rerunning `--apply` prints `already` and drops nothing further.
++- **Nothing else broke.** `ov access run openvibe-ovh health live` answers `ready`; home stats and the
++ admin chat console still answer (from Chat), and a chat line still reaches the watch page.
++- **Idempotent.** A later boot keeps the ledger row and drops nothing further.
## Rollback / restore
--Dropping the tables is destructive and a code rollback does not bring them back — the previous release
--runs fine without them (it reads them only on the paths production does not take). Restore only if the
--backup turns out to be needed: roll back the release (`ov access run openvibe-ovh rollback live`), stop
--Live, copy the script's backup (or the host `db-backup`) `live.db` over Live's database as the service
--user with the original owner and mode and no stale `-wal`/`-shm` beside it, and start Live. Restoring
--also discards every other Live write made since the backup, so prefer leaving the tables dropped.
++Dropping the tables is destructive and a code rollback does not bring them back — the previous
++release runs fine without them (it reads them only on paths production does not take). Restore only
++if the backup turns out to be needed: roll back the release (`ov access run openvibe-ovh rollback
++live`), stop Live, copy the host `db-backup` `live.db` over Live's database as the service user with
++the original owner and mode and no stale `-wal`/`-shm` beside it, and start Live. Restoring also
++discards every other Live write made since the backup, so prefer leaving the tables dropped.
## Rehearsal
[test/chat-tables-dropped.test.js](../test/chat-tables-dropped.test.js) checks the static guard
--(nothing under `server/` names any of the twelve, the drop migration's own statements excepted), that a
--fresh schema creates none of them, that a boot neither creates nor drops them, and that `op_003`
--drops legacy copies and is idempotent on a second run. `test/n-1.test.js` proves the previous release
--still prepares every statement it runs on a database this release migrated (which is why the drop must
--not run at boot).
++(nothing under `server/` names any of the twelve, the drop migration's own `DROP` statements
++excepted), that a fresh schema creates none of them, and that the boot migration drops legacy copies
++on a database that has them (`applied`) and is adopted on a fresh one. `test/n-1.test.js` proves the
++previous release still prepares every statement it runs on a database this release migrated.
ddiff --git a/scripts/chat-tables-drop.js b/scripts/chat-tables-drop.js
ddeleted file mode 100644
iindex b56adda6..00000000
---- a/scripts/chat-tables-drop.js
++++ /dev/null
@@@ -1,97 +0,0 @@
--#!/usr/bin/env node
--/**
-- * The contract step for Live's copies of OpenVibe.Chat's chat tables (roadmap T3, final step): Live keeps no
-- * copy of chat_messages, the DM tables, or the moderation/relay/sound/TTS queues any more (the readers and
-- * writers went in #28–#32 and this release no longer creates them), but the release before this one still
-- * runs SQL over them (test/n-1.test.js), so the drop is operator-run. Run it once that release is out of
-- * rollback range.
-- *
-- * node scripts/chat-tables-drop.js [--db <live.db>]
-- * # dry run (the default): which of the twelve tables exist, with row counts; changes nothing
-- * node scripts/chat-tables-drop.js --apply [--backup <file.db>] [--db <live.db>]
-- * # an online backup first (default <database dir>/backups/live-pre-chat-tables-drop-<time>.db, mode
-- * # 0600), then server/db/migrations.js operator migration op_003_drop_chat_tables, which drops the
-- * # twelve tables and their indexes. Idempotent: a second run reports 'already'.
-- *
-- * The rows in them came from OpenVibe.Chat (its scripts/import-from-live.js imported them with ids kept at
-- * the 2026-09-23 cutover, and Chat has been their only writer since) and Live has read none of them since
-- * #32; the backup is the operator's safety net, not a second home for the data.
-- * Default database: $DB_PATH, else data/live.db under $DATA_DIR or the working directory (server/paths.js).
-- * In production run it from /opt/openvibe.live/current as the service user (data -> ../../shared/data).
-- */
--'use strict';
--const fs = require('fs');
--const path = require('path');
--
--const ID = 'op_003_drop_chat_tables';
--const TABLES = [
-- 'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks',
-- 'tts_voice_overrides', 'channel_sounds', 'relay_users', 'hidden_relay_users',
-- 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',
--];
--
--function parseArgs(argv) {
-- const opts = { mode: 'dry', db: null, backup: null, help: false };
-- for (let i = 0; i < argv.length; i++) {
-- const a = argv[i];
-- const next = () => {
-- const v = argv[++i];
-- if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);
-- return v;
-- };
-- if (a === '--apply') opts.mode = 'apply';
-- else if (a === '--dry-run') opts.mode = 'dry';
-- else if (a === '--db') opts.db = next();
-- else if (a === '--backup') opts.backup = next();
-- else if (a === '--help' || a === '-h') opts.help = true;
-- else throw new Error(`unknown option ${a}`);
-- }
-- return opts;
--}
--
--/** Which of the twelve exist, and how many rows each holds. Reads only. */
--function counts(db) {
-- const present = [];
-- for (const t of TABLES) {
-- if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t)) continue;
-- present.push({ table: t, rows: db.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n });
-- }
-- return present;
--}
--
--function report(present, log) {
-- if (!present.length) { log('none of the twelve chat tables is present; the drop is already applied (or these are fresh-schema databases)'); return; }
-- log(`${present.length} of ${TABLES.length} chat table(s) present:`);
-- for (const p of present) log(` ${p.table}: ${p.rows} row(s)`);
--}
--
--async function backup(db, file) {
-- fs.mkdirSync(path.dirname(file), { recursive: true });
-- await db.backup(file);
-- fs.chmodSync(file, 0o600);
-- return file;
--}
--
--async function main(argv = process.argv.slice(2), log = console.log) {
-- const opts = parseArgs(argv);
-- if (opts.help) { log(fs.readFileSync(__filename, 'utf8').split('*/')[0]); return 0; }
-- const Database = require('better-sqlite3');
-- const DB_PATH = path.resolve(opts.db || require('../server/paths').dbPath());
-- const db = new Database(DB_PATH, { readonly: opts.mode === 'dry', fileMustExist: true });
-- try {
-- report(counts(db), log);
-- if (opts.mode === 'dry') return 0;
-- const file = opts.backup || path.join(path.dirname(DB_PATH), 'backups', `live-pre-chat-tables-drop-${new Date().toISOString().replace(/[:.]/g, '-')}.db`);
-- log(`backup: ${await backup(db, file)}`);
-- const res = require('../server/db/migrations').runOperator(db, ID);
-- log(`${ID}: ${res.outcome}${res.error ? ` (${res.error})` : ''}`);
-- if (res.outcome !== 'failed') report(counts(db), log);
-- return res.outcome === 'failed' ? 1 : 0;
-- } finally { db.close(); }
--}
--
--if (require.main === module) {
-- main().then((code) => process.exit(code), (err) => { console.error(err.message); process.exit(1); });
--}
--
--module.exports = { ID, TABLES, counts, parseArgs, main };
ddiff --git a/server/auth/account-data.js b/server/auth/account-data.js
iindex 34a021ac..8f043643 100644
---- a/server/auth/account-data.js
++++ b/server/auth/account-data.js
@@@ -33,11 +33,11 @@ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;
const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;
const FROZEN = new Set(['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'comments']);
--const RETAIN = new Set(['payment_orders', 'subscriptions', 'transactions', 'coin_transactions', 'channel_points_log', 'moderation_actions', 'bans',
++const RETAIN = new Set(['payment_orders', 'subscriptions', 'transactions', 'coin_transactions', 'channel_points_log', 'bans',
'token_revocations', 'verification_keys', 'subject_merges', 'account_data_events']);
--// Rows the schema keeps without the person (SET NULL) that are theirs to erase all the same: sign-in IPs, VPN
--// requests, and their lines in the legacy chat_messages copy while it still exists (see LEGACY_CHAT_COPIES).
--const OVERRIDE_DELETE = new Set(['chat_messages.user_id', 'ip_log.user_id', 'vpn_approvals.user_id']);
++// Rows the schema keeps without the person (SET NULL) that are theirs to erase all the same: sign-in IPs and
++// VPN requests.
++const OVERRIDE_DELETE = new Set(['ip_log.user_id', 'vpn_approvals.user_id']);
// Copies of the person's name kept beside a person column: cleared wherever the row stays.
const NAME_COLS = ['username', 'display_name', 'user_name', 'sender_name', 'sender_username', 'author_name', 'author_username', 'avatar_url', 'user_avatar'];
// Person columns the schema declares without a foreign key.
@@@ -47,11 +47,6 @@ 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 (dropped in N+2/N+3).
const CHAT_TABLES = new Set(['channel_moderators', 'channel_moderation_settings', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events']);
--// Live's legacy copies of the twelve OpenVibe.Chat tables (a read mirror until 2026-10-05; dropped by
--// op_003_drop_chat_tables). Never exported (Chat contributes the chat part), but while a copy still exists here a
--// deletion erases the person from it exactly as before, so a stale copy never outlives their erasure.
--const LEGACY_CHAT_COPIES = new Set(['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks',
-- 'tts_voice_overrides', 'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions']);
function ensureSchema(d) {
d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
@@@ -109,7 +104,7 @@ function exportPart(d, userId) {
bytes += JSON.stringify(profile).length;
const other = {};
for (const [t, cols] of personColumns(d)) {
-- if (CHAT_TABLES.has(t) || LEGACY_CHAT_COPIES.has(t)) continue; // Chat's rows: Chat contributes them to the chat part of the export
++ if (CHAT_TABLES.has(t)) continue; // Chat's rows: Chat contributes them to the chat part of the export
const keep = d.prepare(`PRAGMA table_info(${q(t)})`).all().map((c) => c.name).filter((c) => !SECRET_COL.test(c));
if (!keep.length) continue;
const where = cols.map((c) => `${q(c.col)} = ?`).join(' OR ');
ddiff --git a/server/chat/chat-authority.js b/server/chat/chat-authority.js
iindex 1f5f8cd4..46254be3 100644
---- a/server/chat/chat-authority.js
++++ b/server/chat/chat-authority.js
@@@ -11,8 +11,8 @@
* runs no chat server in any mode;
* - reads chat stats, queues and history from Chat's internal read API
* (server/chat/chat-reads.js → chat-client.js); the read mirror Chat used to write back was
-- * retired on 2026-10-05, Live keeps no copy of Chat's tables, and its twelve are dropped by the
-- * operator migration op_003_drop_chat_tables (scripts/chat-tables-drop.js);
++ * retired on 2026-10-05, Live keeps no copy of Chat's tables, and its twelve are dropped by boot
++ * migration 007_drop_chat_tables;
* - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.
* Anything else (default): Live runs no chat server any more (chat-server.js is gone), so chat is inert: every push
* is dropped and logged once by chat-delivery.js. Unsetting CHAT_AUTHORITY is therefore no rollback lever.
ddiff --git a/server/chat/chat-reads.js b/server/chat/chat-reads.js
iindex 363865f6..ad2f8006 100644
---- a/server/chat/chat-reads.js
++++ b/server/chat/chat-reads.js
@@@ -5,8 +5,8 @@
* pending_ip_messages, hidden_relay_users, tts_voice_overrides and dm_blocks, so Live's home stats,
* recaps, AI context, admin console, mod queues and the call-invite gate read them through Chat's
* internal read API (server/chat/chat-client.js). Live keeps no copy of any of them since 2026-10-05
-- * (the read mirror was retired on both sides; the tables themselves are dropped by migration
-- * op_003_drop_chat_tables), so outside chat mode every one of these answers null / empty.
++ * (the read mirror was retired on both sides; the tables themselves are dropped at boot by
++ * migration 007_drop_chat_tables), so outside chat mode every one of these answers null / empty.
*
* Mode-aware: when CHAT_AUTHORITY=chat, every answer comes from Chat; otherwise (dev, drills) the
* same call answers null / empty — the caller never has to know which. Unsetting CHAT_AUTHORITY is
ddiff --git a/server/db/migrations.js b/server/db/migrations.js
iindex 441f93c5..a799b666 100644
---- a/server/db/migrations.js
++++ b/server/db/migrations.js
@@@ -135,6 +135,60 @@ const MIGRATIONS = [
adopt: (db) => !tableExists(db, 'emotes'),
up: (db) => db.exec('DROP TABLE IF EXISTS emotes'),
},
++ {
++ id: '007_drop_chat_tables',
++ // T3 final step (2026-10-05). OpenVibe.Chat owns these twelve tables and has been their only
++ // writer since the 2026-09-23 02:03 UTC cutover (it imported them from Live with ids kept);
++ // Live's readers and writers are gone (#28-#32 and the release that carries this migration,
++ // #33), that release no longer creates them, and its N-1 fixtures no longer run SQL over them
++ // (test/n-1.test.js), so the drop is now an ordinary boot migration.
++ // On a fresh database none of the twelve exists and the migration is adopted.
++ // Dropping a table removes its own indexes; the explicit DROP INDEX lines also clear index
++ // names older rebuilds left behind. dm_participants/dm_messages go before dm_conversations
++ // (their FK parent). No trigger was ever created on any of the twelve.
++ adopt: (db) => ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks',
++ 'tts_voice_overrides', 'channel_sounds', 'relay_users', 'hidden_relay_users',
++ 'pending_ip_messages', 'stream_first_chats', 'moderation_actions'].every((t) => !tableExists(db, t)),
++ up: (db) => {
++ db.exec(`
++ DROP INDEX IF EXISTS idx_chat_stream_id;
++ DROP INDEX IF EXISTS idx_chat_timestamp;
++ DROP INDEX IF EXISTS idx_chat_user_id;
++ DROP INDEX IF EXISTS idx_chat_stream;
++ DROP INDEX IF EXISTS idx_chat_user;
++ DROP INDEX IF EXISTS idx_chat_channel_user_ts;
++ DROP INDEX IF EXISTS idx_chat_stream_ts;
++ DROP INDEX IF EXISTS idx_chat_autodelete;
++ DROP INDEX IF EXISTS idx_chat_ts_deleted;
++ DROP INDEX IF EXISTS idx_channel_sounds_owner;
++ DROP INDEX IF EXISTS idx_channel_sounds_cmd;
++ DROP INDEX IF EXISTS idx_mod_actions_created;
++ DROP INDEX IF EXISTS idx_mod_actions_actor;
++ DROP INDEX IF EXISTS idx_mod_actions_scope;
++ DROP INDEX IF EXISTS idx_pending_ip_channel;
++ DROP INDEX IF EXISTS idx_hidden_relay_channel;
++ DROP INDEX IF EXISTS idx_sfc_channel;
++ DROP INDEX IF EXISTS idx_dm_participants_conv;
++ DROP INDEX IF EXISTS idx_dm_participants_user;
++ DROP INDEX IF EXISTS idx_dm_messages_conv;
++ DROP INDEX IF EXISTS idx_dm_messages_sender;
++ DROP INDEX IF EXISTS idx_dm_blocks_blocker;
++ DROP INDEX IF EXISTS idx_dm_blocks_blocked;
++ DROP TABLE IF EXISTS dm_participants;
++ DROP TABLE IF EXISTS dm_messages;
++ DROP TABLE IF EXISTS dm_conversations;
++ DROP TABLE IF EXISTS dm_blocks;
++ DROP TABLE IF EXISTS chat_messages;
++ DROP TABLE IF EXISTS tts_voice_overrides;
++ DROP TABLE IF EXISTS channel_sounds;
++ DROP TABLE IF EXISTS relay_users;
++ DROP TABLE IF EXISTS hidden_relay_users;
++ DROP TABLE IF EXISTS pending_ip_messages;
++ DROP TABLE IF EXISTS stream_first_chats;
++ DROP TABLE IF EXISTS moderation_actions;
++ `);
++ },
++ },
];
/**
@@@ -187,57 +241,6 @@ const OPERATOR_MIGRATIONS = [
db.exec('DROP TABLE IF EXISTS chat_bridge_outbox');
},
},
-- {
-- id: 'op_003_drop_chat_tables',
-- // T3 final step. OpenVibe.Chat owns these twelve tables and has been their only writer since
-- // the 2026-09-23 02:03 UTC cutover (it imported them from Live with ids kept); Live's readers
-- // and writers are gone (PRs #28–#32 and the release that carries this migration), and this
-- // release no longer creates them. The N-1 release still runs SQL over them
-- // (test/n-1.test.js shows it), so the contract step is operator-run: once N-1 is out of
-- // rollback range, scripts/chat-tables-drop.js --apply takes an online backup and runs this.
-- // Dropping a table removes its own indexes; the explicit DROP INDEX lines also clear index
-- // names older rebuilds left behind. dm_participants/dm_messages go before dm_conversations
-- // (their FK parent). No trigger was ever created on any of the twelve.
-- up: (db) => {
-- db.exec(`
-- DROP INDEX IF EXISTS idx_chat_stream_id;
-- DROP INDEX IF EXISTS idx_chat_timestamp;
-- DROP INDEX IF EXISTS idx_chat_user_id;
-- DROP INDEX IF EXISTS idx_chat_stream;
-- DROP INDEX IF EXISTS idx_chat_user;
-- DROP INDEX IF EXISTS idx_chat_channel_user_ts;
-- DROP INDEX IF EXISTS idx_chat_stream_ts;
-- DROP INDEX IF EXISTS idx_chat_autodelete;
-- DROP INDEX IF EXISTS idx_chat_ts_deleted;
-- DROP INDEX IF EXISTS idx_channel_sounds_owner;
-- DROP INDEX IF EXISTS idx_channel_sounds_cmd;
-- DROP INDEX IF EXISTS idx_mod_actions_created;
-- DROP INDEX IF EXISTS idx_mod_actions_actor;
-- DROP INDEX IF EXISTS idx_mod_actions_scope;
-- DROP INDEX IF EXISTS idx_pending_ip_channel;
-- DROP INDEX IF EXISTS idx_hidden_relay_channel;
-- DROP INDEX IF EXISTS idx_sfc_channel;
-- DROP INDEX IF EXISTS idx_dm_participants_conv;
-- DROP INDEX IF EXISTS idx_dm_participants_user;
-- DROP INDEX IF EXISTS idx_dm_messages_conv;
-- DROP INDEX IF EXISTS idx_dm_messages_sender;
-- DROP INDEX IF EXISTS idx_dm_blocks_blocker;
-- DROP INDEX IF EXISTS idx_dm_blocks_blocked;
-- DROP TABLE IF EXISTS dm_participants;
-- DROP TABLE IF EXISTS dm_messages;
-- DROP TABLE IF EXISTS dm_conversations;
-- DROP TABLE IF EXISTS dm_blocks;
-- DROP TABLE IF EXISTS chat_messages;
-- DROP TABLE IF EXISTS tts_voice_overrides;
-- DROP TABLE IF EXISTS channel_sounds;
-- DROP TABLE IF EXISTS relay_users;
-- DROP TABLE IF EXISTS hidden_relay_users;
-- DROP TABLE IF EXISTS pending_ip_messages;
-- DROP TABLE IF EXISTS stream_first_chats;
-- DROP TABLE IF EXISTS moderation_actions;
-- `);
-- },
-- },
];
const failures = new Map(); // id -> message, for this process
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex a66d674e..7a6ced0d 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -42,12 +42,6 @@ d.prepare("INSERT INTO managed_streams (user_id, stream_key) VALUES (20, 'sk_liv
d.prepare("INSERT INTO api_tokens (user_id, token_hash) VALUES (20, 'hash-secret-dana')").run();
d.prepare("INSERT INTO transactions (from_user_id, to_user_id, amount, type, message) VALUES (20, 21, 5, 'donation', 'love you xena'), (22, 20, 3, 'donation', 'for dana')").run();
d.prepare("INSERT INTO payment_orders (user_id, provider, provider_ref, kind, amount_cents, currency, bucks, status) VALUES (20, 'paypal', 'PO-1', 'bucks', 500, 'USD', 5, 'completed')").run();
--// A database migrated before op_003 still holds Live's legacy copies of Chat's tables (a fresh schema creates none):
--// a deletion must erase the person from them while they exist.
--d.exec(`CREATE TABLE IF NOT EXISTS chat_messages (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, username TEXT, message TEXT);
-- CREATE TABLE IF NOT EXISTS pending_ip_messages (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, ip TEXT, message TEXT);`);
--d.prepare("INSERT INTO chat_messages (user_id, username, message) VALUES (20, 'dana', 'hello from dana'), (21, 'xena', 'hi')").run();
--d.prepare("INSERT INTO pending_ip_messages (user_id, ip, message) VALUES (20, '203.0.113.20', 'held line')").run();
const envelope = (type, payload) => {
assert.ok(validate(`${type}@1`, payload).valid, JSON.stringify(validate(`${type}@1`, payload).errors));
@@@ -112,9 +106,6 @@ const ulid = () => ids.ulid();
assert.strictEqual(n('SELECT COUNT(*) AS n FROM managed_streams WHERE user_id = 20') + n('SELECT COUNT(*) AS n FROM api_tokens WHERE user_id = 20') + n("SELECT COUNT(*) AS n FROM linked_accounts WHERE user_id = 20"), 0);
assert.deepStrictEqual(d.prepare('SELECT from_user_id AS f, message FROM transactions ORDER BY id').all(), [{ f: 20, message: null }, { f: 22, message: 'for dana' }], 'money kept; her own message cleared');
assert.strictEqual(n("SELECT COUNT(*) AS n FROM payment_orders WHERE user_id = 20"), 1, 'payment orders kept');
-- assert.strictEqual(n('SELECT COUNT(*) AS n FROM chat_messages WHERE user_id = 20 OR username = \'dana\''), 0, 'her lines are erased from the legacy chat copy');
-- assert.strictEqual(n('SELECT COUNT(*) AS n FROM pending_ip_messages WHERE user_id = 20'), 0, 'and her held line with its IP');
-- assert.strictEqual(n('SELECT COUNT(*) AS n FROM chat_messages WHERE user_id = 21'), 1, "others' lines stay");
const tomb = d.prepare('SELECT username, email, bio, deleted_at FROM users WHERE id = 20').get();
assert.deepStrictEqual([tomb.username, tomb.email, tomb.bio, !!tomb.deleted_at], ['deleted-20', null, null, true]);
d.prepare("INSERT INTO users (username, password_hash) VALUES ('dana', '$sso$')").run();
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex 8b1c1e82..3f714dc5 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -229,7 +229,7 @@ const waitFor = async (pred, what, ms = 3000) => {
db.initDb();
const d = db.getDb();
// Live no longer has any of OpenVibe.Chat's tables (the staged ones dropped in T3 N+2; the
-- // twelve chat tables dropped by op_003). The stub Chat below imitates Chat's own copy, so the
++ // twelve chat tables dropped by 007_drop_chat_tables). The stub Chat below imitates Chat's own copy, so the
// test keeps local stand-in tables to seed and read.
d.exec(`
CREATE TABLE IF NOT EXISTS channel_moderators (
ddiff --git a/test/chat-history-store.test.js b/test/chat-history-store.test.js
iindex add8b317..3065fdab 100644
---- a/test/chat-history-store.test.js
++++ b/test/chat-history-store.test.js
@@@ -2,7 +2,7 @@
/**
* Live's chat history reads come from OpenVibe.Chat (roadmap T3 J4b: Read Live's chat stats,
* queues and history from Chat). Live's own history store — the page/delta reads over its copy of
-- * chat_messages — went with the read mirror, and the table itself is dropped (op_003), so nothing
++ * chat_messages — went with the read mirror, and the table itself is dropped (007_drop_chat_tables), so nothing
* keeps a second answer to "what happened in this room?". These checks guard that the dead module
* stays deleted, and that outside chat mode (dev, drills — Live runs no chat server either way) the
* read seam answers empty/null rather than any local value.
ddiff --git a/test/chat-tables-dropped.test.js b/test/chat-tables-dropped.test.js
iindex 7dbe3b16..076cc6d8 100644
---- a/test/chat-tables-dropped.test.js
++++ b/test/chat-tables-dropped.test.js
@@@ -1,13 +1,11 @@
'use strict';
// The twelve OpenVibe.Chat-owned chat tables (roadmap T3, final step). Live's readers and writers
--// are gone (#28–#32 and the release that carries this file), it no longer creates them, and
--// op_003_drop_chat_tables (scripts/chat-tables-drop.js) drops them. The drop is an operator
--// contract step, not a boot migration: the release before this one still runs SQL over them
--// (test/n-1.test.js), so it may only run once that release is out of rollback range (ADR-028).
--// This guard: nothing under server/ names any of the twelve (the drop migration's own statements
--// are the one exception), a freshly migrated database has none, and the operator migration drops
--// legacy copies and is idempotent.
++// are gone (#28-#32 and the release that carries this file, #33), it no longer creates them, and
++// since #33 is the production release its N-1 fixtures no longer run SQL over them (test/n-1.test.js),
++// so boot migration 007_drop_chat_tables drops them (ADR-028). This guard: nothing under server/
++// names any of the twelve (the drop migration's own DROP statements are the one exception), a fresh
++// schema creates none, a boot drops legacy copies that are there and is adopted when none exist.
const assert = require('assert');
const fs = require('fs');
@@@ -16,6 +14,7 @@ const path = require('path');
const Database = require('better-sqlite3');
const ROOT = path.join(__dirname, '..');
++const DROP_ID = '007_drop_chat_tables';
const TABLES = [
'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks',
@@@ -27,7 +26,7 @@ const T = TABLES.join('|');
const READ_SQL = new RegExp(`\\b(FROM|JOIN)\\s+[\`"']?(?:main\\.)?(${T})\\b`, 'gi');
const WRITE_SQL = new RegExp(`\\b(INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|UPDATE|DELETE\\s+FROM|REPLACE\\s+INTO)\\s+(?:main\\.)?(${T})\\b`, 'i');
const SCHEMA_SQL = new RegExp(`\\b(CREATE\\s+(?:TABLE|INDEX|TRIGGER)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?|ALTER\\s+TABLE\\s+|PRAGMA\\s+(?:table_info|table_xinfo|index_list|foreign_key_list)\\s*\\(\\s*[\`"']?)(${T})\\b`, 'i');
--// The operator drop (server/db/migrations.js) is the one place allowed to name them.
++// The boot drop (server/db/migrations.js) is the one place allowed to name them.
const DROP_STMT = new RegExp(`^\\s*DROP\\s+(?:TABLE|INDEX|TRIGGER)\\s+IF\\s+EXISTS\\s+(?:${T})\\s*;\\s*$`, 'i');
function walk(dir, out = []) {
@@@ -40,7 +39,7 @@ function walk(dir, out = []) {
}
const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);
--/** Every read matching one of `regexes` in a file's text → [{ line, text }]. */
++/** Every read matching one of `regexes` in a file's text -> [{ line, text }]. */
function findReads(src, regexes) {
const lines = src.split('\n');
const hits = [];
@@@ -72,7 +71,7 @@ for (const file of walk(path.join(ROOT, 'server'))) {
if (WRITE_SQL.test(line) || SCHEMA_SQL.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
});
}
--assert.deepStrictEqual(offenders, [], `server/ still names OpenVibe.Chat's chat tables (read and write them through Chat's API; the operator drop is the only exception):\n${offenders.join('\n')}`);
++assert.deepStrictEqual(offenders, [], `server/ still names OpenVibe.Chat's chat tables (read and write them through Chat's API; the boot drop is the only exception):\n${offenders.join('\n')}`);
// 2. A database created by the schema has none of them.
const fresh = new Database(':memory:');
@@@ -82,7 +81,11 @@ for (const t of TABLES) {
}
fresh.close();
--// 3. A boot neither creates nor drops them (the drop is the operator step), and op_003 drops them.
++const migrations = require('../server/db/migrations');
++assert.ok(migrations.MIGRATIONS.some((m) => m.id === DROP_ID), `${DROP_ID} is a boot migration`);
++assert.ok(migrations.OPERATOR_MIGRATIONS.every((m) => m.id !== 'op_003_drop_chat_tables'), 'the superseded operator step is gone');
++
++// 3. The boot migration drops them on a database that has them.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-tables-dropped-'));
process.env.DB_PATH = path.join(tmp, 'live.db');
const legacy = new Database(process.env.DB_PATH);
@@@ -93,17 +96,17 @@ const db = require('../server/db/database');
db.initDb();
const d = db.getDb();
const exists = (t) => !!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t);
--for (const t of TABLES) assert.ok(exists(t), `${t} must survive a boot: dropping it is the operator step, not a boot migration`);
--assert.ok(!d.prepare("SELECT 1 FROM schema_migrations WHERE id = 'op_003_drop_chat_tables'").get(), 'op_003 is not a boot migration');
--
--const migrations = require('../server/db/migrations');
--assert.ok(!migrations.MIGRATIONS.some((m) => m.id === 'op_003_drop_chat_tables'), 'op_003 is not in the boot list');
--assert.ok(migrations.OPERATOR_MIGRATIONS.some((m) => m.id === 'op_003_drop_chat_tables'), 'op_003 is an operator migration');
--assert.strictEqual(migrations.runOperator(d, 'op_003_drop_chat_tables').outcome, 'applied', 'the operator drop applies');
--for (const t of TABLES) assert.ok(!exists(t), `${t} must be gone after op_003`);
--assert.strictEqual(migrations.runOperator(d, 'op_003_drop_chat_tables').outcome, 'already', 'the operator drop is idempotent');
++for (const t of TABLES) assert.ok(!exists(t), `${t} must be gone after a boot with the drop migration`);
++assert.strictEqual(d.prepare('SELECT mode FROM schema_migrations WHERE id = ?').get(DROP_ID).mode, 'applied', 'the drop is applied, not adopted, when the tables are present');
++assert.strictEqual(migrations.run(d, migrations.MIGRATIONS.filter((m) => m.id === DROP_ID)).length, 0, 'the drop runs at most once');
db.close();
fs.rmSync(tmp, { recursive: true, force: true });
--console.log(`chat tables dropped: no server/ code names the ${TABLES.length}; fresh schema none; op_003 drops legacy copies (idempotent)`);
++// 4. On a fresh database none of the twelve exists and the migration is adopted.
++const mem = new Database(':memory:');
++const res = migrations.run(mem, migrations.MIGRATIONS.filter((m) => m.id === DROP_ID));
++assert.strictEqual(res[0].outcome, 'adopted', 'a fresh database adopts the drop');
++mem.close();
++
++console.log(`chat tables dropped: no server/ code names the ${TABLES.length} outside ${DROP_ID}; fresh schema none; a boot drops legacy copies and adopts a fresh database`);
process.exit(0);
ddiff --git a/test/fixtures/destructive-migrations.json b/test/fixtures/destructive-migrations.json
iindex 0127d8aa..0eee694b 100644
---- a/test/fixtures/destructive-migrations.json
++++ b/test/fixtures/destructive-migrations.json
@@@ -14,17 +14,17 @@
{ "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" },
{ "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_BRIDGE_OUTBOX", "count": 1, "contract": "roadmap T3 J2 (op_002_drop_chat_bridge_outbox, operator-run via scripts/chat-bridge-outbox-drop.js): Live's chat bridge was deleted in this release; N-1 still writes the table, so the drop waits until N-1 is out of rollback range, delivers unacknowledged writes to Chat first and refuses while any remain; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_PARTICIPANTS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_MESSAGES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_CONVERSATIONS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_BLOCKS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_MESSAGES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS TTS_VOICE_OVERRIDES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHANNEL_SOUNDS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS RELAY_USERS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS HIDDEN_RELAY_USERS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS PENDING_IP_MESSAGES", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS STREAM_FIRST_CHATS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" },
-- { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS MODERATION_ACTIONS", "count": 1, "contract": "roadmap T3 final step (op_003_drop_chat_tables, applied by scripts/chat-tables-drop.js): OpenVibe.Chat owns the chat tables and the N-1 release still runs SQL over them (test/n-1.test.js), so the drop is an operator contract step once N-1 is out of rollback range; backups kept operator-side" }
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_PARTICIPANTS", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_MESSAGES", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_CONVERSATIONS", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS DM_BLOCKS", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHAT_MESSAGES", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS TTS_VOICE_OVERRIDES", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS CHANNEL_SOUNDS", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS RELAY_USERS", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS HIDDEN_RELAY_USERS", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS PENDING_IP_MESSAGES", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS STREAM_FIRST_CHATS", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" },
++ { "file": "server/db/migrations.js", "statement": "DROP TABLE IF EXISTS MODERATION_ACTIONS", "count": 1, "contract": "roadmap T3 final step (007_drop_chat_tables): OpenVibe.Chat owns the chat tables; the N-1 release (#33) no longer runs SQL over them (test/n-1.test.js), so the drop runs at boot; backups kept operator-side" }
]
}
ddiff --git a/test/fixtures/n-1/client.json b/test/fixtures/n-1/client.json
iindex c16062cf..fb345aa7 100644
---- a/test/fixtures/n-1/client.json
++++ b/test/fixtures/n-1/client.json
@@@ -1,13 +1,13 @@
{
"service": "live",
-- "release": "2d0fa0edd39bab4304e0ae7c06d132a357c5d9f3",
-- "recorded_at": "2026-10-05T00:02:56.030Z",
++ "release": "1d49484e3a364f7c39f413582fe24321dc1cb64f",
++ "recorded_at": "2026-10-05T22:52:29.288Z",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"manifest": {
"service": "live",
-- "release": "2d0fa0edd39b",
-- "released_at": "2026-10-05T00:01:56.000Z",
-- "booted_at": "2026-10-05T00:02:53.746Z",
++ "release": "1d49484e3a36",
++ "released_at": "2026-10-05T22:50:37.000Z",
++ "booted_at": "2026-10-05T22:52:27.146Z",
"contracts_version": "0.83.0",
"packages": {
"openvibe-shared": "2.5.0",
@@@ -22,11 +22,11 @@
},
"shell": {
"kind": "script",
-- "version": "4fc32670c70b"
++ "version": "0f5806ad688f"
},
"server": {
"kind": "server",
-- "version": "27aac0b16594"
++ "version": "f0efd244faa6"
}
},
"assets": {
@@@ -7099,7 +7099,7 @@
},
{
"method": "GET",
-- "path": "/js/app.js?v=0fd06344b140",
++ "path": "/js/app.js?v=a34333556690",
"auth": "anon",
"accept": "html",
"status": 200,
@@@ -7121,7 +7121,7 @@
},
{
"method": "GET",
-- "path": "/js/chat-shared.js?v=8fff7cb6e502",
++ "path": "/js/chat-shared.js?v=bd4b18592b1a",
"auth": "anon",
"accept": "html",
"status": 200,
ddiff --git a/test/fixtures/n-1/worker.json b/test/fixtures/n-1/worker.json
iindex 706687ce..872b3e5e 100644
---- a/test/fixtures/n-1/worker.json
++++ b/test/fixtures/n-1/worker.json
@@@ -1,7 +1,7 @@
{
"service": "live",
-- "release": "2d0fa0edd39bab4304e0ae7c06d132a357c5d9f3",
-- "recorded_at": "2026-10-05T00:02:56.030Z",
++ "release": "1d49484e3a364f7c39f413582fe24321dc1cb64f",
++ "recorded_at": "2026-10-05T22:52:29.288Z",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"user_version": 0,
"schema": [
@@@ -30,9 +30,7 @@
"CREATE TABLE channel_ai_config (\n user_id INTEGER PRIMARY KEY,\n enabled INTEGER DEFAULT 0,\n num_ambient_bots INTEGER DEFAULT 3,\n pacing_seconds INTEGER DEFAULT 45,\n persona TEXT DEFAULT '',\n transcribe_enabled INTEGER DEFAULT 0,\n vision_enabled INTEGER DEFAULT 0,\n use_shared_key INTEGER DEFAULT 1, -- 1 = OpenVibe.Live shared key (capped), 0 = BYO\n daily_budget_cents INTEGER DEFAULT 20, -- shared-key daily cap\n byo_key TEXT DEFAULT '',\n byo_base_url TEXT DEFAULT '',\n byo_model TEXT DEFAULT 'gpt-4o-mini',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, settings_json TEXT DEFAULT '{}', byo_in_ai INTEGER DEFAULT 0,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
"CREATE TABLE channel_points (\n user_id INTEGER NOT NULL,\n streamer_id INTEGER NOT NULL,\n balance INTEGER NOT NULL DEFAULT 0,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (user_id, streamer_id)\n )",
"CREATE TABLE channel_points_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n idempotency_key TEXT NOT NULL UNIQUE,\n user_id INTEGER NOT NULL,\n streamer_id INTEGER NOT NULL,\n delta INTEGER NOT NULL,\n reason TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )",
-- "CREATE TABLE channel_sounds (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_owner_id INTEGER NOT NULL, -- streamer whose channel this sound belongs to\n command TEXT NOT NULL, -- trigger word (without leading '!'), lowercased\n url TEXT NOT NULL, -- served path of the audio file on disk\n mime TEXT DEFAULT 'audio/mpeg',\n duration_seconds REAL DEFAULT 0,\n created_by INTEGER, -- uploader user id (NULL if removed user)\n created_by_name TEXT DEFAULT '',\n is_approved INTEGER DEFAULT 1,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, emote_code TEXT DEFAULT '',\n -- Multiple sounds may share a command; playback picks one at random.\n FOREIGN KEY (channel_owner_id) REFERENCES users(id) ON DELETE CASCADE\n)",
"CREATE TABLE channels (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL UNIQUE,\n title TEXT DEFAULT 'Untitled Channel',\n description TEXT DEFAULT '',\n category TEXT DEFAULT 'irl',\n tags TEXT DEFAULT '[]',\n protocol TEXT DEFAULT 'webrtc' CHECK(protocol IN ('jsmpeg', 'webrtc', 'rtmp')),\n is_nsfw INTEGER DEFAULT 0,\n auto_record INTEGER DEFAULT 0,\n offline_banner_url TEXT,\n panels TEXT DEFAULT '[]', -- JSON array of info panels\n emote_sources TEXT DEFAULT '{\"defaults\":true,\"custom\":true,\"ffz\":true,\"bttv\":true,\"7tv\":true}',\n default_vod_visibility TEXT DEFAULT 'public' CHECK(default_vod_visibility IN ('public', 'unlisted', 'private')),\n default_clip_visibility TEXT DEFAULT 'public' CHECK(default_clip_visibility IN ('public', 'unlisted', 'private')),\n vod_recording_enabled INTEGER DEFAULT 1,\n force_vod_recording_disabled INTEGER DEFAULT 0,\n weather_zip TEXT DEFAULT NULL,\n weather_detail TEXT DEFAULT 'basic' CHECK(weather_detail IN ('off', 'basic', 'hourly', 'detailed')),\n weather_show_location INTEGER DEFAULT 0,\n control_mode TEXT DEFAULT 'open' CHECK(control_mode IN ('open','whitelist','disabled')),\n anon_controls_enabled INTEGER DEFAULT 1,\n control_rate_limit_ms INTEGER DEFAULT 100,\n video_click_enabled INTEGER DEFAULT 0,\n video_click_rate_limit_ms INTEGER DEFAULT 0,\n active_control_config_id INTEGER,\n bot_robot_id TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, chat_language TEXT DEFAULT 'auto', cp_name TEXT DEFAULT 'Channel Points', cp_icon TEXT DEFAULT 'fa-coins', cp_watch_interval_min INTEGER DEFAULT 5, cp_watch_amount INTEGER DEFAULT 10, cp_game_interval_min INTEGER DEFAULT 0, clips_allow_creator_delete INTEGER DEFAULT 0, hide_ai_overview INTEGER DEFAULT 0, ai_overview_pref TEXT DEFAULT 'auto', ai_derivation_enabled INTEGER DEFAULT 1, ai_category TEXT, force_nsfw INTEGER DEFAULT 0, offline_screen_type TEXT DEFAULT 'none', offline_screen_url TEXT, offline_html TEXT, offline_css TEXT, social_links TEXT,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n)",
-- "CREATE TABLE \"chat_messages\" (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER,\n user_id INTEGER, -- NULL for anon\n anon_id TEXT, -- 'anon12345' format\n username TEXT,\n message TEXT NOT NULL,\n message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip')),\n metadata TEXT, -- JSON sidecar for rich events (donation/goal-reached)\n is_global INTEGER DEFAULT 0,\n is_deleted INTEGER DEFAULT 0,\n is_filtered INTEGER DEFAULT 0,\n reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL,\n source_platform TEXT,\n deleted_by INTEGER,\n deleted_at DATETIME,\n auto_delete_at DATETIME,\n timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, channel_user_id INTEGER,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL\n)",
"CREATE TABLE clip_ai_state (\n clip_id INTEGER PRIMARY KEY,\n ai_overview_short TEXT,\n ai_transcript_json TEXT,\n transcript_status TEXT,\n transcript_attempts INTEGER DEFAULT 0,\n transcript_error TEXT,\n transcript_next_at DATETIME,\n clip_notified INTEGER DEFAULT 0,\n clip_notify_at DATETIME\n , ai_overview TEXT, transcript_partial_json TEXT, transcript_progress_sec INTEGER DEFAULT 0)",
"CREATE TABLE clips (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n vod_id INTEGER,\n stream_id INTEGER,\n user_id INTEGER NOT NULL,\n title TEXT DEFAULT 'Untitled Clip',\n description TEXT DEFAULT '',\n file_path TEXT,\n thumbnail_url TEXT,\n start_time REAL NOT NULL DEFAULT 0,\n end_time REAL NOT NULL DEFAULT 0,\n duration_seconds REAL DEFAULT 0,\n is_public INTEGER DEFAULT 1,\n view_count INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, storage_provider TEXT DEFAULT 'local', storage_key TEXT, ai_overview TEXT, ai_transcript TEXT, ai_analyzed_at DATETIME, clip_notified INTEGER DEFAULT 0, clip_notify_at DATETIME, visibility TEXT DEFAULT 'public', auto_generated INTEGER DEFAULT 0,\n FOREIGN KEY (vod_id) REFERENCES vods(id) ON DELETE SET NULL,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE SET NULL,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n)",
"CREATE TABLE coin_redemptions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n reward_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n stream_id INTEGER,\n status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'fulfilled', 'rejected', 'refunded')),\n user_input TEXT, -- message if requires_input\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n resolved_at DATETIME,\n FOREIGN KEY (reward_id) REFERENCES coin_rewards(id) ON DELETE CASCADE,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE SET NULL\n)",
@@@ -43,39 +41,30 @@
"CREATE TABLE control_config_buttons (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n config_id INTEGER NOT NULL,\n label TEXT NOT NULL,\n command TEXT NOT NULL,\n icon TEXT DEFAULT 'fa-gamepad',\n control_type TEXT DEFAULT 'button' CHECK(control_type IN ('button','toggle','dpad','keyboard')),\n key_binding TEXT,\n cooldown_ms INTEGER DEFAULT 500,\n sort_order INTEGER DEFAULT 0,\n btn_color TEXT DEFAULT '',\n btn_bg TEXT DEFAULT '',\n btn_border_color TEXT DEFAULT '',\n is_enabled INTEGER DEFAULT 1,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (config_id) REFERENCES control_configs(id) ON DELETE CASCADE\n )",
"CREATE TABLE control_configs (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n name TEXT NOT NULL,\n description TEXT DEFAULT '',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
"CREATE TABLE control_whitelist (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n added_by INTEGER,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(channel_id, user_id),\n FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
-- "CREATE TABLE dm_blocks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n blocker_id INTEGER NOT NULL,\n blocked_id INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(blocker_id, blocked_id),\n FOREIGN KEY (blocker_id) REFERENCES users(id) ON DELETE CASCADE,\n FOREIGN KEY (blocked_id) REFERENCES users(id) ON DELETE CASCADE\n )",
-- "CREATE TABLE dm_conversations (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT,\n is_group INTEGER DEFAULT 0,\n created_by INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE\n )",
-- "CREATE TABLE dm_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n conversation_id INTEGER NOT NULL,\n sender_id INTEGER NOT NULL,\n message TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,\n FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE\n )",
-- "CREATE TABLE dm_participants (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n conversation_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n last_read_at DATETIME DEFAULT '1970-01-01 00:00:00',\n joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(conversation_id, user_id),\n FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
"CREATE TABLE donation_goals (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n title TEXT NOT NULL,\n target_amount INTEGER NOT NULL, -- in Vibes (dollars)\n current_amount INTEGER DEFAULT 0,\n is_active INTEGER DEFAULT 1,\n image_url TEXT, -- optional goal media (webp image / webm video)\n media_type TEXT, -- 'image' | 'video'\n reached_at DATETIME, -- set when target hit; drives the ~1h celebration window\n sort_order INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n)",
"CREATE TABLE easter_egg_solves (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n egg_date TEXT NOT NULL,\n solver_key TEXT NOT NULL,\n user_id INTEGER,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(egg_date, solver_key)\n )",
"CREATE TABLE follows (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n follower_id INTEGER NOT NULL,\n streamer_id INTEGER NOT NULL,\n email_notify INTEGER DEFAULT 0,\n push_notify INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(follower_id, streamer_id),\n FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,\n FOREIGN KEY (streamer_id) REFERENCES users(id) ON DELETE CASCADE\n)",
-- "CREATE TABLE hidden_relay_users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_id INTEGER,\n platform TEXT NOT NULL,\n external_username TEXT NOT NULL,\n action TEXT DEFAULT 'hide' CHECK(action IN ('hide','ban')),\n reason TEXT,\n created_by INTEGER,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,\n FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL\n )",
"CREATE TABLE ip_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER,\n anon_id TEXT,\n ip_address TEXT NOT NULL,\n action TEXT NOT NULL DEFAULT 'chat',\n geo_country TEXT,\n geo_region TEXT,\n geo_city TEXT,\n geo_isp TEXT,\n geo_org TEXT,\n geo_ll TEXT,\n user_agent TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL\n )",
"CREATE TABLE kick_channel_cache (\n slug TEXT PRIMARY KEY,\n chatroom_id INTEGER,\n kick_channel_id INTEGER,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )",
"CREATE TABLE linked_accounts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n service TEXT NOT NULL,\n service_user_id TEXT NOT NULL,\n service_username TEXT,\n linked_at DATETIME DEFAULT CURRENT_TIMESTAMP, subject_id TEXT,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,\n UNIQUE(service, service_user_id)\n )",
"CREATE TABLE managed_streams (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n channel_id INTEGER,\n slug TEXT, -- optional unique string ID (must contain non-numeric chars)\n title TEXT DEFAULT 'Untitled Stream',\n description TEXT DEFAULT '',\n category TEXT DEFAULT 'irl',\n tags TEXT DEFAULT '[]',\n protocol TEXT DEFAULT 'webrtc' CHECK(protocol IN ('jsmpeg', 'webrtc', 'rtmp')),\n stream_key TEXT UNIQUE NOT NULL, -- per-stream stable key (reusable across sessions)\n is_nsfw INTEGER DEFAULT 0,\n control_config_id INTEGER,\n sort_order INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, slot_clip_recording_enabled INTEGER DEFAULT 1, slot_clip_notify_enabled INTEGER DEFAULT 1, slot_powerchat_relay INTEGER DEFAULT 1, slot_powerchat_count_rs_views INTEGER DEFAULT 1, broadcast_settings TEXT DEFAULT '{}', streaming_method TEXT DEFAULT 'browser', browser_mode TEXT DEFAULT 'camera', pip_source_msid INTEGER, pip_defaults TEXT DEFAULT '{}', default_vod_visibility TEXT DEFAULT 'public', default_clip_visibility TEXT DEFAULT 'public', slot_vod_recording_enabled INTEGER DEFAULT 1, weather_zip TEXT DEFAULT NULL, weather_detail TEXT DEFAULT 'basic', weather_show_location INTEGER DEFAULT 0, mic_only_image TEXT DEFAULT NULL, ingest_authority TEXT NOT NULL DEFAULT 'live', openre_stream_id TEXT DEFAULT NULL,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,\n FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,\n FOREIGN KEY (control_config_id) REFERENCES control_configs(id) ON DELETE SET NULL\n)",
"CREATE TABLE media_request_settings (\n user_id INTEGER PRIMARY KEY,\n enabled INTEGER DEFAULT 1,\n request_cost INTEGER DEFAULT 25,\n max_per_user INTEGER DEFAULT 3,\n max_duration_seconds INTEGER DEFAULT 600,\n allow_youtube INTEGER DEFAULT 1,\n allow_vimeo INTEGER DEFAULT 1,\n allow_direct_media INTEGER DEFAULT 1,\n auto_advance INTEGER DEFAULT 1,\n currency TEXT DEFAULT 'opencoins' CHECK(currency IN ('free','vibes','opencoins','points')),\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, cost_mode TEXT DEFAULT 'flat' CHECK(cost_mode IN ('flat','per_minute')), cost_per_minute INTEGER DEFAULT 5, allow_live INTEGER DEFAULT 0, download_mode TEXT DEFAULT 'stream' CHECK(download_mode IN ('stream','download')),\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n)",
"CREATE TABLE media_requests (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n streamer_id INTEGER NOT NULL,\n stream_id INTEGER,\n user_id INTEGER NOT NULL,\n username TEXT NOT NULL,\n input TEXT NOT NULL,\n canonical_url TEXT NOT NULL,\n embed_url TEXT,\n provider TEXT NOT NULL CHECK(provider IN ('youtube', 'vimeo', 'audio', 'video')),\n title TEXT NOT NULL,\n thumbnail_url TEXT,\n duration_seconds INTEGER,\n cost INTEGER NOT NULL DEFAULT 25,\n currency TEXT DEFAULT 'opencoins',\n queue_position INTEGER DEFAULT 0,\n status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'playing', 'played', 'skipped', 'removed', 'failed')),\n requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n started_at DATETIME,\n ended_at DATETIME,\n last_error TEXT, stream_url TEXT, download_status TEXT DEFAULT 'none' CHECK(download_status IN ('none','extracting','downloading','ready','failed')), file_path TEXT, playback_position REAL DEFAULT 0, refunded INTEGER DEFAULT 0, charge_state TEXT,\n FOREIGN KEY (streamer_id) REFERENCES users(id) ON DELETE CASCADE,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE SET NULL,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n)",
-- "CREATE TABLE moderation_actions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scope_type TEXT NOT NULL DEFAULT 'site',\n scope_id INTEGER,\n actor_user_id INTEGER,\n target_user_id INTEGER,\n action_type TEXT NOT NULL,\n details TEXT DEFAULT '{}',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,\n FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL\n )",
"CREATE TABLE news_settings (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scope TEXT NOT NULL DEFAULT 'global',\n scope_id INTEGER,\n source_id TEXT NOT NULL,\n enabled INTEGER NOT NULL DEFAULT 0,\n config TEXT DEFAULT '{}',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(scope, scope_id, source_id)\n )",
"CREATE TABLE openre_sessions (\n session_id TEXT PRIMARY KEY,\n managed_stream_id INTEGER,\n stream_id INTEGER,\n state TEXT NOT NULL,\n revision INTEGER NOT NULL DEFAULT 0,\n started_at DATETIME,\n ended_at DATETIME,\n confirmed_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )",
"CREATE TABLE paste_comments (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n paste_id INTEGER NOT NULL,\n user_id INTEGER,\n parent_id INTEGER,\n anon_name TEXT,\n message TEXT NOT NULL,\n ip_address TEXT,\n is_deleted INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,\n FOREIGN KEY (parent_id) REFERENCES paste_comments(id) ON DELETE CASCADE\n )",
"CREATE TABLE paste_likes (\n paste_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (paste_id, user_id),\n FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
"CREATE TABLE pastes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT UNIQUE NOT NULL,\n user_id INTEGER,\n type TEXT DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),\n title TEXT NOT NULL DEFAULT 'Untitled',\n content TEXT,\n language TEXT DEFAULT 'text',\n visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted')),\n stream_id INTEGER,\n screenshot_path TEXT,\n metadata TEXT,\n burn_after_read INTEGER DEFAULT 0,\n forked_from INTEGER,\n pinned INTEGER DEFAULT 0,\n views INTEGER DEFAULT 0,\n ip_address TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, copies INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, is_nsfw INTEGER DEFAULT 0, ai_summary TEXT, ai_tags TEXT, ai_analyzed_at DATETIME,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE SET NULL,\n FOREIGN KEY (forked_from) REFERENCES pastes(id) ON DELETE SET NULL\n )",
"CREATE TABLE payment_orders (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n provider TEXT NOT NULL,\n provider_ref TEXT,\n kind TEXT NOT NULL DEFAULT 'bucks', -- bucks | subscription\n amount_cents INTEGER NOT NULL DEFAULT 0,\n currency TEXT DEFAULT 'usd',\n bucks INTEGER DEFAULT 0,\n streamer_id INTEGER,\n status TEXT DEFAULT 'pending', -- pending | paid | failed | credited\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
-- "CREATE TABLE pending_ip_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_id INTEGER NOT NULL,\n stream_id INTEGER,\n ip_address TEXT NOT NULL,\n user_id INTEGER,\n anon_id TEXT,\n username TEXT,\n message TEXT NOT NULL,\n status TEXT DEFAULT 'pending' CHECK(status IN ('pending','approved','denied')),\n reviewed_by INTEGER,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,\n FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL\n )",
"CREATE TABLE platform_connections (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n platform TEXT NOT NULL CHECK(platform IN ('youtube', 'twitch', 'kick')),\n platform_user_id TEXT,\n platform_username TEXT,\n channel_url TEXT,\n access_token TEXT,\n refresh_token TEXT,\n token_expires_at INTEGER,\n scope TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(user_id, platform),\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
"CREATE TABLE powerchat_connections (\n user_id INTEGER PRIMARY KEY,\n powerchat_username TEXT,\n powerchat_user_id TEXT,\n access_token TEXT,\n refresh_token TEXT,\n token_expires_at INTEGER, -- epoch ms\n scope TEXT,\n tip_page_url TEXT,\n last_error TEXT,\n connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
"CREATE TABLE powerchat_webhook_deliveries (\n delivery_id TEXT PRIMARY KEY,\n event_type TEXT,\n received_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )",
-- "CREATE TABLE relay_users (\n platform TEXT NOT NULL,\n username TEXT NOT NULL,\n display_name TEXT,\n first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,\n last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,\n message_count INTEGER DEFAULT 0,\n PRIMARY KEY (platform, username)\n )",
"CREATE TABLE restream_destinations (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n platform TEXT NOT NULL CHECK(platform IN ('youtube', 'twitch', 'kick', 'custom')),\n name TEXT,\n server_url TEXT,\n stream_key TEXT,\n enabled INTEGER DEFAULT 1,\n auto_start INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, connection_id INTEGER DEFAULT NULL REFERENCES platform_connections(id) ON DELETE SET NULL, quality_preset TEXT DEFAULT 'auto', custom_video_bitrate INTEGER DEFAULT NULL, custom_audio_bitrate INTEGER DEFAULT NULL, custom_fps INTEGER DEFAULT NULL, custom_encoder_preset TEXT DEFAULT NULL, srt_latency_ms INTEGER DEFAULT NULL, srt_passphrase TEXT DEFAULT NULL, channel_url TEXT DEFAULT NULL, chat_relay INTEGER DEFAULT 0, powerchat_relay INTEGER DEFAULT 1, powerchat_count_views INTEGER DEFAULT 1, consecutive_failures INTEGER DEFAULT 0, cooldown_until DATETIME DEFAULT NULL, last_error TEXT DEFAULT NULL, last_failed_at DATETIME DEFAULT NULL, managed_stream_id INTEGER REFERENCES managed_streams(id) ON DELETE SET NULL,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
"CREATE TABLE \"robotstreamer_integrations\" (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n managed_stream_id INTEGER,\n enabled INTEGER DEFAULT 0,\n mirror_chat INTEGER DEFAULT 1,\n token TEXT,\n robot_id TEXT,\n owner_id TEXT,\n chat_url TEXT,\n control_url TEXT,\n rtc_sfu_url TEXT,\n stream_name TEXT,\n owner_name TEXT,\n last_validated_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,\n FOREIGN KEY (managed_stream_id) REFERENCES managed_streams(id) ON DELETE CASCADE\n )",
"CREATE TABLE schema_migrations (\n id TEXT PRIMARY KEY,\n applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n mode TEXT NOT NULL CHECK (mode IN ('applied', 'adopted')),\n duration_ms INTEGER DEFAULT 0\n)",
"CREATE TABLE site_settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL DEFAULT '',\n description TEXT DEFAULT '',\n type TEXT DEFAULT 'string' CHECK(type IN ('string', 'number', 'boolean', 'json')),\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",
"CREATE TABLE stream_analytics (\n stream_id INTEGER PRIMARY KEY,\n avg_viewers REAL DEFAULT 0,\n peak_viewers INTEGER DEFAULT 0,\n unique_chatters INTEGER DEFAULT 0,\n total_messages INTEGER DEFAULT 0,\n total_watch_minutes INTEGER DEFAULT 0,\n new_followers INTEGER DEFAULT 0,\n clips_created INTEGER DEFAULT 0,\n coins_earned INTEGER DEFAULT 0,\n computed_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n)",
"CREATE TABLE stream_controls (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n label TEXT NOT NULL,\n command TEXT NOT NULL, -- command string sent to hardware\n icon TEXT DEFAULT 'fa-gamepad', -- Font Awesome icon class\n control_type TEXT DEFAULT 'button' CHECK(control_type IN ('button', 'toggle', 'slider', 'dpad', 'onvif', 'keyboard')),\n key_binding TEXT, -- keyboard shortcut\n cooldown_ms INTEGER DEFAULT 100,\n is_enabled INTEGER DEFAULT 1,\n sort_order INTEGER DEFAULT 0,\n camera_id INTEGER, -- NULL for non-ONVIF controls; references camera_profiles(id)\n onvif_movement TEXT, -- 'pan_left', 'pan_right', 'tilt_up', 'tilt_down', 'zoom_in', 'zoom_out', 'preset'\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, btn_color TEXT DEFAULT '', btn_bg TEXT DEFAULT '', btn_border_color TEXT DEFAULT '',\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,\n FOREIGN KEY (camera_id) REFERENCES camera_profiles(id) ON DELETE SET NULL\n)",
-- "CREATE TABLE stream_first_chats (\n chatter_key TEXT NOT NULL,\n channel_user_id INTEGER NOT NULL,\n first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (chatter_key, channel_user_id)\n )",
"CREATE TABLE stream_memories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n user_id INTEGER,\n offset_seconds INTEGER DEFAULT 0, -- seconds into the stream when captured\n captured_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n description TEXT,\n tags TEXT, -- JSON array\n thumbnail_url TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, transcript_json TEXT,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n )",
"CREATE TABLE stream_recaps (\n stream_id INTEGER PRIMARY KEY,\n user_id INTEGER,\n json TEXT NOT NULL,\n ai INTEGER DEFAULT 0,\n announced_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )",
"CREATE TABLE stream_timeline_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n user_id INTEGER,\n vod_id INTEGER, -- set once the session becomes a VOD\n kind TEXT NOT NULL, -- 'speech' | 'sound'\n start_sec REAL NOT NULL, -- absolute seconds into the stream\n end_sec REAL,\n text TEXT, -- speech content\n label TEXT, -- sound event label\n confidence REAL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, lang TEXT, text_en TEXT,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n )",
@@@ -85,7 +74,6 @@
"CREATE TABLE themes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL,\n slug TEXT UNIQUE NOT NULL,\n author_id INTEGER,\n description TEXT DEFAULT '',\n mode TEXT DEFAULT 'dark' CHECK(mode IN ('dark', 'light')),\n variables TEXT NOT NULL DEFAULT '{}', -- JSON: CSS variable overrides\n preview_colors TEXT DEFAULT '{}', -- JSON: {bg, accent, text} for quick preview\n is_builtin INTEGER DEFAULT 0,\n is_public INTEGER DEFAULT 1,\n downloads INTEGER DEFAULT 0,\n rating_sum INTEGER DEFAULT 0,\n rating_count INTEGER DEFAULT 0,\n tags TEXT DEFAULT '[]', -- JSON array\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL\n)",
"CREATE TABLE transactions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n from_user_id INTEGER,\n to_user_id INTEGER,\n stream_id INTEGER,\n amount INTEGER NOT NULL, -- in bits (cents)\n type TEXT NOT NULL CHECK(type IN ('donation', 'purchase', 'subscription', 'cashout', 'refund', 'bonus')),\n status TEXT DEFAULT 'completed' CHECK(status IN ('pending', 'completed', 'failed', 'escrow', 'refunded')),\n message TEXT,\n paypal_transaction_id TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE SET NULL,\n FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE SET NULL,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE SET NULL\n)",
"CREATE TABLE translations (\n key TEXT PRIMARY KEY,\n src TEXT, dst TEXT,\n text TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )",
-- "CREATE TABLE tts_voice_overrides (\n identity_key TEXT PRIMARY KEY,\n voice TEXT,\n pitch INTEGER,\n speed INTEGER,\n gap INTEGER DEFAULT 0,\n set_by INTEGER,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )",
"CREATE TABLE user_cosmetics (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n item_id TEXT NOT NULL, -- references ITEMS key (fx_rainbow, px_sparkle, hat_crown, voice_robot etc.)\n category TEXT NOT NULL, -- 'name_effect'|'particle'|'hat'|'voice'\n unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(user_id, item_id),\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n)",
"CREATE TABLE user_equipped (\n user_id INTEGER NOT NULL,\n slot TEXT NOT NULL, -- 'name_effect'|'particle'|'hat'|'voice'\n item_id TEXT NOT NULL,\n PRIMARY KEY (user_id, slot),\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n)",
"CREATE TABLE user_equipped_tag (\n user_id INTEGER NOT NULL PRIMARY KEY,\n tag_id TEXT NOT NULL,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
@@@ -120,16 +108,7 @@
"CREATE INDEX idx_cameras_stream_id ON cameras(stream_id)",
"CREATE INDEX idx_channel_ai_bots_channel ON channel_ai_bots(channel_user_id, is_active)",
"CREATE UNIQUE INDEX idx_channel_ai_bots_uname ON channel_ai_bots(channel_user_id, username)",
-- "CREATE INDEX idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command)",
-- "CREATE INDEX idx_channel_sounds_owner ON channel_sounds(channel_owner_id)",
"CREATE INDEX idx_channels_user_id ON channels(user_id)",
-- "CREATE INDEX idx_chat_autodelete ON chat_messages(auto_delete_at)",
-- "CREATE INDEX idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp)",
-- "CREATE INDEX idx_chat_stream_id ON chat_messages(stream_id)",
-- "CREATE INDEX idx_chat_stream_ts ON chat_messages(stream_id, timestamp)",
-- "CREATE INDEX idx_chat_timestamp ON chat_messages(timestamp)",
-- "CREATE INDEX idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted)",
-- "CREATE INDEX idx_chat_user_id ON chat_messages(user_id)",
"CREATE UNIQUE INDEX idx_clip_ai_state_clip_id_unique ON clip_ai_state(clip_id)",
"CREATE INDEX idx_clips_stream_id ON clips(stream_id)",
"CREATE INDEX idx_clips_user_id ON clips(user_id)",
@@@ -147,17 +126,10 @@
"CREATE INDEX idx_control_configs_user ON control_configs(user_id)",
"CREATE INDEX idx_control_whitelist_channel ON control_whitelist(channel_id)",
"CREATE INDEX idx_cp_log_user ON channel_points_log(user_id, streamer_id, id)",
-- "CREATE INDEX idx_dm_blocks_blocked ON dm_blocks(blocked_id)",
-- "CREATE INDEX idx_dm_blocks_blocker ON dm_blocks(blocker_id)",
-- "CREATE INDEX idx_dm_messages_conv ON dm_messages(conversation_id, created_at)",
-- "CREATE INDEX idx_dm_messages_sender ON dm_messages(sender_id)",
-- "CREATE INDEX idx_dm_participants_conv ON dm_participants(conversation_id)",
-- "CREATE INDEX idx_dm_participants_user ON dm_participants(user_id)",
"CREATE INDEX idx_egg_solves_date ON easter_egg_solves(egg_date)",
"CREATE INDEX idx_follows_follower ON follows(follower_id)",
"CREATE INDEX idx_follows_streamer ON follows(streamer_id)",
"CREATE INDEX idx_goals_active ON donation_goals(is_active)",
-- "CREATE INDEX idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform)",
"CREATE INDEX idx_ip_log_action ON ip_log(action)",
"CREATE INDEX idx_ip_log_created ON ip_log(created_at)",
"CREATE INDEX idx_ip_log_ip ON ip_log(ip_address)",
@@@ -171,9 +143,6 @@
"CREATE INDEX idx_media_requests_canonical ON media_requests(streamer_id, canonical_url, status)",
"CREATE INDEX idx_media_requests_streamer_status ON media_requests(streamer_id, status, queue_position, requested_at)",
"CREATE INDEX idx_media_requests_user_status ON media_requests(user_id, status, requested_at)",
-- "CREATE INDEX idx_mod_actions_actor ON moderation_actions(actor_user_id)",
-- "CREATE INDEX idx_mod_actions_created ON moderation_actions(created_at DESC)",
-- "CREATE INDEX idx_mod_actions_scope ON moderation_actions(scope_type, scope_id)",
"CREATE INDEX idx_openre_sessions_state ON openre_sessions(state)",
"CREATE INDEX idx_openre_sessions_stream ON openre_sessions(stream_id)",
"CREATE INDEX idx_paste_comments_ip ON paste_comments(ip_address)",
@@@ -188,13 +157,11 @@
"CREATE INDEX idx_pastes_visibility ON pastes(visibility)",
"CREATE INDEX idx_payment_orders_ref ON payment_orders(provider, provider_ref)",
"CREATE INDEX idx_payment_orders_user ON payment_orders(user_id)",
-- "CREATE INDEX idx_pending_ip_channel ON pending_ip_messages(channel_id, status)",
"CREATE INDEX idx_powerchat_conn_pcuid ON powerchat_connections(powerchat_user_id)",
"CREATE INDEX idx_powerchat_conn_username ON powerchat_connections(powerchat_username)",
"CREATE INDEX idx_restream_dest_managed ON restream_destinations(managed_stream_id)",
"CREATE INDEX idx_restream_dest_user ON restream_destinations(user_id)",
"CREATE UNIQUE INDEX idx_rs_integrations_user_slot\n ON robotstreamer_integrations(user_id, IFNULL(managed_stream_id, 0))",
-- "CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id)",
"CREATE UNIQUE INDEX idx_stream_memories_moment_unique ON stream_memories(stream_id, offset_seconds)",
"CREATE INDEX idx_stream_memories_stream ON stream_memories(stream_id, offset_seconds)",
"CREATE INDEX idx_stream_recaps_user ON stream_recaps(user_id, created_at)",
@@@ -240,31 +207,31 @@
"schema_migrations": [
{
"id": "001_vibes_decimal_to_bits",
-- "applied_at": "2026-10-05 00:02:52",
++ "applied_at": "2026-10-05 22:52:26",
"mode": "adopted",
-- "duration_ms": 0
++ "duration_ms": 1
},
{
"id": "002_pastes_ai_columns",
-- "applied_at": "2026-10-05 00:02:52",
++ "applied_at": "2026-10-05 22:52:26",
"mode": "applied",
-- "duration_ms": 5
++ "duration_ms": 3
},
{
"id": "004_hot_path_indexes",
-- "applied_at": "2026-10-05 00:02:52",
++ "applied_at": "2026-10-05 22:52:26",
"mode": "applied",
"duration_ms": 1
},
{
"id": "005_drop_chat_staged_tables",
-- "applied_at": "2026-10-05 00:02:52",
++ "applied_at": "2026-10-05 22:52:26",
"mode": "adopted",
"duration_ms": 0
},
{
"id": "006_drop_emotes",
-- "applied_at": "2026-10-05 00:02:52",
++ "applied_at": "2026-10-05 22:52:26",
"mode": "adopted",
"duration_ms": 0
}
@@@ -304,7 +271,6 @@
"DELETE FROM camera_profiles WHERE id = ?",
"DELETE FROM channel_ai_bots WHERE id = ?",
"DELETE FROM channel_points WHERE user_id = ? AND streamer_id = ?",
-- "DELETE FROM channel_sounds WHERE id = ?",
"DELETE FROM clip_ai_state WHERE clip_id = ?",
"DELETE FROM coin_rewards WHERE id = ?",
"DELETE FROM content_views WHERE content_type = ? AND content_id = ?",
@@@ -312,14 +278,9 @@
"DELETE FROM control_config_buttons WHERE id = ? AND config_id = ?",
"DELETE FROM control_configs WHERE id = ?",
"DELETE FROM control_whitelist WHERE id = ? AND channel_id = ?",
-- "DELETE FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?",
-- "DELETE FROM dm_messages WHERE id = ?",
-- "DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?",
"DELETE FROM donation_goals WHERE id = ? AND user_id = ?",
"DELETE FROM follows WHERE follower_id = ? AND streamer_id = ?",
"DELETE FROM follows WHERE id = ?",
-- "DELETE FROM hidden_relay_users WHERE id = ?",
-- "DELETE FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND channel_id IS ?",
"DELETE FROM lineage_unresolved WHERE last_at < ?",
"DELETE FROM linked_accounts WHERE service = 'hobotools'",
"DELETE FROM managed_streams WHERE id = ? AND user_id = ?",
@@@ -339,7 +300,6 @@
"DELETE FROM themes WHERE id = ?",
"DELETE FROM tips_deliveries WHERE created_at < datetime('now', ?)",
"DELETE FROM tips_deliveries WHERE idempotency_key = ? AND state = 'pending'",
-- "DELETE FROM tts_voice_overrides WHERE identity_key = ?",
"DELETE FROM user_cosmetics WHERE user_id = ? AND item_id = ?",
"DELETE FROM user_equipped WHERE user_id = ? AND slot = ?",
"DELETE FROM user_equipped WHERE user_id = ? AND slot = ? AND item_id = ?",
@@@ -365,7 +325,6 @@
"INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO bans (stream_id, user_id, ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
-- "INSERT INTO bans (stream_id, user_id, reason, banned_by) VALUES (?, ?, ?, ?)",
"INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO bans (user_id, ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO bans (user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?)",
@@@ -378,16 +337,12 @@
"INSERT INTO channel_points (user_id, streamer_id, balance) VALUES (?, ?, ?)",
"INSERT INTO channel_points (user_id, streamer_id, balance, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id, streamer_id) DO UPDATE SET balance = balance + excluded.balance, updated_at = CURRENT_TIMESTAMP",
"INSERT INTO channel_points_log (idempotency_key, user_id, streamer_id, delta, reason) VALUES (?, ?, ?, ?, ?)",
-- "INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
-- "INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO coin_redemptions (reward_id, user_id, stream_id, user_input) VALUES (?, ?, ?, ?)",
"INSERT INTO coin_rewards (streamer_id, title, description, cost, icon, color, cooldown_seconds, max_per_stream, requires_input, is_global, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO coin_transactions (user_id, stream_id, amount, type, reward_id, message) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO control_config_buttons (config_id, label, command, icon, control_type, key_binding, cooldown_ms, sort_order, btn_color, btn_bg, btn_border_color) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', '', '')",
"INSERT INTO control_config_buttons (config_id, label, command, icon, control_type, key_binding, cooldown_ms, sort_order, btn_color, btn_bg, btn_border_color) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO control_configs (user_id, name, description) VALUES (?, ?, ?)",
-- "INSERT INTO dm_conversations (name, is_group, created_by) VALUES (?, ?, ?)",
-- "INSERT INTO dm_messages (conversation_id, sender_id, message) VALUES (?, ?, ?)",
"INSERT INTO donation_goals (user_id, title, target_amount) VALUES (?, ?, ?)",
"INSERT INTO donation_goals (user_id, title, target_amount, image_url, media_type, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO follow_projection_revisions (follower_subject, target_subject, revision) VALUES (?, ?, ?) ON CONFLICT (follower_subject, target_subject) DO UPDATE SET revision = excluded.revision",
@@@ -398,7 +353,6 @@
"INSERT INTO managed_streams (user_id, channel_id, title, category, protocol, stream_key, is_nsfw) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO media_request_settings ( user_id, enabled, request_cost, max_per_user, max_duration_seconds, allow_youtube, allow_vimeo, allow_direct_media, auto_advance, cost_mode, cost_per_minute, allow_live, download_mode, currency ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO media_requests ( streamer_id, stream_id, user_id, username, input, canonical_url, embed_url, provider, title, thumbnail_url, duration_seconds, cost, queue_position, currency, status, charge_state ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
-- "INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO module_summary_pushes (user_id, namespace, hash, pushed_at) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, namespace) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at",
"INSERT INTO news_settings (scope, scope_id, source_id, enabled, config) VALUES ('global', NULL, ?, ?, ?) ON CONFLICT(scope, scope_id, source_id) DO UPDATE SET enabled = excluded.enabled, config = excluded.config, updated_at = CURRENT_TIMESTAMP",
"INSERT INTO news_settings (scope, scope_id, source_id, enabled, config) VALUES ('user', ?, '_master', ?, '{}') ON CONFLICT(scope, scope_id, source_id) DO UPDATE SET enabled = excluded.enabled, updated_at = CURRENT_TIMESTAMP",
@@@ -406,9 +360,7 @@
"INSERT INTO openre_sessions (session_id, managed_stream_id, stream_id, state, revision, ended_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(session_id) DO UPDATE SET state = excluded.state, revision = excluded.revision, ended_at = excluded.ended_at, updated_at = CURRENT_TIMESTAMP",
"INSERT INTO openre_sessions (session_id, managed_stream_id, stream_id, state, revision, started_at, confirmed_at, updated_at) VALUES (?, ?, ?, 'live', ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(session_id) DO UPDATE SET stream_id = excluded.stream_id, state = 'live', revision = excluded.revision, confirmed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP",
"INSERT INTO payment_orders (user_id, provider, provider_ref, kind, amount_cents, currency, bucks, streamer_id, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
-- "INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, user_id, anon_id, username, message) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO platform_connections (user_id, platform, platform_user_id, platform_username, channel_url, access_token, refresh_token, token_expires_at, scope) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
-- "INSERT INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1) ON CONFLICT(platform, username) DO UPDATE SET last_seen = CURRENT_TIMESTAMP, message_count = message_count + 1, display_name = excluded.display_name",
"INSERT INTO restream_destinations (user_id, managed_stream_id, platform, name, server_url, stream_key, enabled, auto_start, quality_preset, custom_video_bitrate, custom_audio_bitrate, custom_fps, custom_encoder_preset, srt_latency_ms, srt_passphrase, channel_url, chat_relay, powerchat_relay, powerchat_count_views) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO schema_migrations (id, mode, duration_ms) VALUES (?, ?, ?)",
"INSERT INTO search_doc_pushes (user_id, hash, revision, deleted, pushed_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET hash = excluded.hash, revision = excluded.revision, deleted = excluded.deleted, pushed_at = excluded.pushed_at",
@@@ -426,7 +378,6 @@
"INSERT INTO themes (name, slug, mode, description, variables, preview_colors, is_builtin, is_public, tags) VALUES (?, ?, ?, ?, ?, ?, 1, 1, ?)",
"INSERT INTO token_revocations (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at WHERE excluded.valid_after_ms > token_revocations.valid_after_ms",
"INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status, message) VALUES (?, ?, ?, ?, ?, ?, ?)",
-- "INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(identity_key) DO UPDATE SET voice=excluded.voice, pitch=excluded.pitch, speed=excluded.speed, gap=excluded.gap, set_by=excluded.set_by, updated_at=CURRENT_TIMESTAMP",
"INSERT INTO user_preferences (user_id, chat_settings, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET chat_settings = excluded.chat_settings, updated_at = CURRENT_TIMESTAMP",
"INSERT INTO user_themes (user_id, theme_id, custom_variables, is_custom) VALUES (?, ?, ?, ?)",
"INSERT INTO username_history (user_id, old_username, new_username) VALUES (?, ?, ?)",
@@@ -448,25 +399,19 @@
"INSERT OR IGNORE INTO comment_thread_refs (content_type, content_id, thread_id, access_id) VALUES (?, ?, ?, ?)",
"INSERT OR IGNORE INTO content_views (content_type, content_id, ip) VALUES (?, ?, ?)",
"INSERT OR IGNORE INTO control_whitelist (channel_id, user_id, added_by) VALUES (?, ?, ?)",
-- "INSERT OR IGNORE INTO dm_blocks (blocker_id, blocked_id) VALUES (?, ?)",
-- "INSERT OR IGNORE INTO dm_participants (conversation_id, user_id) VALUES (?, ?)",
"INSERT OR IGNORE INTO easter_egg_solves (egg_date, solver_key, user_id) VALUES (?, ?, ?)",
"INSERT OR IGNORE INTO follows (follower_id, streamer_id) VALUES (?, ?)",
"INSERT OR IGNORE INTO linked_accounts (service, service_user_id, service_username, user_id) VALUES ('network', ?, ?, ?)",
"INSERT OR IGNORE INTO linked_accounts (user_id, service, service_user_id, service_username) VALUES (?, 'network', ?, ?)",
"INSERT OR IGNORE INTO powerchat_webhook_deliveries (delivery_id, event_type) VALUES (?, ?)",
-- "INSERT OR IGNORE INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count) SELECT source_platform, LOWER(TRIM(SUBSTR(username, INSTR(username, '] ') + 2))), TRIM(SUBSTR(username, INSTR(username, '] ') + 2)), MIN(timestamp), MAX(timestamp), COUNT(*) FROM chat_messages WHERE user_id IS NULL AND source_platform IS NOT NULL AND username LIKE '[%] %' AND INSTR(username, '] ') > 0 GROUP BY source_platform, LOWER(TRIM(SUBSTR(username, INSTR(username, '] ') + 2)))",
"INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)",
-- "INSERT OR IGNORE INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)",
"INSERT OR IGNORE INTO stream_memories (stream_id, user_id, offset_seconds, description, tags, thumbnail_url, transcript_json) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT OR IGNORE INTO subject_merges (merge_id, from_subject, into_subject, from_user_id, into_user_id, outcome) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT OR IGNORE INTO tips_deliveries (idempotency_key, effect, state, claimed_at) VALUES (?, ?, 'pending', ?)",
"INSERT OR IGNORE INTO user_cosmetics (user_id, item_id, category) VALUES (?, ?, ?)",
"INSERT OR IGNORE INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, 'user')",
"INSERT OR IGNORE INTO vod_ai_state (vod_id) VALUES (?)",
-- "INSERT OR REPLACE INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT OR REPLACE INTO site_settings (key, value, description, type) VALUES ('bucks_bits_migration_done', '1', 'Internal: decimal→bit Vibes migration applied (see schema_migrations)', 'boolean')",
-- "INSERT OR REPLACE INTO site_settings (key, value, type) VALUES ('relay_users_backfilled','1','string')",
"INSERT OR REPLACE INTO translations (key, src, dst, text) VALUES (?, ?, ?, ?)",
"INSERT OR REPLACE INTO user_equipped (user_id, slot, item_id) VALUES (?, ?, ?)",
"SELECT * FROM ai_chatbot_configs",
@@@ -513,13 +458,9 @@
"SELECT * FROM channel_ai_bots WHERE channel_user_id = ? ORDER BY last_active_at DESC, created_at ASC",
"SELECT * FROM channel_ai_bots WHERE id = ?",
"SELECT * FROM channel_ai_config WHERE user_id = ?",
-- "SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND command = ? AND is_approved = 1 ORDER BY RANDOM() LIMIT 1",
-- "SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND is_approved = 1 ORDER BY command",
-- "SELECT * FROM channel_sounds WHERE id = ?",
"SELECT * FROM channels WHERE id = ?",
"SELECT * FROM channels WHERE user_id = ?",
"SELECT * FROM channels WHERE user_id IN (?)",
-- "SELECT * FROM chat_messages WHERE id = ?",
"SELECT * FROM clip_ai_state WHERE clip_id = ?",
"SELECT * FROM coin_rewards WHERE id = ?",
"SELECT * FROM coin_rewards WHERE is_global = 1 AND is_enabled = 1 ORDER BY sort_order, cost",
@@@ -529,8 +470,6 @@
"SELECT * FROM control_configs WHERE id = ?",
"SELECT * FROM control_configs WHERE user_id = ?",
"SELECT * FROM control_configs WHERE user_id = ? ORDER BY created_at DESC",
-- "SELECT * FROM dm_conversations WHERE id = ?",
-- "SELECT * FROM dm_messages WHERE id = ?",
"SELECT * FROM donation_goals WHERE id = ?",
"SELECT * FROM donation_goals WHERE user_id = ? AND (is_active = 1 OR (reached_at IS NOT NULL AND reached_at > datetime('now', ?))) ORDER BY sort_order ASC, created_at ASC",
"SELECT * FROM donation_goals WHERE user_id = ? AND is_active = 1 ORDER BY created_at",
@@@ -557,9 +496,6 @@
"SELECT * FROM payment_orders WHERE id = ?",
"SELECT * FROM payment_orders WHERE provider = 'powerchat' AND status = 'pending' AND created_at >= datetime('now', ?) ORDER BY id ASC",
"SELECT * FROM payment_orders WHERE provider = ? AND provider_ref = ? ORDER BY id DESC LIMIT 1",
-- "SELECT * FROM pending_ip_messages WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
-- "SELECT * FROM pending_ip_messages WHERE channel_id = ? AND status = 'pending' ORDER BY created_at ASC LIMIT ?",
-- "SELECT * FROM pending_ip_messages WHERE id = ?",
"SELECT * FROM platform_connections WHERE id = ?",
"SELECT * FROM platform_connections WHERE user_id = ? AND platform = ?",
"SELECT * FROM platform_connections WHERE user_id = ? ORDER BY platform",
@@@ -613,14 +549,8 @@
"SELECT 1 FROM approved_ips WHERE channel_id = ? AND ip_address = ?",
"SELECT 1 FROM channel_ai_config WHERE user_id = ?",
"SELECT 1 FROM channel_points WHERE user_id = ? AND streamer_id = ?",
-- "SELECT 1 FROM chat_messages cm JOIN streams s ON cm.stream_id = s.id WHERE s.channel_id = ? AND cm.is_deleted = 0 AND (cm.user_id = ? OR cm.anon_id = ?) LIMIT 1",
"SELECT 1 FROM control_whitelist WHERE channel_id = ? AND user_id = ?",
-- "SELECT 1 FROM dm_blocks WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)",
-- "SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?",
-- "SELECT 1 FROM dm_messages WHERE conversation_id = ? AND sender_id = ? AND message = ? AND created_at > datetime('now', '-' || ? || ' seconds') LIMIT 1",
-- "SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?",
"SELECT 1 FROM easter_egg_solves WHERE egg_date = ? AND solver_key = ?",
-- "SELECT 1 FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND (channel_id = ? OR channel_id IS NULL)",
"SELECT 1 FROM managed_streams WHERE user_id = ? AND ingest_authority = 'openre' LIMIT 1",
"SELECT 1 FROM media_request_settings WHERE user_id = ? LIMIT 1",
"SELECT 1 FROM module_summary_pushes WHERE user_id = ? AND namespace = 'live.loyalty'",
@@@ -629,7 +559,6 @@
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?",
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
-- "SELECT 1 FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?",
"SELECT 1 FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND created_at >= datetime('now', '-30 minutes') LIMIT 1",
"SELECT 1 FROM streams WHERE user_id = ? AND is_live = 1 LIMIT 1",
"SELECT 1 FROM streams WHERE user_id = ? LIMIT 1",
@@@ -638,10 +567,7 @@
"SELECT AVG(CASE WHEN sampled_at >= datetime('now','-1 day') AND viewers > 0 THEN viewers END) AS vAvg24, MAX(CASE WHEN sampled_at >= datetime('now','-1 day') THEN viewers END) AS vPeak24, AVG(CASE WHEN viewers > 0 THEN viewers END) AS vAvg7, AVG(CASE WHEN sampled_at >= datetime('now','-1 day') AND live_streams > 0 THEN live_streams END) AS lAvg24, MAX(CASE WHEN sampled_at >= datetime('now','-1 day') THEN live_streams END) AS lPeak24, AVG(CASE WHEN live_streams > 0 THEN live_streams END) AS lAvg7, COUNT(*) AS samples FROM viewer_samples WHERE sampled_at >= datetime('now','-7 days')",
"SELECT AVG(viewer_count) as avg_vc FROM viewer_snapshots WHERE stream_id = ?",
"SELECT CASE WHEN a_user_id = ? THEN b_user_id ELSE a_user_id END AS opp, COUNT(*) AS n FROM arena_beefs WHERE (a_user_id = ? OR b_user_id = ?) GROUP BY opp HAVING n >= 2 ORDER BY n DESC LIMIT ?",
-- "SELECT CAST((julianday(cm.timestamp) - julianday(s.started_at)) * 86400 / ? AS INT) AS bucket, COUNT(*) AS n FROM chat_messages cm JOIN streams s ON s.id = cm.stream_id WHERE cm.stream_id = ? AND s.started_at IS NOT NULL AND cm.timestamp >= s.started_at AND COALESCE(cm.is_deleted, 0) = 0 GROUP BY bucket HAVING bucket >= 0 ORDER BY n DESC LIMIT ?",
-- "SELECT CAST(strftime('%s', cm.timestamp) / ? AS INT) AS b, COUNT(*) AS n, MIN(CAST(strftime('%s', cm.timestamp) AS INT)) AS ts FROM chat_messages cm WHERE cm.stream_id = ? AND COALESCE(cm.is_deleted, 0) = 0 AND cm.timestamp >= datetime('now', ?) GROUP BY b ORDER BY ts ASC",
"SELECT COALESCE(-SUM(amount), 0) AS count FROM coin_transactions WHERE amount < 0",
-- "SELECT COALESCE(MAX(id),0) AS m FROM chat_messages WHERE channel_user_id = ?",
"SELECT COALESCE(MAX(sort_order),-1)+1 AS n FROM donation_goals WHERE user_id = ?",
"SELECT COALESCE(NULLIF(ai_category, ''), category) AS category, COUNT(*) AS n FROM streams WHERE user_id = ? AND duration_seconds > 0 AND COALESCE(NULLIF(ai_category, ''), category) IS NOT NULL AND COALESCE(NULLIF(ai_category, ''), category) != '' GROUP BY 1 ORDER BY n DESC LIMIT 1",
"SELECT COALESCE(SUM(-amount), 0) AS count FROM coin_transactions WHERE created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days') AND amount < 0",
@@@ -667,7 +593,6 @@
"SELECT COALESCE(SUM(duration_seconds), 0) AS sec FROM streams WHERE user_id = ? AND duration_seconds > 0 AND id IN (SELECT DISTINCT stream_id FROM stream_timeline_events WHERE user_id = ? AND created_at >= datetime('now', ?))",
"SELECT COALESCE(SUM(minutes_watched), 0) AS count FROM watch_time",
"SELECT COALESCE(SUM(openvibe_bucks_balance), 0) as c FROM users",
-- "SELECT COALESCE(SUM(unread), 0) as total FROM ( SELECT COUNT(*) as unread FROM dm_messages m JOIN dm_participants p ON p.conversation_id = m.conversation_id AND p.user_id = ? WHERE m.created_at > p.last_read_at AND m.sender_id != ? )",
"SELECT COALESCE(SUM(viewer_count), 0) AS count FROM streams WHERE is_live = 1",
"SELECT COALESCE(SUM(viewer_count), 0) as c FROM streams WHERE is_live = 1",
"SELECT COALESCE(SUM(viewer_count),0) AS v, COUNT(*) AS n FROM streams WHERE is_live = 1",
@@@ -676,20 +601,14 @@
"SELECT COALESCE(text_en, text) AS t, start_sec AS s FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' ORDER BY start_sec ASC",
"SELECT COUNT(*) - COUNT(DISTINCT stream_id || ':' || offset_seconds) AS extra FROM stream_memories",
"SELECT COUNT(*) AS c FROM channel_ai_config",
-- "SELECT COUNT(*) AS c FROM chat_messages WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0 AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)",
"SELECT COUNT(*) AS c FROM media_requests WHERE streamer_id = ? AND user_id = ? AND status IN ('pending', 'playing')",
"SELECT COUNT(*) AS c FROM stream_memories WHERE stream_id = ?",
"SELECT COUNT(*) AS c, COALESCE(MAX(id), 0) AS m, COALESCE(SUM(id * 7 + COALESCE(stream_id, 0) + LENGTH(COALESCE(expires_at, ''))), 0) AS s FROM bans",
"SELECT COUNT(*) AS calls, COALESCE(SUM(input_tokens),0) AS input_tokens, COALESCE(SUM(output_tokens),0) AS output_tokens, COALESCE(SUM(cached_tokens),0) AS cached_tokens, COALESCE(SUM(cost_usd),0) AS cost_usd FROM ai_usage WHERE created_at >= date('now', ?)",
-- "SELECT COUNT(*) AS count FROM ( SELECT DISTINCT 'u' || user_id AS id FROM chat_messages WHERE user_id IS NOT NULL AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-14 days') AND timestamp < datetime('now', '-7 days') UNION SELECT DISTINCT 'a' || anon_id FROM chat_messages WHERE anon_id IS NOT NULL AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-14 days') AND timestamp < datetime('now', '-7 days') UNION SELECT DISTINCT 'r' || source_platform || '|' || username FROM chat_messages WHERE source_platform IS NOT NULL AND source_platform <> '' AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-14 days') AND timestamp < datetime('now', '-7 days') )",
-- "SELECT COUNT(*) AS count FROM ( SELECT DISTINCT 'u' || user_id AS id FROM chat_messages WHERE user_id IS NOT NULL AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-7 days') UNION SELECT DISTINCT 'a' || anon_id FROM chat_messages WHERE anon_id IS NOT NULL AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-7 days') UNION SELECT DISTINCT 'r' || source_platform || '|' || username FROM chat_messages WHERE source_platform IS NOT NULL AND source_platform <> '' AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-7 days') )",
"SELECT COUNT(*) AS count FROM anon_ip_mappings",
"SELECT COUNT(*) AS count FROM anon_ip_mappings WHERE created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')",
"SELECT COUNT(*) AS count FROM anon_ip_mappings WHERE created_at >= datetime('now', '-7 days')",
"SELECT COUNT(*) AS count FROM anon_ip_mappings WHERE created_at >= datetime('now', ?)",
-- "SELECT COUNT(*) AS count FROM chat_messages",
-- "SELECT COUNT(*) AS count FROM chat_messages WHERE timestamp >= datetime('now', '-14 days') AND timestamp < datetime('now', '-7 days') AND COALESCE(is_deleted, 0) = 0",
-- "SELECT COUNT(*) AS count FROM chat_messages WHERE timestamp >= datetime('now', ?) AND COALESCE(is_deleted, 0) = 0",
"SELECT COUNT(*) AS count FROM coin_redemptions WHERE created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days') AND status NOT IN ('rejected', 'refunded')",
"SELECT COUNT(*) AS count FROM coin_redemptions WHERE created_at >= datetime('now', ?) AND status NOT IN ('rejected', 'refunded')",
"SELECT COUNT(*) AS count FROM coin_redemptions WHERE status NOT IN ('rejected', 'refunded')",
@@@ -722,9 +641,6 @@
"SELECT COUNT(*) AS n FROM arena_mic_moments WHERE said_at >= ?",
"SELECT COUNT(*) AS n FROM arena_mic_moments WHERE stream_id = ?",
"SELECT COUNT(*) AS n FROM channel_ai_bots WHERE channel_user_id = ?",
-- "SELECT COUNT(*) AS n FROM channel_sounds WHERE channel_owner_id = ?",
-- "SELECT COUNT(*) AS n FROM chat_messages WHERE stream_id = ? AND message_type = 'soundboard'",
-- "SELECT COUNT(*) AS n FROM chat_messages WHERE timestamp >= ? AND COALESCE(is_deleted, 0) = 0",
"SELECT COUNT(*) AS n FROM control_configs WHERE user_id = ?",
"SELECT COUNT(*) AS n FROM donation_goals WHERE user_id = ? AND is_active = 1",
"SELECT COUNT(*) AS n FROM easter_egg_solves WHERE egg_date = ?",
@@@ -743,16 +659,11 @@
"SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0) AS t FROM transactions WHERE type = 'donation' AND (stream_id = ? OR (to_user_id = ? AND created_at BETWEEN ? AND ?))",
"SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0) AS total FROM transactions WHERE type = 'donation' AND COALESCE(status, 'completed') NOT IN ('failed', 'refunded', 'pending') AND ((stream_id = ?) OR (to_user_id = ? AND created_at BETWEEN ? AND ?))",
"SELECT COUNT(*) AS n, COALESCE(SUM(s.duration_seconds), 0) AS secs, COALESCE(MAX(s.peak_viewers), 0) AS peak, AVG(a.avg_viewers) AS avg FROM streams s LEFT JOIN stream_analytics a ON a.stream_id = s.id WHERE s.user_id = ? AND s.started_at >= ? AND s.is_live = 0",
-- "SELECT COUNT(*) AS n, COUNT(DISTINCT COALESCE(user_id, anon_id, username)) AS c FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0",
-- "SELECT COUNT(*) AS n, COUNT(DISTINCT COALESCE(user_id, anon_id, username)) AS chatters FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0",
"SELECT COUNT(*) AS n, MAX(started_at) AS last FROM streams WHERE user_id = ?",
"SELECT COUNT(*) AS n, MIN(started_at) AS first FROM streams WHERE user_id = ?",
"SELECT COUNT(*) AS streams, COALESCE(SUM(duration_seconds), 0) / 3600.0 AS hours, MAX(ended_at) AS last_live_at FROM streams WHERE user_id = ? AND duration_seconds > 0 AND started_at >= datetime('now', ?)",
-- "SELECT COUNT(*) as c FROM chat_messages",
-- "SELECT COUNT(*) as c FROM chat_messages WHERE user_id = ? AND is_deleted = 0 AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)",
"SELECT COUNT(*) as c FROM coin_redemptions WHERE reward_id = ? AND stream_id = ?",
"SELECT COUNT(*) as c FROM content_views WHERE content_type = ? AND content_id = ?",
-- "SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?",
"SELECT COUNT(*) as c FROM follows WHERE follower_id = ?",
"SELECT COUNT(*) as c FROM follows WHERE streamer_id = ?",
"SELECT COUNT(*) as c FROM site_settings",
@@@ -765,19 +676,12 @@
"SELECT COUNT(*) as c FROM users WHERE role = 'mod'",
"SELECT COUNT(*) as c FROM users WHERE role IN ('streamer', 'admin')",
"SELECT COUNT(*) as c FROM users WHERE username LIKE ? OR display_name LIKE ?",
-- "SELECT COUNT(*) as cnt FROM chat_messages WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0",
-- "SELECT COUNT(*) as cnt FROM chat_messages WHERE stream_id = ? AND is_deleted = 0 AND is_global = 0 AND message_type = 'chat'",
-- "SELECT COUNT(*) as cnt FROM chat_messages WHERE stream_id = ? AND timestamp >= ? AND is_deleted = 0 AND is_global = 0 AND message_type = 'chat'",
-- "SELECT COUNT(*) as cnt FROM chat_messages WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0",
"SELECT COUNT(*) as cnt FROM follows WHERE streamer_id = ?",
"SELECT COUNT(*) as cnt FROM follows WHERE streamer_id = ? AND created_at >= ? AND created_at <= ?",
"SELECT COUNT(*) as cnt FROM streams WHERE user_id = ? AND duration_seconds > 0",
-- "SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ?",
-- "SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ? AND created_by = ?",
"SELECT COUNT(*) as count FROM follows WHERE streamer_id = ?",
"SELECT COUNT(*) as total_streams, SUM(duration_seconds) as total_duration, MAX(peak_viewers) as peak_viewers FROM streams WHERE user_id = ? AND duration_seconds > 0",
"SELECT COUNT(*) as total_streams, SUM(s.duration_seconds) as total_duration, MAX(s.peak_viewers) as all_time_peak, AVG(sa.avg_viewers) as avg_viewers_per_stream, SUM(sa.total_messages) as total_messages, SUM(sa.unique_chatters) as total_unique_chatters, SUM(sa.total_watch_minutes) as total_watch_minutes, SUM(sa.new_followers) as total_new_followers, SUM(sa.clips_created) as total_clips FROM streams s LEFT JOIN stream_analytics sa ON sa.stream_id = s.id WHERE s.user_id = ? AND s.started_at >= ? AND s.duration_seconds > 0",
-- "SELECT COUNT(DISTINCT COALESCE(user_id, anon_id)) as cnt FROM chat_messages WHERE stream_id = ? AND is_deleted = 0 AND is_global = 0",
"SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL AND created_at >= ?",
"SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL AND created_at >= datetime('now', ?)",
"SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL AND created_at >= datetime('now', ?) AND created_at < datetime('now', ?)",
@@@ -795,9 +699,7 @@
"SELECT DISTINCT user_id AS id FROM streams UNION SELECT user_id FROM search_doc_pushes",
"SELECT DISTINCT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ?",
"SELECT MAX(anon_num) as m FROM anon_ip_mappings",
-- "SELECT MAX(id) AS id FROM chat_messages",
"SELECT MAX(queue_position) AS max_pos FROM media_requests WHERE streamer_id = ? AND status = 'pending'",
-- "SELECT MIN(timestamp) AS t FROM chat_messages WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0",
"SELECT ROUND(AVG(viewer_count), 1) AS a, COUNT(*) AS k FROM viewer_snapshots WHERE stream_id = ?",
"SELECT ROUND(SUM(COALESCE(duration_seconds, CASE WHEN ended_at IS NOT NULL THEN (julianday(ended_at) - julianday(started_at)) * 86400 ELSE (julianday('now') - julianday(started_at)) * 86400 END)) / 3600.0, 1) AS h FROM streams WHERE started_at >= ?",
"SELECT SUM(CASE WHEN event = 'line' THEN 1 ELSE 0 END) AS lines, SUM(CASE WHEN event = 'tick' THEN 1 ELSE 0 END) AS ticks, SUM(CASE WHEN event = 'skip' THEN 1 ELSE 0 END) AS skips, COALESCE(SUM(cost_usd),0) AS cost_usd, COALESCE(SUM(tokens_in),0) AS tokens_in, COALESCE(SUM(tokens_cached),0) AS tokens_cached FROM ai_viewer_log WHERE channel_user_id = ? AND created_at > datetime('now', ?)",
@@@ -817,11 +719,7 @@
"SELECT applied_at FROM schema_migrations WHERE id = ?",
"SELECT balance FROM channel_points WHERE user_id = ? AND streamer_id = ?",
"SELECT broadcast_settings FROM managed_streams WHERE id = ? AND user_id = ?",
-- "SELECT c.* FROM dm_conversations c JOIN dm_participants p1 ON p1.conversation_id = c.id AND p1.user_id = ? JOIN dm_participants p2 ON p2.conversation_id = c.id AND p2.user_id = ? WHERE c.is_group = 0",
"SELECT c.*, u.username, u.display_name, u.avatar_url, u.profile_color, u.bio, u.stream_key, u.role, u.is_owner FROM channels c JOIN users u ON c.user_id = u.id WHERE u.username = ? COLLATE NOCASE",
-- "SELECT c.id, c.name, c.is_group, c.updated_at, (SELECT dm.message FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message, (SELECT dm.sender_id FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_sender_id, (SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message_at, (SELECT COUNT(*) FROM dm_messages dm WHERE dm.conversation_id = c.id AND dm.created_at > p.last_read_at AND dm.sender_id != ?) AS unread_count FROM dm_conversations c JOIN dm_participants p ON p.conversation_id = c.id AND p.user_id = ? ORDER BY COALESCE((SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1), c.created_at) DESC",
-- "SELECT c.username, u.display_name, u.avatar_url, u.profile_color, COUNT(*) AS n FROM chat_messages c LEFT JOIN users u ON u.id = c.user_id WHERE c.stream_id = ? AND COALESCE(c.is_deleted, 0) = 0 AND c.username IS NOT NULL AND (c.user_id IS NULL OR c.user_id != ?) GROUP BY COALESCE(c.user_id, c.username) ORDER BY n DESC LIMIT 3",
-- "SELECT c.username, u.display_name, u.avatar_url, u.profile_color, COUNT(*) AS n FROM chat_messages c LEFT JOIN users u ON u.id = c.user_id WHERE c.stream_id = ? AND COALESCE(c.is_deleted, 0) = 0 AND c.username IS NOT NULL AND (c.user_id IS NULL OR c.user_id != ?) GROUP BY COALESCE(c.user_id, c.username) ORDER BY n DESC LIMIT 5",
"SELECT category, is_nsfw FROM streams WHERE id = ?",
"SELECT chat_language FROM channels WHERE user_id = ?",
"SELECT chat_settings FROM user_preferences WHERE user_id = ?",
@@@ -829,17 +727,10 @@
"SELECT clip_id AS id, s.* FROM clip_ai_state s WHERE ai_transcript_json IS NULL AND (transcript_status IS NULL OR transcript_status IN ('pending','retry')) AND (transcript_next_at IS NULL OR transcript_next_at <= CURRENT_TIMESTAMP) ORDER BY (transcript_status='retry'), clip_id DESC LIMIT ?",
"SELECT clip_id FROM clip_ai_state WHERE COALESCE(clip_notified,0) = 0 AND clip_notify_at IS NOT NULL AND clip_notify_at <= CURRENT_TIMESTAMP LIMIT ?",
"SELECT clips_created FROM stream_analytics WHERE stream_id = ?",
-- "SELECT cm.*, s.title as stream_title FROM chat_messages cm LEFT JOIN streams s ON cm.stream_id = s.id WHERE cm.user_id = ? AND cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?",
-- "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name FROM chat_messages cm LEFT JOIN users u ON cm.user_id = u.id WHERE cm.stream_id = ? AND cm.is_deleted = 0 AND cm.message_type = 'chat' AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)",
-- "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, COALESCE(su.username, cu.username) AS stream_channel, s.is_live AS source_is_live, s.managed_stream_id AS source_managed_id, COALESCE(ms.title, s.title) AS source_stream_title, ms.slug AS source_slug FROM chat_messages cm LEFT JOIN users u ON cm.user_id = u.id LEFT JOIN streams s ON cm.stream_id = s.id LEFT JOIN users su ON s.user_id = su.id LEFT JOIN users cu ON cm.channel_user_id = cu.id LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id WHERE cm.is_deleted = 0 AND cm.message_type IN ('chat', 'system', 'channel-sound', 'soundboard', 'donation') AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)",
-- "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, s.title AS source_stream_title, s.managed_stream_id AS source_managed_id, s.is_live AS source_is_live, ms.slug AS source_slug, COALESCE(bu.username, cu.username) AS source_channel FROM chat_messages cm LEFT JOIN users u ON cm.user_id = u.id LEFT JOIN streams s ON cm.stream_id = s.id LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id LEFT JOIN users bu ON s.user_id = bu.id LEFT JOIN users cu ON cm.channel_user_id = cu.id WHERE cm.channel_user_id = ? AND cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)",
-- "SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color FROM chat_messages cm LEFT JOIN users u ON cm.user_id = u.id WHERE cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)",
-- "SELECT cm.user_id, cm.username, COUNT(*) as message_count FROM chat_messages cm JOIN streams s ON cm.stream_id = s.id WHERE s.user_id = ? AND cm.timestamp >= ? AND cm.is_deleted = 0 AND cm.is_global = 0 AND cm.message_type = 'chat' AND cm.user_id IS NOT NULL GROUP BY cm.user_id ORDER BY message_count DESC LIMIT 20",
"SELECT consecutive_failures FROM restream_destinations WHERE id = ?",
"SELECT created_at FROM anon_ip_mappings WHERE anon_num = ?",
"SELECT created_at FROM anon_ip_mappings WHERE ip = ?",
"SELECT created_at FROM coin_redemptions WHERE reward_id = ? AND user_id = ? ORDER BY created_at DESC LIMIT 1",
-- "SELECT created_at FROM users WHERE id = ?",
"SELECT cw.id, cw.user_id, u.username, u.display_name, cw.created_at FROM control_whitelist cw JOIN users u ON cw.user_id = u.id WHERE cw.channel_id = ? ORDER BY cw.created_at DESC",
"SELECT date(created_at) AS day, COUNT(*) AS calls, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens, SUM(cost_usd) AS cost_usd FROM ai_usage WHERE created_at >= date('now', ?) GROUP BY day ORDER BY day DESC",
"SELECT default_clip_visibility, slot_clip_recording_enabled FROM managed_streams WHERE id = ?",
@@@ -856,7 +747,6 @@
"SELECT hash, revision, deleted FROM search_doc_pushes WHERE user_id = ?",
"SELECT hash, revision, deleted FROM search_media_pushes WHERE kind = ? AND media_id = ?",
"SELECT hash, strftime('%s', pushed_at) * 1000 AS at FROM module_summary_pushes WHERE user_id = ? AND namespace = ?",
-- "SELECT hru.*, u.username as created_by_username FROM hidden_relay_users hru LEFT JOIN users u ON hru.created_by = u.id WHERE hru.channel_id = ? OR hru.channel_id IS NULL ORDER BY hru.created_at DESC LIMIT ?",
"SELECT id FROM billing_actions WHERE idempotency_key = ?",
"SELECT id FROM channels WHERE user_id = ?",
"SELECT id FROM coin_transactions WHERE user_id = ? AND type = 'follow_bonus' AND message LIKE '%streamer:' || ? || '%'",
@@@ -869,7 +759,6 @@
"SELECT id FROM streams WHERE is_live = 1 AND protocol NOT IN ('rtmp', 'jsmpeg')",
"SELECT id FROM streams WHERE managed_stream_id = ? AND is_live = 1 AND id != ?",
"SELECT id FROM streams WHERE managed_stream_id = ? AND is_live = 1 LIMIT 1",
-- "SELECT id FROM streams WHERE user_id = ? ORDER BY id DESC LIMIT 1",
"SELECT id FROM themes WHERE slug = ?",
"SELECT id FROM user_themes WHERE user_id = ?",
"SELECT id FROM users WHERE role = 'admin' LIMIT 1",
@@@ -878,21 +767,17 @@
"SELECT id, action, idempotency_key, live_user_id, live_ref, status, error, attempts, created_at, updated_at FROM billing_actions WHERE status IN ('unknown', 'pending') ORDER BY id DESC LIMIT 50",
"SELECT id, ai_overview_short, ai_overview, title FROM streams WHERE user_id = ? AND (ai_title IS NULL OR ai_title = '') AND (ai_overview_short IS NOT NULL OR ai_overview IS NOT NULL) ORDER BY COALESCE(started_at, created_at) DESC LIMIT ?",
"SELECT id, applied_at, mode, duration_ms FROM schema_migrations",
-- "SELECT id, channel_id FROM hidden_relay_users WHERE id = ?",
"SELECT id, created_at FROM api_tokens WHERE token_hash = ?",
"SELECT id, ip_address, stream_id, reason FROM bans WHERE user_id = ? AND stream_id IS NULL",
"SELECT id, label, permissions, last_used, is_active, created_at FROM api_keys WHERE user_id = ?",
"SELECT id, label, scopes, created_at, last_used_at, expires_at, is_active FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC",
-- "SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1",
"SELECT id, pip_source_msid, pip_defaults FROM managed_streams WHERE id = ?",
"SELECT id, start_sec, end_sec, text, lang, text_en, created_at FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND id > ? ORDER BY id DESC LIMIT ?",
-- "SELECT id, stream_id FROM chat_messages WHERE is_deleted = 0 AND auto_delete_at IS NOT NULL AND auto_delete_at <= CURRENT_TIMESTAMP ORDER BY auto_delete_at ASC LIMIT ?",
"SELECT id, stream_id, user_id, ip_address, anon_id, expires_at FROM bans WHERE expires_at IS NULL OR datetime(expires_at) > CURRENT_TIMESTAMP",
"SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?) AND LENGTH(text) BETWEEN 25 AND 220 ORDER BY created_at DESC LIMIT 1500",
"SELECT id, title, slug FROM managed_streams WHERE user_id = ? AND (? IS NULL OR id != ?) ORDER BY sort_order ASC, id ASC",
"SELECT id, title, started_at, viewer_count FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1",
"SELECT id, user_id FROM channels WHERE id = ?",
-- "SELECT id, user_id, anon_id, username, message, message_type, source_platform, reply_to_id, timestamp FROM chat_messages WHERE channel_user_id = ? AND id > ? AND is_deleted = 0 AND message_type IN ('chat','donation') AND (auto_delete_at IS NULL OR auto_delete_at > CURRENT_TIMESTAMP) ORDER BY id DESC LIMIT ?",
"SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams",
"SELECT id, user_id, protocol FROM streams WHERE is_live = 1 AND ( (last_heartbeat IS NOT NULL AND last_heartbeat < datetime('now', '-5 minutes')) OR (last_heartbeat IS NULL AND started_at < datetime('now', '-6 minutes')) )",
"SELECT id, user_id, slug FROM managed_streams WHERE id = ?",
@@@ -901,7 +786,6 @@
"SELECT id, user_id, title, emote_sources FROM channels",
"SELECT id, username FROM users WHERE id = ?",
"SELECT id, username FROM users WHERE username = ? COLLATE NOCASE",
-- "SELECT id, username, display_name, avatar_url, profile_color FROM users WHERE id != ? AND is_banned = 0 AND id NOT IN (SELECT blocked_id FROM dm_blocks WHERE blocker_id = ?) AND id NOT IN (SELECT blocker_id FROM dm_blocks WHERE blocked_id = ?) AND (username LIKE ? COLLATE NOCASE OR display_name LIKE ? COLLATE NOCASE) LIMIT ?",
"SELECT id, username, display_name, avatar_url, profile_color, role, openvibe_bucks_balance, openvibe_coins_balance, created_at, last_seen FROM users WHERE id = ?",
"SELECT id, username, display_name, bio, is_banned FROM users WHERE id = ?",
"SELECT id, username, display_name, is_banned FROM users WHERE id = ?",
@@@ -920,14 +804,9 @@
"SELECT kind, start_sec, end_sec, text, label, confidence FROM stream_timeline_events WHERE stream_id = ? AND kind = ? ORDER BY start_sec ASC LIMIT ?",
"SELECT kind, start_sec, end_sec, text, label, confidence FROM stream_timeline_events WHERE vod_id = ? ORDER BY start_sec ASC LIMIT 20000",
"SELECT label, COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND label IS NOT NULL AND created_at >= datetime('now', ?) GROUP BY label ORDER BY n DESC LIMIT 5",
-- "SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_messages m JOIN users u ON u.id = m.sender_id WHERE m.conversation_id = ? AND m.id < ? ORDER BY m.created_at DESC LIMIT ?",
-- "SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_messages m JOIN users u ON u.id = m.sender_id WHERE m.conversation_id = ? AND m.id > ? ORDER BY m.created_at ASC LIMIT ?",
-- "SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_messages m JOIN users u ON u.id = m.sender_id WHERE m.conversation_id = ? ORDER BY m.created_at DESC LIMIT ?",
"SELECT m.id AS msid, m.title, m.slug, m.user_id, s.id AS stream_id, s.is_live FROM managed_streams m LEFT JOIN streams s ON s.managed_stream_id = m.id AND s.is_live = 1 WHERE m.id = ?",
"SELECT m.text, m.quality, m.said_at, m.aimed_at, u.username, u.display_name FROM arena_mic_moments m JOIN users u ON u.id = m.user_id WHERE m.said_at >= ? AND m.quality >= 7 ORDER BY m.quality DESC, m.said_at DESC LIMIT 3",
"SELECT media_id FROM search_media_pushes WHERE kind = ? AND deleted = 0",
-- "SELECT message FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0 AND message IS NOT NULL AND message <> '' AND timestamp >= datetime('now', ?) ORDER BY timestamp DESC LIMIT ?",
-- "SELECT metadata FROM chat_messages WHERE id = ?",
"SELECT model, COUNT(*) AS calls, SUM(cost_usd) AS cost_usd, SUM(input_tokens) AS input_tokens, SUM(cached_tokens) AS cached_tokens, SUM(output_tokens) AS output_tokens FROM ai_usage WHERE created_at >= date('now', ?) GROUP BY model ORDER BY cost_usd DESC",
"SELECT ms.*, (SELECT COUNT(*) FROM streams s WHERE s.managed_stream_id = ms.id) AS session_count, (SELECT MAX(s.ended_at) FROM streams s WHERE s.managed_stream_id = ms.id AND s.ended_at IS NOT NULL) AS last_live_at, (SELECT s.is_live FROM streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS is_currently_live, (SELECT s.id FROM streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS live_session_id FROM managed_streams ms WHERE ms.user_id = ? ORDER BY ms.sort_order ASC, ms.created_at ASC",
"SELECT ms.*, u.username, u.display_name, u.avatar_url, u.profile_color FROM managed_streams ms JOIN users u ON ms.user_id = u.id WHERE ms.id = ?",
@@@ -955,8 +834,6 @@
"SELECT recorded_at AS t, viewer_count AS v, COALESCE(chat_messages_5m, 0) AS c FROM viewer_snapshots WHERE stream_id = ? ORDER BY recorded_at ASC",
"SELECT revision FROM follow_projection_revisions WHERE follower_subject = ? AND target_subject = ?",
"SELECT revision FROM subject_projection WHERE subject_id = ?",
-- "SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?",
-- "SELECT rowid AS id, * FROM relay_users WHERE rowid = ?",
"SELECT rowid AS rid, ai_overview AS ov FROM streams WHERE ai_overview IS NOT NULL AND TRIM(ai_overview) != '' AND (ai_overview_short IS NULL OR ai_overview_short = '')",
"SELECT rowid AS rid, overview AS ov FROM streamer_overviews WHERE overview IS NOT NULL AND TRIM(overview) != '' AND (overview_short IS NULL OR overview_short = '')",
"SELECT s.*, COALESCE(NULLIF(s.ai_category, ''), s.category) AS category, s.category AS chosen_category, u.username, u.display_name, u.avatar_url, u.profile_color FROM streams s JOIN ( SELECT user_id, MAX(ended_at) AS latest_ended_at FROM streams WHERE is_live = 0 AND ended_at IS NOT NULL GROUP BY user_id ) latest ON latest.user_id = s.user_id AND latest.latest_ended_at = s.ended_at JOIN users u ON s.user_id = u.id WHERE s.is_live = 0 AND s.ended_at IS NOT NULL ORDER BY s.ended_at DESC LIMIT ?",
@@@ -989,10 +866,8 @@
"SELECT slot_powerchat_count_rs_views FROM managed_streams WHERE id = ?",
"SELECT slot_powerchat_relay FROM managed_streams WHERE id = ?",
"SELECT slot_vod_recording_enabled FROM managed_streams WHERE id = ?",
-- "SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='chat_messages' AND sql IS NOT NULL",
-- "SELECT sql FROM sqlite_master WHERE type='table' AND name='channel_sounds'",
-- "SELECT sql FROM sqlite_master WHERE type='table' AND name='chat_messages'",
"SELECT sql FROM sqlite_master WHERE type='table' AND name='stream_controls'",
++ "SELECT started_at FROM streams WHERE id = ?",
"SELECT started_at, is_live FROM streams WHERE id = ?",
"SELECT status, COUNT(*) AS n FROM billing_actions GROUP BY status",
"SELECT stream_id, json, ai, created_at FROM stream_recaps WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
@@@ -1030,13 +905,10 @@
"SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, COUNT(s.id) AS sessions, MAX(s.started_at) AS last_at, MAX(COALESCE(s.peak_viewers, 0)) AS peak_viewers, ROUND(SUM(COALESCE(s.duration_seconds, CASE WHEN s.ended_at IS NOT NULL THEN (julianday(s.ended_at) - julianday(s.started_at)) * 86400 ELSE 0 END)) / 3600.0, 1) AS hours, (SELECT title FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_title FROM streams s JOIN users u ON u.id = s.user_id WHERE s.started_at >= ? AND s.is_live = 0 AND COALESCE(u.is_banned, 0) = 0 GROUP BY u.id ORDER BY hours DESC, last_at DESC LIMIT 14",
"SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, MAX(s.ended_at) AS last_online_at, o.overview AS ai_overview, o.overview_short AS ai_overview_short, ( SELECT json_group_array(json_object( 'managed_stream_id', ms2.id, 'slug', ms2.slug, 'title', ms2.title, 'protocol', ms2.protocol, 'last_live_at', (SELECT MAX(s2.ended_at) FROM streams s2 WHERE s2.managed_stream_id = ms2.id AND s2.ended_at IS NOT NULL), 'vod_thumbnail', NULL )) FROM managed_streams ms2 WHERE ms2.user_id = u.id AND EXISTS (SELECT 1 FROM streams sx WHERE sx.managed_stream_id = ms2.id AND sx.ended_at IS NOT NULL) ) AS managed_streams_json FROM streams s JOIN users u ON s.user_id = u.id LEFT JOIN streamer_overviews o ON o.user_id = u.id WHERE s.is_live = 0 AND s.ended_at IS NOT NULL GROUP BY u.id ORDER BY last_online_at DESC LIMIT ? OFFSET ?",
"SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, s.title, s.viewer_count, s.started_at FROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0 ORDER BY s.viewer_count DESC, s.started_at DESC LIMIT 10",
-- "SELECT u.id, u.username FROM chat_messages c JOIN users u ON c.user_id = u.id WHERE c.timestamp >= datetime('now','-14 days') AND COALESCE(u.is_banned,0)=0 AND COALESCE(c.is_deleted,0)=0 GROUP BY u.id ORDER BY COUNT(*) DESC LIMIT 12",
-- "SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_participants p JOIN users u ON u.id = p.user_id WHERE p.conversation_id = ?",
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, MAX(s.started_at) AS last_live_at, COUNT(s.id) AS sessions, ROUND(SUM(COALESCE(s.duration_seconds, CASE WHEN s.ended_at IS NOT NULL THEN (julianday(s.ended_at) - julianday(s.started_at)) * 86400 ELSE 0 END)) / 3600.0, 1) AS hours, (SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id) AS followers, (SELECT COALESCE(ch.ai_category, ch.category) FROM channels ch WHERE ch.user_id = u.id) AS category FROM streams s JOIN users u ON u.id = s.user_id WHERE s.started_at >= datetime('now', ?) AND COALESCE(u.is_banned, 0) = 0 AND u.id != COALESCE(?, -1) GROUP BY u.id HAVING hours >= 0.25 OR sessions >= 2 ORDER BY last_live_at DESC LIMIT 40",
-- "SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, b.created_at as blocked_at FROM dm_blocks b JOIN users u ON u.id = b.blocked_id WHERE b.blocker_id = ? ORDER BY b.created_at DESC",
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.role, u.is_banned, u.ban_reason, u.created_at, COUNT(DISTINCT shared.ip_address) as shared_ip_count, GROUP_CONCAT(DISTINCT shared.ip_address) as shared_ips, MAX(shared.created_at) as last_shared_activity FROM ip_log mine JOIN ip_log shared ON mine.ip_address = shared.ip_address AND (shared.user_id IS NOT NULL OR shared.anon_id != ?) LEFT JOIN users u ON shared.user_id = u.id WHERE mine.anon_id = ? GROUP BY COALESCE(shared.user_id, shared.anon_id) ORDER BY shared_ip_count DESC, last_shared_activity DESC",
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.role, u.is_banned, u.ban_reason, u.created_at, COUNT(DISTINCT shared.ip_address) as shared_ip_count, GROUP_CONCAT(DISTINCT shared.ip_address) as shared_ips, MAX(shared.created_at) as last_shared_activity FROM ip_log mine JOIN ip_log shared ON mine.ip_address = shared.ip_address AND shared.user_id != ? JOIN users u ON shared.user_id = u.id WHERE mine.user_id = ? GROUP BY shared.user_id ORDER BY shared_ip_count DESC, last_shared_activity DESC",
-- "SELECT u.id, u.username, u.display_name, u.bio, COUNT(s.id) AS sessions, ROUND(SUM(COALESCE(s.duration_seconds, CASE WHEN s.ended_at IS NOT NULL THEN (julianday(s.ended_at) - julianday(s.started_at)) * 86400 ELSE 0 END)) / 3600.0, 1) AS hours, MAX(COALESCE(s.peak_viewers, 0)) AS peak_viewers, ROUND(AVG(COALESCE(s.peak_viewers, 0)), 1) AS avg_peak, MAX(s.started_at) AS last_live_at, (SELECT COUNT(*) FROM chat_messages c WHERE c.stream_id IN (SELECT id FROM streams x WHERE x.user_id = u.id AND x.started_at >= datetime('now', ?)) AND COALESCE(c.is_deleted, 0) = 0) AS chat_lines, (SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id AND f.created_at >= datetime('now', ?)) AS new_followers, (SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id) AS followers, (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.user_id = u.id AND m.said_at >= datetime('now', ?)) AS mic_moments, (SELECT COALESCE(so.overview_short, so.overview) FROM streamer_overviews so WHERE so.user_id = u.id) AS overview, (SELECT ch.ai_category FROM channels ch WHERE ch.user_id = u.id) AS category FROM users u JOIN streams s ON s.user_id = u.id WHERE s.started_at >= datetime('now', ?) AND COALESCE(u.is_banned, 0) = 0 GROUP BY u.id ORDER BY hours DESC LIMIT 40",
++ "SELECT u.id, u.username, u.display_name, u.bio, COUNT(s.id) AS sessions, ROUND(SUM(COALESCE(s.duration_seconds, CASE WHEN s.ended_at IS NOT NULL THEN (julianday(s.ended_at) - julianday(s.started_at)) * 86400 ELSE 0 END)) / 3600.0, 1) AS hours, MAX(COALESCE(s.peak_viewers, 0)) AS peak_viewers, ROUND(AVG(COALESCE(s.peak_viewers, 0)), 1) AS avg_peak, MAX(s.started_at) AS last_live_at, (SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id AND f.created_at >= datetime('now', ?)) AS new_followers, (SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id) AS followers, (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.user_id = u.id AND m.said_at >= datetime('now', ?)) AS mic_moments, (SELECT COALESCE(so.overview_short, so.overview) FROM streamer_overviews so WHERE so.user_id = u.id) AS overview, (SELECT ch.ai_category FROM channels ch WHERE ch.user_id = u.id) AS category FROM users u JOIN streams s ON s.user_id = u.id WHERE s.started_at >= datetime('now', ?) AND COALESCE(u.is_banned, 0) = 0 GROUP BY u.id ORDER BY hours DESC LIMIT 40",
"SELECT u.id, u.username, u.display_name, u.role, u.openvibe_bucks_balance, u.is_banned, u.ban_reason, u.created_at, u.last_seen, COALESCE(c.force_vod_recording_disabled, 0) AS force_vod_recording_disabled FROM users u LEFT JOIN channels c ON c.user_id = u.id",
"SELECT u.username AS channel, cp.balance AS points FROM channel_points cp JOIN users u ON u.id = cp.streamer_id WHERE cp.user_id = ? AND cp.balance > 0 ORDER BY cp.balance DESC, u.username LIMIT 10",
"SELECT u.username FROM username_history h JOIN users u ON u.id = h.user_id WHERE h.old_username = ? COLLATE NOCASE ORDER BY h.id DESC LIMIT 1",
@@@ -1045,6 +917,7 @@
"SELECT u.username, u.display_name, SUM(t.amount) AS total FROM transactions t JOIN users u ON u.id = t.from_user_id WHERE t.type = 'donation' AND ((t.stream_id = ?) OR (t.to_user_id = ? AND t.created_at BETWEEN ? AND ?)) GROUP BY t.from_user_id ORDER BY total DESC LIMIT 1",
"SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount) AS total FROM coin_transactions c JOIN users u ON u.id = c.user_id WHERE c.amount > 0 AND c.created_at >= datetime('now', '-7 days') GROUP BY c.user_id ORDER BY total DESC LIMIT 3",
"SELECT u.username, u.display_name, u.avatar_url, SUM(t.amount) AS total FROM transactions t JOIN users u ON u.id = t.from_user_id WHERE t.type = 'donation' AND t.created_at >= datetime('now', '-7 days') GROUP BY t.from_user_id ORDER BY total DESC LIMIT 3",
++ "SELECT unique_chatters, total_messages FROM stream_analytics WHERE stream_id = ?",
"SELECT user_id AS id FROM streams WHERE (started_at >= @from AND started_at < @to) OR (ended_at >= @from AND ended_at < @to) UNION SELECT streamer_id FROM follows WHERE created_at >= @from AND created_at < @to UNION SELECT id FROM users WHERE updated_at >= @from AND updated_at < @to UNION SELECT user_id FROM channels WHERE updated_at >= @from AND updated_at < @to",
"SELECT user_id AS k, overview AS v FROM streamer_overviews WHERE overview LIKE '{%\"description\"%'",
"SELECT user_id AS u, balance FROM channel_points WHERE streamer_id = ?",
@@@ -1064,13 +937,11 @@
"SELECT value FROM app_state WHERE key = ?",
"SELECT value FROM site_settings WHERE key = 'bucks_per_usd'",
"SELECT value FROM site_settings WHERE key = 'powerchat_site_user_id'",
-- "SELECT value FROM site_settings WHERE key='relay_users_backfilled'",
"SELECT viewer_count, chat_messages_5m, recorded_at FROM viewer_snapshots WHERE stream_id = ? ORDER BY recorded_at ASC",
"SELECT vk.*, u1.username as created_by_name, u2.username as used_by_name FROM verification_keys vk LEFT JOIN users u1 ON vk.created_by = u1.id LEFT JOIN users u2 ON vk.used_by = u2.id ORDER BY vk.created_at DESC",
"SELECT vod_id AS id, s.* FROM vod_ai_state s WHERE (ai_overview IS NULL OR ai_overview = '') AND (ai_overview_short IS NULL OR ai_overview_short = '' OR ai_overview_short LIKE '%…') ORDER BY vod_id DESC LIMIT ?",
"SELECT vod_id AS id, s.* FROM vod_ai_state s WHERE ai_transcript_json IS NULL AND (transcript_status IS NULL OR transcript_status IN ('pending','retry')) AND (transcript_next_at IS NULL OR transcript_next_at <= CURRENT_TIMESTAMP) ORDER BY (transcript_status='retry'), vod_id DESC LIMIT ?",
"SELECT vod_id FROM stream_timeline_events WHERE stream_id = ? AND vod_id IS NOT NULL LIMIT 1",
-- "SELECT voice, pitch, speed, gap FROM tts_voice_overrides WHERE identity_key = ?",
"SELECT winner_user_id FROM arena_beefs WHERE status = 'resolved' AND (a_user_id = ? OR b_user_id = ?) ORDER BY resolved_at DESC LIMIT 20",
"SELECT workspace_name, machine_name, extension_version, publisher_id, publisher_label, publisher_vendor, publisher_client_type, publisher_client_name, publisher_client_version, publisher_capabilities_json, publisher_depth FROM vibe_coding_sessions WHERE managed_stream_id = ? ORDER BY COALESCE(last_event_at, created_at) DESC, id DESC LIMIT 1",
"SELECT wt.user_id, u.username, u.display_name, SUM(wt.minutes_watched) as total_minutes FROM watch_time wt JOIN users u ON u.id = wt.user_id JOIN streams s ON wt.stream_id = s.id WHERE s.user_id = ? AND s.started_at >= ? GROUP BY wt.user_id ORDER BY total_minutes DESC LIMIT 20",
@@@ -1093,18 +964,9 @@
"UPDATE channel_ai_bots SET msg_count = msg_count + 1, last_active_at = CURRENT_TIMESTAMP WHERE id = ?",
"UPDATE channel_points SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND streamer_id = ?",
"UPDATE channel_points SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND streamer_id = ? AND balance >= ?",
-- "UPDATE channel_sounds SET command = ? WHERE channel_owner_id = ? AND command = ?",
-- "UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND command = ?",
-- "UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND emote_code = ?",
"UPDATE channels SET ai_category = ? WHERE user_id = ?",
"UPDATE channels SET ai_overview_pref = 'hide' WHERE hide_ai_overview = 1 AND (ai_overview_pref IS NULL OR ai_overview_pref = 'auto')",
"UPDATE channels SET clips_allow_creator_delete = ? WHERE id = ?",
-- "UPDATE chat_messages SET channel_user_id = (SELECT s.user_id FROM streams s WHERE s.id = chat_messages.stream_id) WHERE stream_id IS NOT NULL AND channel_user_id IS NULL",
-- "UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id = ?",
-- "UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0",
-- "UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0",
-- "UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?",
-- "UPDATE chat_messages SET metadata = ? WHERE id = ?",
"UPDATE clip_ai_state SET ai_overview = ?, ai_overview_short = ?, ai_transcript_json = COALESCE(?, ai_transcript_json) WHERE clip_id = ?",
"UPDATE clip_ai_state SET ai_transcript_json = ? WHERE clip_id = ?",
"UPDATE clip_ai_state SET clip_notified = 1, clip_notify_at = NULL WHERE clip_id = ?",
@@@ -1116,10 +978,6 @@
"UPDATE clips SET visibility = CASE WHEN is_public = 1 THEN 'public' ELSE 'unlisted' END",
"UPDATE coin_redemptions SET status = ?, resolved_at = CURRENT_TIMESTAMP WHERE id = ?",
"UPDATE coin_rewards SET redemption_count = redemption_count + 1 WHERE id = ?",
-- "UPDATE dm_conversations SET is_group = 1 WHERE id = ?",
-- "UPDATE dm_conversations SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
-- "UPDATE dm_conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
-- "UPDATE dm_participants SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = ? AND user_id = ?",
"UPDATE donation_goals SET current_amount = ? WHERE id = ?",
"UPDATE donation_goals SET current_amount = ?, is_active = 0, reached_at = CURRENT_TIMESTAMP WHERE id = ?",
"UPDATE donation_goals SET is_active = 0 WHERE id = ?",
@@@ -1138,10 +996,6 @@
"UPDATE opencoin_admin_grants SET balance = ? WHERE id = ?",
"UPDATE openre_sessions SET confirmed_at = CURRENT_TIMESTAMP WHERE session_id = ?",
"UPDATE openre_sessions SET state = 'detached', updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
-- "UPDATE pending_ip_messages SET status = 'approved', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
-- "UPDATE pending_ip_messages SET status = 'denied', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
-- "UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ?",
-- "UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ? AND channel_id = ?",
"UPDATE platform_connections SET access_token = ?, refresh_token = COALESCE(?, refresh_token), token_expires_at = ?, scope = COALESCE(?, scope), updated_at = CURRENT_TIMESTAMP WHERE id = ?",
"UPDATE platform_connections SET platform_user_id = ?, platform_username = ?, channel_url = ?, access_token = ?, refresh_token = COALESCE(?, refresh_token), token_expires_at = ?, scope = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
"UPDATE powerchat_connections SET access_token = ?, refresh_token = COALESCE(?, refresh_token), token_expires_at = ?, scope = COALESCE(?, scope), last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?",
@@@ -1155,6 +1009,7 @@
"UPDATE site_settings SET value = ? WHERE key = 'powerchat_site_tip_username' AND (value IS NULL OR value = '')",
"UPDATE site_settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?",
"UPDATE stream_analytics SET clips_created = ? WHERE stream_id = ?",
++ "UPDATE stream_analytics SET unique_chatters = ?, total_messages = ? WHERE stream_id = ?",
"UPDATE stream_controls SET camera_id = NULL WHERE camera_id = ?",
"UPDATE stream_recaps SET announced_at = CURRENT_TIMESTAMP WHERE stream_id = ?",
"UPDATE stream_timeline_events AS t SET vod_id = (SELECT s.vod_id FROM stream_timeline_events s WHERE s.stream_id = t.stream_id AND s.vod_id IS NOT NULL LIMIT 1) WHERE t.vod_id IS NULL AND EXISTS (SELECT 1 FROM stream_timeline_events s WHERE s.stream_id = t.stream_id AND s.vod_id IS NOT NULL)",