openvibers/openvibe.chat Commits
Chat drops the staged-table machinery (plan T3 step 4; Chat has written all six tables since 2026-09-29 14:46 UTC): STAGED_TABLES/KEYS, the authority cache and checks, applyStagedChanges/sliceHash/stagedSlice, the staged mirror triggers, the bridge handOver and its stagedApply/stagedSlice/tableAuthority/setTableAuthority ops, scripts/table-authority.js, the staged import kinds and parity's --tables mode, docs/staged-tables-cutover.md and their tests; local reads, erasure and every writer are unconditional (no 503 'not yet'). Kept: the C-02/C-03 bridge and mirror for the twelve non-staged tables, the db ops Live's current release still calls, and the table_authority table with its rows for one release (N-1 reads it; the next release drops it). 42/42.
Changes
@@@ -56,9 +56,10 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th- **Import:** `scripts/import-from-live.js --live-db <snapshot> [--apply]` — a dry run unless`--apply` (which backs Chat's database up first), idempotent, never drops a row of Chat's tables(`import_hold`), reports counts per table.--- **Staged tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,-- `chat_ai_summaries`, `chat_timeline_events`): Live writes them until each is handed to Chat, one-- table at a time (`table_authority`, register C-04). Runbook: `docs/staged-tables-cutover.md`.++- **The six chat tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,++ `chat_ai_summaries`, `chat_timeline_events`): Chat's own since the C-04 cutover — Chat is their only++ writer, there is no authority switch any more. Live's current release's writers still call Chat over++ the bridge (op `db`) until Live N+1 is deployed.- **The features on those tables, served by Chat** (plan T3, plan step 1) — built here so Live canstop touching them at the flip:- `GET /api/emotes/global`, `/channel/:userId`, `/mine`, `/defaults`, `/sources` (GET/PUT),@@@ -81,16 +82,13 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th- **The chat-AI summaries** (plan T3 step 2, decision 5) — Live's `server/ai/chat-ai.js` job movedhere: a background poller (off unless `CHAT_AI_ENABLED=1`) that folds Chat's own messages intorolling global/per-user/per-relay/per-anon insights and the append-only timeline, writing-- `chat_ai_summaries`/`chat_timeline_events` through the staged writers. The model is OpenVibe.AI++ Chat's own `chat_ai_summaries`/`chat_timeline_events` tables. The model is OpenVibe.AIcalled with Chat's own service token (`ai.run.create`/`ai.run.read`, audience `openvibe.ai`,namespace `chat.*`, workflows `chat.global`/`chat.profile`); when AI does not answer the jobstores a deterministic extractive summary instead, so the routes always have something to serve.Routes (Live's paths, parameters, shapes and public visibility, now under `/api/chat/ai/`):`GET /global`, `/timeline` (`?before=&since=&q=&limit=`), `/user/:id`, `/anon/:anonId`,-- `/relay/:platform/:username`, `/timeline/:username`. A write while Live still owns the two-- tables is skipped with one log line, never forced.-- - Every write keeps the `table_authority` gate: while Live still writes the table the route-- answers `503 { ok: false, error: 'not yet' }` and never writes.++ `/relay/:platform/:username`, `/timeline/:username`.## How it fits the network@@@ -100,7 +98,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) || Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) || Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |--| The six staged tables (moderators, moderation settings, emotes, tags, AI summaries, timeline) | **Chat** (after the T3 flip; Live until then) | Chat's own SQLite; Live reads through `GET /internal/moderation/*` (`chat.moderation.read`), 30 s cached |++| The six chat tables (moderators, moderation settings, emotes, tags, AI summaries, timeline) | **Chat** (C-04 done) | Chat's own SQLite; Live reads through `GET /internal/moderation/*` (`chat.moderation.read`), 30 s cached || Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` || Chat-AI summaries (global / per-chatter insight + timeline) | **OpenVibe.AI** (namespace `chat.*`) | `server/ai/client.js` (openvibe-sdk `createAiClient`), Chat's service token (`ai.run.create` / `ai.run.read`); workflows `chat.global` / `chat.profile`; extractive fallback when AI does not answer || Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |@@@ -116,10 +114,9 @@ Chat owns (from the cutover): `chat_messages`, `dm_conversations`, `dm_participa`subject_id` / `sender_subject_id` / `blocker_subject_id` / `actor_subject_id` /`created_by_subject_id` (the Network `usr_…` subject, filled on new rows and by the importer).--Staged here, still written by Live in this wave (their routes have not moved): `channel_moderators`,--`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events` —--imported (and refreshed by later import runs) so their move is a switch; Chat reads the live values--through `live-context`. `table_authority` records which is which.++Chat owns too (C-04 done; there is no authority switch any more): `channel_moderators`,++`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`.++The importer no longer copies them; Live reads them through `GET /internal/moderation/*`.Stays in Live (decided with evidence, `docs/cutover.md`): `media_requests`, `media_request_settings`.@@@ -357,8 +354,6 @@ npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,snpm test # Node 22; stub Live and Network in-processnode scripts/import-from-live.js --live-db /tmp/live-snapshot.db # dry run; --apply writesnode scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"--node scripts/parity-check.js --tables --live-db /tmp/live-snapshot.db --chat-db /tmp/chat-snapshot.db--node scripts/table-authority.js # who writes each staged table (the handoff is Live's)node scripts/mirror-flush.js # rollback helper: push queued mirror rows to Livenode scripts/subscribe-events.js --dry-run # Chat's Events subscriptions (boot creates missing ones)node scripts/parity.js # chat parity scenarios: a dry run; --apply only on test accounts (docs/parity.md)@@@ -434,8 +429,8 @@ server/calls/ moved from Live: the call server (/ws/call), its RESTserver/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service tokenserver/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)--scripts/ import-from-live, parity-check, parity, mirror-flush, table-authority, migrate-chat-preferences, subscribe-events, n-1-record--docs/ cutover.md, calls-cutover.md, staged-tables-cutover.md, parity.md, live-patch.diff, capabilities-proposal/++scripts/ import-from-live, parity-check, parity, mirror-flush, migrate-chat-preferences, subscribe-events, n-1-record++docs/ cutover.md, calls-cutover.md, parity.md, live-patch.diff, capabilities-proposal/```## Owns@@@ -41,13 +41,13 @@ nginx).| Table (Live baseline, target OpenVibe.Chat) | W6 authority | Notes || --- | --- | --- || `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` | **Chat** | Imported with their ids. Live keeps a read mirror (Chat → `POST /internal/chat-effects/mirror`); Live's remaining writers (AI viewers, relays, donations, `/api/mod`, `/api/channels` deletes, emote renames) forward to Chat. |--| `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events` | **Live** (staged in Chat) until each is handed over | Moved one table at a time by `docs/staged-tables-cutover.md` (register C-04): at `live` Live writes and its changes reach Chat's copy over the bridge; at `chat` Live's writers call Chat and the mirror copies the table back. Chat reads the moderation tables through `live-context` while Live writes them and in place once Chat does. |++| `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events` | **Chat** (C-04 done) | Chat's own tables; Chat is their only writer. Live reads what it needs through `GET /internal/moderation/*` (`chat.moderation.read`), and Live's current release's writers call Chat over the bridge (op `db`) until Live N+1 is deployed. There is no authority switch any more. || `media_requests`, `media_request_settings` | **Live** (not moved) | Decision with evidence: every writer is `server/media/media-queue.js` / `server/media/routes.js` (`/api/media`: gold payment, yt-dlp download, playback state, the streamer's overlay and dashboard). Chat only has the `!sr/!queue/!np/!skip` entry points, which call Live (`POST /internal/chat-effects/media-queue`). Not chat-owned in practice; it moves with the queue lifecycle the charter describes, not before. The importer reports them as `not_moved`. || `chat_messages_new`, `emotes_new`, `channel_sounds_new` | — | Transient tables of Live's table rebuilds; empty in a consistent snapshot. Rows found there go to `import_hold`. |Chat also keeps `ctx_users`, `ctx_streams`, `ctx_managed_streams`, `ctx_channels` — projections ofLive data maintained by `live-context` (never authority), `events_outbox`, `live_mirror_outbox`,--`bridge_applied`, `import_hold`, `import_runs`, `table_authority`.++`bridge_applied`, `import_hold`, `import_runs`.## Prerequisites@@@ -60,9 +60,8 @@ The gaps the script found on 2026-09-25, with the product decisions taken for th- **Sub-only mode.** A channel setting (`channel_moderation_settings.sub_only`, in Live and Chat),set by `/subonly` and `/subonly off` (the streamer, channel moderators and chat staff) and by the-- dashboard's moderation settings, written where that table's authority says (Live's-- `channel-settings` effect while it is at `live`, Chat's `upsertChannelModerationSettings` once Chat-- writes it). When it is on, only people with an **active Live channel subscription** to the streamer++ dashboard's moderation settings, written by Chat's `upsertChannelModerationSettings` (the table is++ Chat's own since the C-04 cutover). When it is on, only people with an **active Live channel subscription** to the streamer(Live's `subscriptions`: status active, the paid period not over; Billing's entitlement under`BILLING_AUTHORITY=billing`), the streamer, channel moderators and chat staff may chat; Network VIPdoes not count and anonymous viewers cannot chat. Chat asks Live `GETddeleted file mode 100644@@@ -1,288 +0,0 @@--# Staged tables cutover — Live → OpenVibe.Chat (WS-I task 2, register C-04)----Six chat tables are still written by Live since the Wave 6 cutover (`docs/cutover.md`). This moves--their authority to Chat **one table at a time**, each move reversible:----| Table | Live's writers today | After the flip |--| --- | --- | --- |--| `chat_timeline_events` | `server/ai/chat-ai.js` (global timeline, seeding) | Live's chat AI calls Chat's `addChatTimelineEvents` |--| `chat_ai_summaries` | `server/ai/chat-ai.js` (global, user, relay-user, anon insights) | Live's chat AI calls Chat's `upsertChatAiSummary` |--| `user_tags` | none (chat tags are read-only in Live since the game moved to OpenVibe.Games) | data only; Chat has `grantUserTag` / `revokeUserTag` |--| `emotes` | `POST/PATCH/DELETE /api/emotes`, Media asset-sync (`media_url`, `media_asset_id`) | Live's routes and asset-sync call Chat's `createEmote`, `updateEmote`, `deleteEmote`, `setEmoteMedia` |--| `channel_moderation_settings` | `PUT /api/channels/:id/moderation` (dashboard), Chat's `/slow`, `/subonly` and alert sounds (Live's `/internal/chat-effects/channel-settings` and `alert-sound`) | the dashboard calls Chat's `upsertChannelModerationSettings`; `/slow`, `/subonly` and alert sounds are written by Chat itself |--| `channel_moderators` | `POST/DELETE /api/channels/:id/mods` | Live's routes call Chat's `addChannelModerator` / `removeChannelModerator` |----`media_requests` is **not** part of this: it is not a chat table. Its writers are Live's media--queue (`server/media/media-queue.js`, `server/media/routes.js`, `server/tips/delivery-routes.js`:--gold payment, yt-dlp download, playback state); Chat only has the `!sr/!queue/!np/!skip` entry--points, which call Live; the plan's own media namespaces list it as `live.media_requests`. The--decision of `docs/cutover.md` ("Data authority") stands.----- [How it works](#how-it-works)--- [Calendar](#calendar)--- [Shell helpers](#shell-helpers)--- [0. Deploy](#0-deploy)--- [1. Import](#1-import)--- [2. Parity](#2-parity)--- [3. Dual read for one release](#3-dual-read-for-one-release)--- [4. Flip each table](#4-flip-each-table)--- [5. Live stops writing](#5-live-stops-writing)--- [6. Rollback](#6-rollback)----## How it works----Two records say who writes each table, and they move together:----- **Chat:** `table_authority` in `chat.db` (`scripts/table-authority.js`, `GET /ready` →-- `table_authority`). A restart never changes it.--- **Live:** app_state `chat_table_authority` (JSON; a missing table is `live`), read by-- `server/chat/chat-tables.js`; `GET /internal/chat-tables` shows both sides. Without-- `CHAT_AUTHORITY=chat` Live treats every table as `live`.----**At `live`** (every table after the deploy): Live writes its own table as before. TEMP triggers--capture every change into `chat_staged_outbox`, and a relay sends the rows as they are now to Chat's--bridge (`POST /internal/live/calls`, op `stagedApply`, capability `chat.live_bridge.write`) every--2 s, so Chat's copy stays current. Chat's staged writes refuse (`table.not_chat`). Chat's chat--server reads moderators and settings through Live (`live-context`), as it has since Wave 6. With a--table's **dual-read** flag on, Live's reads of it are compared in the background with Chat's copy--(op `stagedSlice`: row count and a sha256 over the rows) and counted in `chat_dual_read_stats`;--Live's answers never change.----**The flip** (`POST /internal/chat-tables/:table {"authority":"chat"}` on Live, never automatic):--the table's writers wait; Live's queued changes reach Chat; Chat takes the table (op--`setTableAuthority`; refused if `LIVE_MIRROR` is off); Live records `chat` and stops capturing.----**At `chat`:** Live's writers call Chat's write of the same name over the bridge (awaited, with an--idempotency key); Chat answers Live's own return value plus the rows, which Live applies to its copy--at once. Live's table is from then on a read mirror: Chat's triggers queue every change of the table--in `live_mirror_outbox` and the mirror sends it to Live (`/internal/chat-effects/mirror`, as for--Chat's own tables), which takes staged rows only for a table Chat writes. Live's readers do not--change. Chat's chat server reads `channel_moderators` and `channel_moderation_settings` in place,--and writes `/slow`, `/subonly` and alert sounds itself.----**The flip back** (`{"authority":"live"}`): Chat sends Live every queued change of the table and--refuses to give it back while any is left; then Live records `live` and captures again. Running a--flip again is safe: it brings both sides back into agreement after a failed attempt.----## Calendar----The plan (WS-I task 2) asks for dual read during one release before the flip. Live's rollback--window is a week (`test/rollback-newer-writes.test.js`: production may roll back to any release of--the last seven days), and a Live release from before this change would write a flipped table itself.--So a table is flipped only when **both** hold: the dual-read counters stayed at 0 mismatches--through at least one Live release shipped after D0, and every Live release of the last seven days--has this change (at the earliest D0 + 7 days).----| When (D0 = 2026-09-28) | What |--| --- | --- |--| D0, Mon 2026-09-28 | Deploy Chat, then Live. Import (dry run, apply). Parity. Dual read on for all six tables. |--| D0 … D0 + 7 | Watch the counters daily. At least one more Live release ships in this week (any deploy counts), with dual read still on. |--| Mon 2026-10-05 | Flip `chat_timeline_events`. |--| Tue 2026-10-06 | Flip `chat_ai_summaries`. |--| Wed 2026-10-07 | Flip `user_tags`. |--| Thu 2026-10-08 | Flip `emotes`. |--| Fri 2026-10-09 | Flip `channel_moderation_settings`. |--| Mon 2026-10-12 | Flip `channel_moderators` (last: it decides who may moderate). |--| until 2026-11-06 | Keep the flip back available. C-01 to C-03 (Live's chat mirror and bridge) have that target date and carry these tables too. |--| not before 2026-12-18 | Contract step (register C-04 target): remove Live's local write path, the capture and the relay for the staged tables, after 30 days on `chat` without a flip back. |----If the deploy slips, shift every date by the same number of days. If the counters show mismatches,--the flips wait until a full release passes without any.----## Shell helpers----Live's internal routes answer only on loopback with `X-Internal-Key`. The helper takes the key from--the running service and passes it to curl on stdin, so it is never printed or put on a command line.----```bash--live_tables() {-- local key-- key="$(sudo cat /proc/"$(systemctl show -p MainPID --value openvibe-live)"/environ | tr '\0' '\n' | sed -n 's/^INTERNAL_API_KEY=//p')"-- printf 'X-Internal-Key: %s\n' "$key" | curl -sS -H @- -H 'Content-Type: application/json' "$@"--}--LT=http://127.0.0.1:3000/internal/chat-tables--# Chat's side (same database as the service)--chat_tables() { (cd /opt/openvibe.chat && sudo -u ubuntu env CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db node scripts/table-authority.js "$@"); }--STAGED=channel_moderators,channel_moderation_settings,emotes,user_tags,chat_ai_summaries,chat_timeline_events--# The overview used below--status() { live_tables "$LT" | jq '{relay, chat_error, tables: (.tables | map_values({authority, chat_authority, disagree, dual_read, outbox_pending, dr: (.dual_read_stats | {compared, matched, mismatched, inconclusive, errors, last_mismatch_at})}))}'; }--```----## 0. Deploy----Chat first (it must know the new bridge ops before Live starts relaying), then Live. Nothing changes--for users: every table stays at `live`.----```bash--cd /opt/openvibe.chat && sudo -u ubuntu git pull --ff-only--sudo systemctl restart openvibe-chat && until curl -sf 127.0.0.1:4400/ready >/dev/null; do sleep 1; done--curl -s 127.0.0.1:4400/ready | jq '{status, table_authority, mirror}'----cd /opt/openvibe.live/current && sudo deploy/scripts/deploy.sh --wait-idle--until curl -sf https://openvibe.live/api/ready >/dev/null; do sleep 1; done--journalctl -u openvibe-live -n 300 | grep ChatTables--status--```----Look at:----- Chat `/ready`: `status` `ready`, every `table_authority` value `live`, `mirror.last_error` null.--- Live's journal: `[ChatTables] staged tables: channel_moderators=live, …` (the capture and relay-- started). No `relay to Chat waiting` lines after the first minute.--- `status`: every table `authority` and `chat_authority` `live`, no `disagree`, `relay.pending`-- near 0, `relay.last_error` null, `relay.paused` false.----## 1. Import----Chat's copy was last filled at the Wave 6 cutover. The import makes it equal to Live's: refreshed--rows, missing rows added, rows Live no longer has removed (`pruned`). The relay is held meanwhile--(Live keeps capturing), so every change made after the pause reaches Chat after the import and the--import never undoes one.----```bash--# 1a. hold the relay--live_tables -X POST -d '{"paused":true}' "$LT/relay" | jq .relay # paused: true--# 1b. snapshot Live (online, consistent)--sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-staged-import.db"--# 1c. dry run (the default: nothing is written)--cd /opt/openvibe.chat--sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-staged-import.db \-- --chat-db /var/lib/openvibe-chat/chat.db --tables "$STAGED" --no-projections | tee /tmp/staged-import-dry.json | jq '.tables, .held_total'--# 1d. apply (copies chat.db to /var/lib/openvibe-chat/chat.db.pre-import-<time> first)--sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-staged-import.db \-- --chat-db /var/lib/openvibe-chat/chat.db --tables "$STAGED" --no-projections --apply | tee /tmp/staged-import.json | jq '.backup, .tables, .held_total'--# 1e. release the relay: the changes since 1a go now--live_tables -X POST -d '{"paused":false}' "$LT/relay" | jq .relay--sleep 5; status--```----Look at:----- Dry run and apply report the same numbers. Per table: `authority` `live`; `inserted + identical +-- refreshed` = `live` (the snapshot's rows); `pruned` = rows only Chat had (removed moderators,-- deleted emotes since 2026-09-23); `held` 0. `held_total` unchanged from before the run (anything-- new: `sqlite3 /var/lib/openvibe-chat/chat.db "select source_table, reason from import_hold order by id desc limit 5"`).--- `backup` names the copy of `chat.db` taken before the write.--- After 1e: `relay.paused` false, `relay.pending` back near 0, `relay.last_error` null.--- A second dry run (1c again) reports `inserted` 0 and `pruned` 0 for every table (idempotent).----## 2. Parity----Row counts and a content hash per table, on two snapshots taken back to back:----```bash--status | jq .relay.pending # 0 (nothing on its way)--sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-parity.db"--sudo -u ubuntu sqlite3 /var/lib/openvibe-chat/chat.db ".backup /tmp/chat-parity.db"--cd /opt/openvibe.chat && sudo -u ubuntu node scripts/parity-check.js --tables --live-db /tmp/live-parity.db --chat-db /tmp/chat-parity.db--```----Look at: `all staged tables are the same`, one `same` line per table with its row count and--authority `(live)`. A `DIFFERENT` line lists the keys only in Live, only in Chat, or different. A--write between the two snapshots shows up this way once: run the three commands again a minute--later. A key that differs twice is real: look at both rows--(`sqlite3 … "select * from <table> where id = <key>"`), then repeat step 1 for that table--(`--tables <table>`) and step 2. Afterwards delete the snapshots (they hold user data):--`rm -f /tmp/live-staged-import.db /tmp/live-parity.db /tmp/chat-parity.db /tmp/staged-import*.json`.----## 3. Dual read for one release----```bash--for t in ${STAGED//,/ }; do live_tables -X POST -d '{"dual_read":true}' "$LT/$t" | jq -c '{t: "'$t'", dual_read: .table.dual_read}'; done--status--```----Every day until the flips (D0 … D0 + 7, and through at least one more Live release — each release--restarts Live; the counters are kept in `chat_dual_read_stats` and survive it):----```bash--status--journalctl -u openvibe-live --since -24h | grep -E 'ChatTables\] (dual read|relay)'--ls /opt/openvibe.live/releases # a release after D0 has shipped--```----Look at:----- `dr.mismatched` stays **0** for every table; `dr.compared` grows for the tables Live reads-- (`channel_moderation_settings`, `channel_moderators`, `emotes`, `chat_ai_summaries`,-- `chat_timeline_events`). `user_tags` has no reader in Live, so it compares nothing: parity (step-- 2) is its check — run step 2 again the day before its flip.--- `dr.inconclusive` counts comparisons made while a change was on its way to Chat; it is not a-- mismatch. `dr.errors` counts Chat not answering; a few during Chat restarts are expected.--- `relay.pending` near 0 and `relay.last_error` null at every look.--- A mismatch: `live_tables "$LT" | jq '.tables.<table>.dual_read_stats.last_mismatch'` gives the-- slice (`where`), both counts and hashes, and the keys (`only_live`, `only_chat`, `differ`). Find-- the cause, repeat steps 1 and 2 for that table, reset its counters-- (`live_tables -X POST -d '{"reset_counters":true}' "$LT/<table>"`) and start the release over.----## 4. Flip each table----One table per day, in the order of the calendar. Before each flip:----```bash--status # this table: mismatched 0, outbox_pending 0, no disagree--curl -s 127.0.0.1:4400/ready | jq '{status, mirror}' # mirror.enabled true, pending near 0, last_error null--C04=d6480da # the Live commit of this change (git -C /opt/openvibe.live/repo log --oneline --grep 'C-04')--for r in /opt/openvibe.live/releases/*; do git -C /opt/openvibe.live/repo merge-base --is-ancestor "$C04" "${r##*-}" && echo "has it $r" || echo "OLDER $r"; done--```----Every release of the last seven days must say `has it` (`--rollback` must never land on one that--writes the table itself). Then flip:----```bash--T=chat_timeline_events # the table of the day--live_tables -X POST -d '{"authority":"chat","by":"'"$USER"'"}' "$LT/$T" | jq--```----Look at:----- The answer: `ok` true, `handoff.authority` `chat`, `table.authority` and `table.chat_authority`-- both `chat`, `table.outbox_pending` 0. `ok` false: nothing moved (`error` says why; Chat not-- reachable, `LIVE_MIRROR is off`, Live's queue not drained). Fix it and run the same command again.--- `journalctl -u openvibe-live -n 50 | grep "ChatTables\] $T"` → `live → chat`;-- `journalctl -u openvibe-chat -n 50 | grep "$T: Chat writes it now"`.--- `chat_tables` → `"$T": { "authority": "chat", … }`; `curl -s 127.0.0.1:4400/ready | jq .table_authority`.----## 5. Live stops writing----The flip is the moment Live stops writing the table: its writers call Chat and Live's copy only--takes Chat's rows. Check it the same day, with a real write through the feature:----| Table | A write to make | Then |--| --- | --- | --- |--| `chat_timeline_events`, `chat_ai_summaries` | none: the chat AI writes within 30 minutes while chat is active (`journalctl -u openvibe-live \| grep ChatAI`) | the newest row by `id` / `updated_at` is the same in both databases |--| `user_tags` | none | step 2 for `user_tags` |--| `emotes` | upload a test emote on a test channel, rename it, delete it (dashboard → Emotes) | each step shows in Chat's `emotes` with the same `id` as in Live's, then in Live's |--| `channel_moderation_settings` | change a setting on a test channel in the dashboard; `/slow 5` then `/slow off` as its moderator | Chat's row changes first, Live's copy follows; slow mode works in the stream chat |--| `channel_moderators` | add and remove a test moderator in the dashboard | the moderator can `/timeout` a test account in that channel's chat, and cannot after removal |----```bash--sudo -u ubuntu sqlite3 /var/lib/openvibe-chat/chat.db "select * from $T order by rowid desc limit 3"--sqlite3 /opt/openvibe.live/data/live.db "select * from $T order by rowid desc limit 3"--status | jq ".tables.$T" # outbox_pending stays 0: Live captures nothing now--curl -s 127.0.0.1:4400/ready | jq .mirror # pending near 0, last_error null--journalctl -u openvibe-live --since -1h | grep -E "ChatTables|refused" | grep -v 'staged tables:'--```----Look at: the same rows (same ids) on both sides; `outbox_pending` 0; no `OpenVibe.Chat refused`--and no `not applied here` lines; Chat's mirror queue drains. For the emote, settings and moderator--checks, also run step 2 for the table (the `same` line says `(chat)`). A write that fails while Chat--is unreachable answers an error to the user and changes nothing in Live: that is the table being--Chat's. After the last table (2026-10-12), run step 2 for all six.----## 6. Rollback----| Step | Rollback | Look at |--| --- | --- | --- |--| 0. Deploy | Nothing to undo while every table is `live`. Before rolling Live (`deploy.sh --rollback`) or Chat back past these changes, every table must be `live` again (row 4). | `status`: no table at `chat` |--| 1. Import | The staged copy in Chat is read by nothing while its table is `live`, so a bad import has no effect on users. Repeat step 1 (pause, fresh snapshot, `--apply`, resume). **Never copy the `.pre-import-*` backup over `chat.db`**: it would drop the chat messages written since; it is for looking at. If step 1 stopped between 1a and 1e, release the relay: `live_tables -X POST -d '{"paused":false}' "$LT/relay"`. | `status`: `relay.paused` false, pending drains |--| 2. Parity | Read-only. | — |--| 3. Dual read | `for t in ${STAGED//,/ }; do live_tables -X POST -d '{"dual_read":false}' "$LT/$t" >/dev/null; done`. It never changed an answer. | `status`: `dual_read` false |--| 4. Flip | `live_tables -X POST -d '{"authority":"live","by":"'"$USER"'"}' "$LT/$T" \| jq`: Chat sends its queued changes of the table to Live first, then Live writes it again. Refused while any change is still queued (`not in Live yet`): look at `curl -s 127.0.0.1:4400/ready \| jq .mirror` and run it again. | answer `table.authority` / `chat_authority` `live`; `outbox_pending` counting Live's new writes, then draining |--| 4, Chat down | 1) send Chat's queue to Live: `cd /opt/openvibe.chat && sudo -u ubuntu env $(sudo cat /etc/openvibe/chat.env \| xargs) CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db node scripts/mirror-flush.js` (exit 0 = drained; Live takes the rows only while the table is still `chat` there, so this comes first); 2) Chat's side: `chat_tables set $T live --force`; 3) Live's side: `live_tables -X POST -d '{"authority":"live","force":true}' "$LT/$T"`. | `status`: `authority` `live`; once Chat runs again `chat_authority` `live`, no `disagree` |--| 5. Live stops writing | Same as step 4: the flip back makes Live the writer again. | as step 4 |--| Wave 6 rollback | Before `docs/cutover.md` "Rollback", flip every table back (row 4). If Chat cannot run, its mirror flush there (step 2 of that rollback) carries the staged tables at `chat` too; then set each table back with the "Chat down" row. | `status` |----If `status` shows `disagree` for a table (one side `chat`, the other `live`, after a failed or forced--flip), run the flip that states the side you want once more; Live's relay keeps any change Chat--refused in the meantime and sends it once both sides agree.@@@ -19,11 +19,9 @@* - Never drops a row. What cannot be represented goes to import_hold with the reason:* an id already used by a DIFFERENT row, a constraint the row breaks, rows of Live's* transient *_new migration tables.-- * - Chat's tables: an existing Chat row always wins (after the cutover Chat is the authority-- * and may have edited it). Staged tables (C-04) follow their table_authority: at 'live' this copy-- * is made equal to Live's — rows refreshed, and rows Live no longer has removed (`pruned`: a-- * moderator removed in Live must not keep powers here); at 'chat' Chat is the authority and the-- * run only reports how Live's copy differs (`differs`, `live_only`, `chat_only`).++ * - Chat's tables: an existing Chat row always wins (after the cutover Chat is the authority and may++ * have edited it). The six tables Chat owns (channel moderators/settings, emotes, user tags,++ * chat-AI summaries/timeline; C-04 done) are not imported — Chat is their only writer.* - New columns: *subject_id is filled from Live's linked_accounts (the Network subject).* - media_requests / media_request_settings stay in Live (decision: docs/cutover.md) and are* only reported.@@@ -107,10 +105,9 @@ function run(opts) {const liveTables = new Set(live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));const colsOf = (conn, t) => conn.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);-- // A staged table Chat already writes is compared, never written (Chat is its authority).++ // The six tables Chat owns (C-04 done) are not imported: Chat is their only writer.const plan = [-- ...Object.entries(db.CHAT_TABLES).map(([t, pk]) => ({ table: t, pk, kind: 'chat' })),-- ...Object.entries(db.STAGED_KEYS).map(([t, pk]) => ({ table: t, pk, kind: db.tableAuthority(t) === 'chat' ? 'staged-chat' : 'staged' })),++ ...Object.entries(db.CHAT_TABLES).map(([t, pk]) => ({ table: t, pk })),].filter((p) => !opts.tables || opts.tables.includes(p.table));// Schema drift check first: nothing is written when Live has columns Chat cannot hold.@@@ -137,35 +134,13 @@ function run(opts) {const holdStmt = chat.prepare('INSERT OR IGNORE INTO import_hold (source_table, source_pk, reason, row_json) VALUES (?, ?, ?, ?)');const hold = (table, pkObj, reason, row) => holdStmt.run(table, JSON.stringify(pkObj), String(reason).slice(0, 300), JSON.stringify(row)).changes;-- // A staged table Chat writes: count how Live's copy (its mirror) differs, write nothing.-- function compareOnly(p, r) {-- const chatCols = new Set(colsOf(chat, p.table));-- const cols = colsOf(live, p.table).filter((c) => chatCols.has(c));-- const getChat = chat.prepare(`SELECT ${cols.join(', ')} FROM ${p.table} WHERE ${p.pk.map((k) => `${k} = ?`).join(' AND ')}`);-- const seen = new Set();-- for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${p.table}`).iterate()) {-- r.live++;-- seen.add(JSON.stringify(p.pk.map((k) => row[k])));-- const mine = getChat.get(...p.pk.map((k) => row[k]));-- if (!mine) r.live_only++;-- else if (cols.every((c) => mine[c] === row[c] || (mine[c] == null && row[c] == null))) r.identical++;-- else r.differs++;-- }-- for (const row of chat.prepare(`SELECT ${p.pk.join(', ')} FROM ${p.table}`).iterate()) {-- if (!seen.has(JSON.stringify(p.pk.map((k) => row[k])))) r.chat_only++;-- }-- }--const report = { live_db: path.resolve(opts.liveDb), dry_run: !!opts.dryRun, backup: null, tables: {} };if (!opts.dryRun && opts.backup) report.backup = backupChat(chat, opts.backupPath);for (const p of plan) {-- const r = p.kind === 'staged-chat'-- ? { authority: 'chat', live: 0, identical: 0, differs: 0, live_only: 0, chat_only: 0 }-- : { live: 0, inserted: 0, identical: 0, refreshed: 0, chat_kept: 0, held: 0, ...(p.kind === 'staged' ? { authority: 'live', pruned: 0 } : {}) };++ const r = { live: 0, inserted: 0, identical: 0, chat_kept: 0, held: 0 };report.tables[p.table] = r;if (!liveTables.has(p.table)) { r.missing_in_live = true; continue; }-- if (p.kind === 'staged-chat') { compareOnly(p, r); continue; }const chatCols = new Set(colsOf(chat, p.table));const cols = colsOf(live, p.table).filter((c) => chatCols.has(c));const subjectCols = (SUBJECT_COLUMNS[p.table] || []).filter(([sc]) => chatCols.has(sc));@@@ -173,22 +148,10 @@ function run(opts) {const where = p.pk.map((k) => `${k} = ?`).join(' AND ');const getChat = chat.prepare(`SELECT * FROM ${p.table} WHERE ${where}`);const ins = chat.prepare(`INSERT INTO ${p.table} (${insertCols.join(', ')}) VALUES (${insertCols.map(() => '?').join(', ')})`);-- const upd = cols.filter((c) => !p.pk.includes(c));-- const refresh = upd.length ? chat.prepare(`UPDATE ${p.table} SET ${upd.map((c) => `${c} = ?`).join(', ')} WHERE ${where}`) : null;const identity = IDENTITY[p.table] || null;const orderBy = p.pk.join(', ');const apply = () => {-- // A staged table at 'live' is a copy: what Live no longer has goes first (a row Live-- // removed and added again under a new id would otherwise collide on its unique columns).-- if (p.kind === 'staged') {-- const keyOf = (row) => JSON.stringify(p.pk.map((k) => row[k]));-- const inLive = new Set(live.prepare(`SELECT ${p.pk.join(', ')} FROM ${p.table}`).all().map(keyOf));-- const del = chat.prepare(`DELETE FROM ${p.table} WHERE ${where}`);-- for (const row of chat.prepare(`SELECT ${p.pk.join(', ')} FROM ${p.table}`).all()) {-- if (!inLive.has(keyOf(row))) r.pruned += del.run(...p.pk.map((k) => row[k])).changes;-- }-- }for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${p.table} ORDER BY ${orderBy}`).iterate()) {r.live++;const pkVals = p.pk.map((k) => row[k]);@@@ -210,16 +173,7 @@ function run(opts) {r.held += hold(p.table, pkObj, 'id already used by a different row in chat', row);continue;}-- if (p.kind === 'staged' && refresh) {-- try {-- refresh.run(...upd.map((c) => row[c]), ...pkVals);-- r.refreshed++;-- } catch (err) {-- r.held += hold(p.table, pkObj, `refresh failed: ${err.message}`, row);-- }-- } else {-- r.chat_kept++;-- }++ r.chat_kept++;}};@@@ -5,14 +5,6 @@* node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 \* [--before "2026-09-22 10:00:00"] [--stream 123]... [--channel 45]... [--token <jwt>]*-- * node scripts/parity-check.js --tables --live-db <live.db copy> --chat-db <chat.db or a copy> \-- * [--table emotes]...-- *-- * --tables compares the staged tables (C-04, docs/staged-tables-cutover.md) row for row: per table-- * the row count and a sha256 over every row (the columns both copies have, ordered by key — the hash-- * Chat's dual read uses, database.js sliceHash), and on a difference the first keys that are only in-- * one copy or differ. Both files are opened read-only. Exit 1 on any difference.-- ** --before pins history pages to messages older than the snapshot, so rows written since do not* count as differences. --token (a Network JWT of a test account) adds the DM routes. Prints one* line per path (same / DIFFERENT with the first differing keys) and exits 1 on any difference.@@@ -22,52 +14,15 @@'use strict';function parseArgs(argv) {-- const o = { streams: [], channels: [], only: [] };++ const o = { streams: [], channels: [] };for (let i = 0; i < argv.length; i++) {const a = argv[i], v = argv[i + 1];-- if (a === '--live') { o.live = v; i++; } else if (a === '--chat') { o.chat = v; i++; } else if (a === '--before') { o.before = v; i++; } else if (a === '--stream') { o.streams.push(v); i++; } else if (a === '--channel') { o.channels.push(v); i++; } else if (a === '--token') { o.token = v; i++; } else if (a === '--tables') { o.tables = true; } else if (a === '--live-db') { o.liveDb = v; i++; } else if (a === '--chat-db') { o.chatDb = v; i++; } else if (a === '--table') { o.only.push(v); i++; } else throw new Error(`unknown argument ${a}`);++ if (a === '--live') { o.live = v; i++; } else if (a === '--chat') { o.chat = v; i++; } else if (a === '--before') { o.before = v; i++; } else if (a === '--stream') { o.streams.push(v); i++; } else if (a === '--channel') { o.channels.push(v); i++; } else if (a === '--token') { o.token = v; i++; } else throw new Error(`unknown argument ${a}`);}-- if (o.tables) { if (!o.liveDb || !o.chatDb) throw new Error('--tables needs --live-db and --chat-db'); } else if (!o.live || !o.chat) throw new Error('--live and --chat are required');++ if (!o.live || !o.chat) throw new Error('--live and --chat are required');return o;}--/** --tables: the staged tables of two database files, row for row. → number of tables that differ */--function tableParity(o, log = console.log) {-- const path = require('path');-- const Database = require('better-sqlite3');-- const { STAGED_KEYS, sliceHash } = require('../server/db/database');-- const live = new Database(path.resolve(o.liveDb), { readonly: true, fileMustExist: true });-- const chat = new Database(path.resolve(o.chatDb), { readonly: true, fileMustExist: true });-- const colsOf = (conn, t) => conn.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);-- let authority = {};-- try { authority = Object.fromEntries(chat.prepare('SELECT table_name, authority FROM table_authority').all().map((r) => [r.table_name, r.authority])); } catch { /* an old copy */ }-- let bad = 0;-- for (const [t, pk] of Object.entries(STAGED_KEYS)) {-- if (o.only.length && !o.only.includes(t)) continue;-- const lc = colsOf(live, t), cc = colsOf(chat, t);-- if (!lc.length || !cc.length) { bad++; log(`MISSING ${t}: ${!lc.length ? 'not in the Live copy' : 'not in the Chat copy'}`); continue; }-- const cols = lc.filter((c) => cc.includes(c)).sort();-- const q = `SELECT ${cols.join(', ')} FROM ${t} ORDER BY ${pk.join(', ')}`;-- const a = live.prepare(q).all(), b = chat.prepare(q).all();-- const ha = sliceHash(cols, a), hb = sliceHash(cols, b);-- const label = `${t.padEnd(28)} (${authority[t] || '?'})`;-- if (a.length === b.length && ha === hb) { log(`same ${label} rows ${a.length} sha256 ${ha.slice(0, 16)}`); continue; }-- bad++;-- const keyOf = (r) => JSON.stringify(pk.map((k) => r[k]));-- const rowOf = (r) => JSON.stringify(cols.map((c) => (r[c] === undefined ? null : r[c])));-- const ma = new Map(a.map((r) => [keyOf(r), rowOf(r)])), mb = new Map(b.map((r) => [keyOf(r), rowOf(r)]));-- const onlyLive = [...ma.keys()].filter((k) => !mb.has(k));-- const onlyChat = [...mb.keys()].filter((k) => !ma.has(k));-- const differ = [...ma.keys()].filter((k) => mb.has(k) && mb.get(k) !== ma.get(k));-- log(`DIFFERENT ${label} rows ${a.length} ≠ ${b.length} sha256 ${ha.slice(0, 16)} ≠ ${hb.slice(0, 16)}`);-- const show = (name, list) => { if (list.length) log(` ${name} (${list.length}): ${list.slice(0, 5).join(' ')}${list.length > 5 ? ' …' : ''}`); };-- show('only in Live', onlyLive); show('only in Chat', onlyChat); show('differ', differ);-- }-- live.close(); chat.close();-- log(bad ? `\n${bad} table(s) differ` : '\nall staged tables are the same');-- return bad;--}--const IGNORE = new Set(['request_id', 'trace_id', 'latest_id']);function diff(a, b, at = '', out = []) {if (out.length > 5) return out;@@@ -93,7 +48,6 @@ async function get(base, p, token) {async function main() {const o = parseArgs(process.argv.slice(2));-- if (o.tables) process.exit(tableParity(o) ? 1 : 0);const before = o.before ? `&before=${encodeURIComponent(o.before)}` : '';const paths = [`/api/chat/global/history?limit=200${before}`,@@@ -120,4 +74,4 @@ async function main() {if (require.main === module) main().catch((err) => { console.error(err.message); process.exit(2); });--module.exports = { parseArgs, tableParity };++module.exports = { parseArgs };ddeleted file mode 100644@@@ -1,51 +0,0 @@--#!/usr/bin/env node--/**-- * Who writes each staged table (roadmap C-04, docs/staged-tables-cutover.md), from Chat's side.-- *-- * node scripts/table-authority.js → { tables: { t: { authority, rows, mirror_pending } } }-- * node scripts/table-authority.js set <table> <live|chat> --force-- *-- * The normal way to move a table is Live's handoff (POST /internal/chat-tables/:table on Live), which-- * drains both sides and moves Chat and Live together. `set` changes only Chat's table_authority, for-- * when Live cannot run the handoff (Live down during a rollback); afterwards make Live agree with the-- * same handoff call. Back to 'live' is refused while changes of the table still wait for the Live-- * mirror (scripts/mirror-flush.js sends them). Uses the service's env (CHAT_DB_PATH).-- */--'use strict';----const db = require('../server/db/database');----function status() {-- const tables = {};-- for (const t of Object.keys(db.STAGED_KEYS)) {-- tables[t] = { authority: db.tableAuthority(t), rows: db.get(`SELECT COUNT(*) AS n FROM ${t}`).n, mirror_pending: db.mirrorPending(t) };-- }-- return { tables };--}----function set(table, authority) {-- if (!db.STAGED_KEYS[table]) throw new Error(`${table} is not a staged table (${Object.keys(db.STAGED_KEYS).join(', ')})`);-- if (authority !== 'live' && authority !== 'chat') throw new Error('authority is live or chat');-- const pending = db.mirrorPending(table);-- if (authority === 'live' && pending) throw new Error(`${pending} change(s) to ${table} have not reached Live yet: run scripts/mirror-flush.js first`);-- return { table, before: db.tableAuthority(table), after: db.setTableAuthority(table, authority) };--}----if (require.main === module) {-- const [cmd, table, authority] = process.argv.slice(2).filter((a) => !a.startsWith('--'));-- const force = process.argv.includes('--force');-- try {-- db.initDb({ captureMirror: false });-- if (!cmd || cmd === 'status') console.log(JSON.stringify(status(), null, 2));-- else if (cmd === 'set') {-- if (!force) throw new Error('set changes Chat alone; the handoff is Live\'s POST /internal/chat-tables/:table. Add --force if Live cannot run it.');-- console.log(JSON.stringify(set(table, authority), null, 2));-- } else throw new Error('usage: table-authority.js [status] | set <table> <live|chat> --force');-- db.close();-- } catch (err) {-- console.error(err.message);-- process.exit(2);-- }--}----module.exports = { status, set };@@@ -13,10 +13,9 @@* Lower cost + graceful scaling as Live: a DB poller with the high-water mark in* chat_ai_summaries.last_message_id, an adaptive window, a few subjects per tick.*-- * Writes go through Chat's staged writers under the _assertChatWrites rule: while Live is still the-- * table's authority a write is skipped with one log line, never forced. The job itself is off-- * unless config.ai.enabled (CHAT_AI_ENABLED=1) — off in development and the test harness, like-- * Chat's other background jobs.++ * Chat is the only writer of the two summaries tables since the C-04 cutover: the writes are plain++ * calls, no authority gate. The job itself is off unless config.ai.enabled (CHAT_AI_ENABLED=1) —++ * off in development and the test harness, like Chat's other background jobs.*/'use strict';@@@ -57,7 +56,6 @@ let _running = false;let _timer = null;let _tickInFlight = false;let _lastUserPass = 0;--let _writeSkipLogged = false;// ── Small utils ──────────────────────────────────────────────────────────────// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (CURRENT_TIMESTAMP). Match that format.@@@ -118,24 +116,7 @@ function _cleanTimeline(arr) {.sort((a, b) => _parseSqlTime(a.ts) - _parseSqlTime(b.ts));}--// ── The authority gate ───────────────────────────────────────────────────────--// Live still writes the two summaries tables until the cutover: the job skips (one log line), it--// never forces a write, and writes that would race the flip are caught the same way.--function _tablesHold() {-- return db.tableAuthority('chat_ai_summaries') === 'chat' && db.tableAuthority('chat_timeline_events') === 'chat';--}--function _logWriteSkip() {-- if (_writeSkipLogged) return;-- _writeSkipLogged = true;-- console.log('[ChatAI] chat_ai_summaries/chat_timeline_events are still written by Live (table_authority live) — the job skips its writes');--}--function _write(fn, ...args) {-- try { return fn(...args); }-- catch (err) {-- if (err && err.code === 'table.not_chat') { _logWriteSkip(); return null; }-- throw err;-- }--}++// Chat is the only writer of the two summaries tables since the C-04 cutover: no authority gate.// ── GLOBAL ───────────────────────────────────────────────────────────────────async function _refreshGlobal() {@@@ -172,8 +153,8 @@ async function _refreshGlobal() {if (!parsed) return false;const nowIso = _sqlTime(now);-- _write(db.addChatTimelineEvents, 'global', 0, _stampAdditions(parsed.timeline, nowIso, now));-- _write(db.upsertChatAiSummary, {++ db.addChatTimelineEvents('global', 0, _stampAdditions(parsed.timeline, nowIso, now));++ db.upsertChatAiSummary({scope: 'global', subject_id: 0, window: 'global',overview: _clip(parsed.recent_overview || '', 2000),memory_json: _clip(parsed.memory || priorMemory, MEMORY_MAX_CHARS),@@@ -215,7 +196,7 @@ async function _refreshUser(uid, maxId) {const nowIso = _sqlTime(now);const _lastRow = dayRows[dayRows.length - 1] || {};const activityTs = _lastRow.timestamp || _lastRow.created_at || nowIso;-- _write(db.upsertChatAiSummary, {++ db.upsertChatAiSummary({scope: 'user', subject_id: uid, window: 'rolling',overview: JSON.stringify({today: _clip(parsed.overview_24h || '', 1200),@@@ -271,7 +252,7 @@ async function _refreshRelayUser(ru) {if (!parsed) return false;const nowIso = _sqlTime(now);-- _write(db.upsertChatAiSummary, {++ db.upsertChatAiSummary({scope: 'relay', subject_id: ru.id, window: 'rolling',overview: JSON.stringify({today: _clip(parsed.overview_24h || '', 1200),@@@ -325,7 +306,7 @@ async function _refreshAnon(anonId) {if (!parsed) return false;const nowIso = _sqlTime(now);-- _write(db.upsertChatAiSummary, {++ db.upsertChatAiSummary({scope: 'anon', subject_id: subjectId, window: 'rolling',overview: JSON.stringify({today: _clip(parsed.overview_24h || '', 1200),@@@ -365,7 +346,6 @@ async function _tick() {_tickInFlight = true;try {if (!config.ai.enabled) return; // the job's switch (off in dev and tests)-- if (!_tablesHold()) { _logWriteSkip(); return; }try { await _refreshGlobal(); }catch (e) { console.warn('[ChatAI] global refresh failed:', e.message); }@@@ -390,7 +370,7 @@ async function _seedTimelineEvents() {if (!row) return;let tl = []; try { tl = JSON.parse(row.timeline_json || '[]'); } catch { tl = []; }const cleaned = _cleanTimeline(tl);-- if (cleaned.length) { _write(db.addChatTimelineEvents, 'global', 0, cleaned); console.log(`[ChatAI] Seeded ${cleaned.length} timeline event(s)`); }++ if (cleaned.length) { db.addChatTimelineEvents('global', 0, cleaned); console.log(`[ChatAI] Seeded ${cleaned.length} timeline event(s)`); }} catch { /* */ }}@@@ -147,8 +147,6 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServeconnections: chatServer.getTotalConnections(),live: { last_sync_at: ctx.stats.lastSyncAt, last_success_at: ctx.stats.lastSuccessAt, failures: ctx.stats.failures, last_error: ctx.stats.lastError },mirror: mirror ? { enabled: config.live.mirror, pending, last_error: mirror.lastError() } : null,-- // Who writes each staged table (C-04, docs/staged-tables-cutover.md).-- table_authority: checks.db.status === 'ok' ? (() => { try { return db.stagedAuthorities(); } catch { return null; } })() : null,events: {enabled: !!config.events.url,consumer: events ? { enabled: events.enabled, ...events.stats } : null,@@@ -24,18 +24,12 @@* without it that op alone is refused ({ ok: false, code: 'capability.denied' }) and the* rest of the batch still runs.*-- * Staged tables (roadmap C-04, docs/staged-tables-cutover.md), same endpoint and capability:-- * db ops addChannelModerator … addChatTimelineEvents Live's writers once a table is at-- * 'chat' (awaited one by one, with an idempotency key); each answers { value, mirror }:-- * Live's own return value and the rows as they are now, which Live applies to its copy-- * at once. Refused while the table is at 'live'.-- * stagedApply [changes] Live's captured changes while a table is at 'live' (this copy-- * stays current; refused per change once Chat writes the table)-- * stagedSlice [table, where, columns] { count, hash, … } for Live's dual read-- * tableAuthority [] { table: 'live' | 'chat' }-- * setTableAuthority [table, authority] Chat's half of the handoff Live runs (never-- * automatic): to 'chat' needs the Live mirror on; back to 'live' first sends every-- * queued change of the table to Live and refuses while any is left.++ * The six tables Chat owns (channel moderators/settings, emotes, user tags, chat-AI++ * summaries/timeline), same endpoint and capability: db ops addChannelModerator …++ * addChatTimelineEvents. Chat is their only writer since the C-04 cutover (there is no++ * authority switch any more); Live's current release still calls these ops until its next++ * deploy, each answering { value, mirror } — Live's own return value and the rows as they are++ * now, which Live applies to its copy at once.** GET /internal/live/presence capability chat.presence.read* Who is connected where (counts, slow modes, user/anon → ip), for Live's synchronous@@@ -60,7 +54,8 @@ const DB_OPS = new Set(['logModerationAction', 'recordFirstChat', 'setTtsVoiceOverride', 'deleteTtsVoiceOverride','createChannelSound', 'setChannelSoundEmote', 'deleteChannelSound', 'renameChannelSoundCommand','updateChannelSoundEmoteRefs',-- // Staged tables at 'chat' (each refuses while its table is at 'live').++ // The six tables Chat owns (C-04 done): Live's current release's writers, kept as thin calls++ // to the same database functions until Live N+1 is deployed.'addChannelModerator', 'removeChannelModerator', 'upsertChannelModerationSettings', 'setChannelAlertSound','createEmote', 'updateEmote', 'deleteEmote', 'setEmoteMedia', 'grantUserTag', 'revokeUserTag','upsertChatAiSummary', 'addChatTimelineEvents',@@@ -119,34 +114,6 @@ const REF_TTL_MS = 10 * 60 * 1000; // in memoryconst REF_KEEP_MS = 24 * 3600 * 1000; // in bridge_refsconst REF_MIN = -(2 ** 40); // Live's placeholders are ≤ this--/**-- * Chat's half of a staged table's handoff (Live's server/chat/chat-tables.js runs it, its writers-- * waiting meanwhile). To 'chat': Chat writes the table from now on and the mirror copies it to Live —-- * so the mirror must be on. Back to 'live': every change Chat made to the table reaches Live first;-- * the last check and the switch run in one tick, so no Chat write lands between them.-- */--async function handOver(table, authority, { mirror, config }) {-- if (!db.STAGED_KEYS[table]) throw new Error(`${table} is not a staged table`);-- if (authority !== 'live' && authority !== 'chat') throw new Error('authority is live or chat');-- if (authority === 'chat') {-- if (!mirror || !config.live.mirror) throw new Error('LIVE_MIRROR is off: Live would never get this table back');-- db.setTableAuthority(table, 'chat');-- console.log(`[Bridge] ${table}: Chat writes it now (table_authority chat)`);-- return { table, authority: 'chat', mirror_pending: db.mirrorPending(table) };-- }-- const until = Date.now() + 15000;-- while (db.mirrorPending(table) > 0 && mirror && config.live.mirror && Date.now() < until) {-- const r = await mirror.flush();-- if (r && r.error) break;-- if (r && r.busy) await new Promise((res) => setTimeout(res, 100));-- }-- const left = db.mirrorPending(table);-- if (left) throw new Error(`${left} change(s) to ${table} not in Live yet${mirror && mirror.lastError() ? ` (${mirror.lastError()})` : ''}`);-- db.setTableAuthority(table, 'live');-- console.log(`[Bridge] ${table}: back to Live (table_authority live)`);-- return { table, authority: 'live', mirror_pending: 0 };--}--function createBridge({ chatServer, mirror = null, config = require('../config') }) {const router = express.Router();const refs = new Map(); // `${boot}|${ref}` → { id, at }@@@ -262,15 +229,6 @@ function createBridge({ chatServer, mirror = null, config = require('../config')else throw new Error(`invalidate: unknown kind ${kind}`);return null;}-- // Staged tables (C-04).-- case 'stagedApply':-- return db.applyStagedChanges(args[0]);-- case 'stagedSlice':-- return db.stagedSlice(String(args[0] || ''), args[1] && typeof args[1] === 'object' ? args[1] : {}, Array.isArray(args[2]) ? args[2] : null);-- case 'tableAuthority':-- return db.stagedAuthorities();-- case 'setTableAuthority':-- return handOver(String(args[0] || ''), String(args[1] || ''), { mirror, config });default:throw new Error(`unknown op ${op}`);}@@@ -323,4 +281,4 @@ function createBridge({ chatServer, mirror = null, config = require('../config')return router;}--module.exports = { createBridge, handOver, DB_OPS, SERVER_OPS, MESSAGE_SEND, sendsMessage };++module.exports = { createBridge, DB_OPS, SERVER_OPS, MESSAGE_SEND, sendsMessage };@@@ -3,9 +3,8 @@** After the cutover Chat is the only writer of its tables, but Live still reads some of them in* place (home-page stats, recaps, AI chat context, VOD chat replay, /api/mod queues, analytics),-- * and rollback means Live becomes the authority again. So every change Chat makes to its tables —-- * and to each staged table once it is at 'chat' (C-04, docs/staged-tables-cutover.md) — is copied-- * into Live's tables of the same name, same ids:++ * and rollback means Live becomes the authority again. So every change Chat makes to its tables++ * (C-02) is copied into Live's tables of the same name, same ids:** - this connection's TEMP triggers (db.initDb({ captureMirror: true })) record each change in* live_mirror_outbox — writes by the importer (rows that came from Live) are never recorded;@@@ -40,8 +39,7 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lofor (const r of rows) byKey.set(`${r.tbl}|${r.pk}`, r);const changes = [];for (const r of byKey.values()) {-- // A staged table (C-04) is queued only while Chat writes it (its triggers check).-- const cols = db.CHAT_TABLES[r.tbl] || db.STAGED_KEYS[r.tbl];++ const cols = db.CHAT_TABLES[r.tbl];if (!cols) continue;let pk;try { pk = JSON.parse(r.pk); } catch { continue; }@@@ -17,10 +17,9 @@* requests, their tags, and the AI summaries and timeline about them;* - the channel, stream and user mirrors of their Live account (the user mirror* stays as a tombstone).-- * Moderation actions stay as recorded. A staged table (channel_moderators,-- * channel_moderation_settings, emotes, user_tags, chat_ai_summaries,-- * chat_timeline_events) is erased here only while Chat writes it (table_authority);-- * until then Live erases it and its capture keeps this copy current.++ * Moderation actions stay as recorded. Chat owns the six chat tables++ * (channel_moderators, channel_moderation_settings, emotes, user_tags,++ * chat_ai_summaries, chat_timeline_events, C-04 done) and erases them all here.* Chat then confirms with counts.*/const db = require('../db/database');@@@ -92,9 +91,6 @@ function exportPart(d, subject) {// ── Deletion ───────────────────────────────────────────────────--const STAGED = ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events'];--const chatWrites = (d, table) => { try { const r = d.prepare('SELECT authority FROM table_authority WHERE table_name = ?').get(table); return !!r && r.authority === 'chat'; } catch { return false; } };--function erase(d, subjects) {const ids = liveIds(d, subjects);const erased = {}; const retained = {};@@@ -102,8 +98,6 @@ function erase(d, subjects) {const del = (key, table, sc, ic) => {const w = whereFor(d, table, sc, ic, subjects, ids);if (!w) return;-- // A staged table Live still writes: Live erases it, and its capture brings the change here.-- if (STAGED.includes(table) && !chatWrites(d, table)) { add(retained, 'erased_by_live', d.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${w[0]}`).get(...w[1]).n); return; }add(erased, key, d.prepare(`DELETE FROM ${table} WHERE ${w[0]}`).run(...w[1]).changes);};d.transaction(() => {@@@ -137,8 +131,8 @@ function erase(d, subjects) {if (ids.length) {// Their channel: emotes, sounds, moderators, stats, audio requests, then the Live mirrors.const channels = has(d, 'ctx_channels', 'user_id') ? d.prepare(`SELECT id FROM ctx_channels WHERE user_id IN ${inList(ids)}`).all(...ids).map((r) => r.id) : [];-- if (channels.length && has(d, 'channel_moderators', 'channel_id') && chatWrites(d, 'channel_moderators')) add(erased, 'moderator_roles', d.prepare(`DELETE FROM channel_moderators WHERE channel_id IN ${inList(channels)}`).run(...channels).changes);-- if (channels.length && has(d, 'channel_moderation_settings', 'channel_id') && chatWrites(d, 'channel_moderation_settings')) d.prepare(`DELETE FROM channel_moderation_settings WHERE channel_id IN ${inList(channels)}`).run(...channels);++ if (channels.length && has(d, 'channel_moderators', 'channel_id')) add(erased, 'moderator_roles', d.prepare(`DELETE FROM channel_moderators WHERE channel_id IN ${inList(channels)}`).run(...channels).changes);++ if (channels.length && has(d, 'channel_moderation_settings', 'channel_id')) d.prepare(`DELETE FROM channel_moderation_settings WHERE channel_id IN ${inList(channels)}`).run(...channels);del('emotes', 'emotes', [], ['user_id']);del('sounds', 'channel_sounds', ['created_by_subject_id'], ['created_by']);del('channel_stats', 'stream_first_chats', [], ['channel_user_id']);@@@ -14,8 +14,7 @@* POST /:channelId/moderation/messages/:messageId/delete - Delete a chat message (mod+)** Channels, users and moderation policy come through live-context; the moderator and settings rows-- * are Chat's own tables. A write while Live is still the table's writer answers 503-- * { ok: false, error: 'not yet' } and never writes; after the flip a settings or moderator change++ * are Chat's own tables (C-04 done, Chat is their only writer). A settings or moderator change* refreshes Chat's policy cache at once (the db helpers call invalidateChannel).*/'use strict';@@@ -28,12 +27,6 @@ const permissions = require('../auth/permissions');const router = express.Router();--/** A staged-table write while Live is still the authority: 503, never a write. */--function notYet(res, err) {-- if (err && err.code === 'table.not_chat') { res.status(503).json({ ok: false, error: 'not yet' }); return true; }-- return false;--}--function parseBoolean(value, fallback) {if (value === undefined || value === null) return fallback;if (typeof value === 'boolean') return value;@@@ -127,7 +120,6 @@ router.post('/:channelId/mods', requireAuth, async (req, res) => {});res.json({ message: `${targetUser.username} added as channel moderator`, moderators: db.getChannelModerators(channelId) });} catch (err) {-- if (notYet(res, err)) return;res.status(500).json({ error: 'Failed to add channel moderator' });}});@@@ -154,7 +146,6 @@ router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {});res.json({ message: 'Channel moderator removed', moderators: db.getChannelModerators(channelId) });} catch (err) {-- if (notYet(res, err)) return;res.status(500).json({ error: 'Failed to remove channel moderator' });}});@@@ -243,7 +234,6 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r});res.json({ settings: settings.value });} catch (err) {-- if (notYet(res, err)) return;console.error('[ChannelMod] Failed to update moderation settings:', err.message);res.status(500).json({ error: 'Failed to update moderation settings' });}@@@ -313,7 +303,6 @@ router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, re});res.json({ message: 'Message deleted', ids: [messageId] });} catch (err) {-- if (notYet(res, err)) return;res.status(500).json({ error: 'Failed to delete message' });}});@@@ -2838,8 +2838,8 @@ class ChatServer {}/**-- * /slow and /subonly. The channel's saved setting is the truth: it is written where the table's-- * authority says (Live's effect, or here once Chat writes channel_moderation_settings), then the++ * /slow and /subonly. The channel's saved setting is the truth: it is written to Chat's own++ * channel_moderation_settings row (C-04 done), then the* channel's whole room hears it (every live slot, the offline room, popouts). Nothing changes when* the write fails. → true when saved.*/@@@ -19,8 +19,8 @@* GET /api/emotes/all/:streamId - All emotes available in a stream context** Uploaded bytes go to OpenVibe.Media with Chat's own service token; the row stores media_url and-- * media_asset_id, and responses use media_url (existing Live rows already carry one). Writes answer-- * 503 { ok: false, error: 'not yet' } while Live is still the table's writer (table_authority live).++ * media_asset_id, and responses use media_url (existing Live rows already carry one). Chat is the++ * emotes table's only writer (C-04 done).*/'use strict';@@@ -73,11 +73,6 @@ function emoteUploadRateLimited(userId) {return false;}--/** A staged-table write while Live is still the authority: 503, never a write. */--function notYet(res, err) {-- if (err && err.code === 'table.not_chat') { res.status(503).json({ ok: false, error: 'not yet' }); return true; }-- return false;--}const fileUrl = (e) => e.media_url || `/api/emotes/file/${path.basename(e.url)}`;// ══════════════════════════════════════════════════════════════@@@ -343,7 +338,6 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {},});} catch (err) {-- if (notYet(res, err)) return;console.error('[Emotes] Upload error:', err);if (err && /UNIQUE constraint/i.test(err.message || '')) {const code = String((req.body && req.body.code) || '').trim();@@@ -413,7 +407,6 @@ router.patch('/:id', requireAuth, async (req, res) => {}res.json({ message: 'Emote updated', code: patch.code ?? emote.code, size: patch.size ?? emote.size });} catch (err) {-- if (notYet(res, err)) return;res.status(500).json({ error: 'Failed to update emote' });}});@@@ -437,7 +430,6 @@ router.delete('/:id', requireAuth, async (req, res) => {}res.json({ message: 'Emote deleted' });} catch (err) {-- if (notYet(res, err)) return;res.status(500).json({ error: 'Failed to delete emote' });}});@@@ -458,8 +458,8 @@ router.post('/alert/:kind', requireAuth, uploadLimit, soundUpload.single('sound'const ext = path.extname(finalPath).toLowerCase();const mime = EXT_TO_MIME[ext] || 'audio/mpeg';-- // The alert columns are on channel_moderation_settings (Live's until the C-04 handoff, then-- // Chat's and mirrored); Live's alerts read them.++ // The alert columns are on channel_moderation_settings, Chat's own since the C-04 cutover++ // (mirrored to Live, whose alerts read them).await ctx.effects.setChannelAlertSound(channel.id, kind, finalPath, mime, req.user.id);res.json({ set: true, kind, url: `/api/sounds/file/${path.basename(finalPath)}` });} catch (err) {@@@ -36,29 +36,6 @@ const CHAT_TABLES = {stream_first_chats: ['chatter_key', 'channel_user_id'],moderation_actions: ['id'],};--// Chat-target tables whose writer moves per table (roadmap C-04, docs/staged-tables-cutover.md).--// table_authority says who writes each one: 'live' (the default) — Live writes, and every change--// it makes reaches this copy over the bridge (applyStagedChanges); 'chat' — the staged writes--// below are the only writers, Live's writers call them over the bridge and the Live mirror copies--// the table back. The notes say who wrote them before the move.--const STAGED_TABLES = {-- channel_moderators: 'Live /api/channels (channel-mod-routes) while at live',-- channel_moderation_settings: 'Live /api/channels, the dashboard, /slow and alert sounds while at live',-- emotes: 'Live /api/emotes and its Media asset-sync while at live',-- user_tags: 'no writer since Live’s game tags went read-only; data only',-- chat_ai_summaries: 'Live server/ai/chat-ai.js while at live',-- chat_timeline_events: 'Live server/ai/chat-ai.js while at live',--};--// Their primary keys (the mirror and Live's changes address rows by them).--const STAGED_KEYS = {-- channel_moderators: ['id'],-- channel_moderation_settings: ['channel_id'],-- emotes: ['id'],-- user_tags: ['id'],-- chat_ai_summaries: ['id'],-- chat_timeline_events: ['id'],--};--function getDb() {if (!db) {_dbPath = path.resolve(config.dbPath);@@@ -131,12 +108,9 @@ function initDb({ captureMirror = false } = {}) {}// auth/network-session.js resolves a verified Network token to its user by subject.d.exec('CREATE INDEX IF NOT EXISTS idx_ctx_users_subject ON ctx_users(subject_id)');-- const upsertAuth = d.prepare('INSERT INTO table_authority (table_name, authority, note) VALUES (?, ?, ?) ON CONFLICT(table_name) DO UPDATE SET authority = excluded.authority, note = excluded.note');-- for (const t of Object.keys(CHAT_TABLES)) upsertAuth.run(t, 'chat', 'Chat writes; Live keeps a read mirror');-- // A staged table keeps the authority it was handed (a restart never moves it back).-- const seedStaged = d.prepare("INSERT INTO table_authority (table_name, authority, note) VALUES (?, 'live', ?) ON CONFLICT(table_name) DO UPDATE SET note = excluded.note");-- for (const [t, note] of Object.entries(STAGED_TABLES)) seedStaged.run(t, note);-- _authority.at = 0;++ // C-04 is done: Chat is the only writer of the six tables, so there is no authority left to++ // record. The table's migration drops it from databases an earlier release created; a new++ // database never gets it (schema.sql no longer has it).if (captureMirror) installMirrorTriggers();return d;}@@@ -145,9 +119,6 @@ function installMirrorTriggers() {const d = getDb();const tables = [...Object.entries(CHAT_TABLES).map(([table, pk]) => [table, pk, '']),-- // A staged table is mirrored only while Chat writes it (read at each change, so a handoff-- // made by another process counts at once).-- ...Object.entries(STAGED_KEYS).map(([table, pk]) => [table, pk, `WHEN (SELECT authority FROM main.table_authority WHERE table_name = '${table}') = 'chat'`]),];for (const [table, pk, when] of tables) {const obj = (alias) => `json_object(${pk.map((c) => `'${c}', ${alias}.${c}`).join(', ')})`;@@@ -993,53 +964,14 @@ function searchChannelChatMessages(channel, { query, userId, limit = 50, offset};}--// ── Staged tables (roadmap C-04) ──────────────────────────────--// Who writes each one is table_authority (STAGED_TABLES above). The reads are cached for a second:--// a handoff made by another process (scripts/table-authority.js) counts within that.--const _authority = { at: 0, map: new Map() };--function tableAuthority(table) {-- if (Date.now() - _authority.at > 1000) {-- _authority.map = new Map(all('SELECT table_name, authority FROM table_authority').map((r) => [r.table_name, r.authority]));-- _authority.at = Date.now();-- }-- return _authority.map.get(table) || (CHAT_TABLES[table] ? 'chat' : 'live');--}--function setTableAuthority(table, authority) {-- if (!STAGED_KEYS[table]) throw new Error(`${table} is not a staged table`);-- if (authority !== 'live' && authority !== 'chat') throw new Error('authority is live or chat');-- run('UPDATE table_authority SET authority = ? WHERE table_name = ?', [authority, table]);-- _authority.at = 0;-- return tableAuthority(table);--}--/** { table: authority } for the staged tables. */--function stagedAuthorities() {-- return Object.fromEntries(Object.keys(STAGED_KEYS).map((t) => [t, tableAuthority(t)]));--}--/** Changes of this table still queued for Live (the mirror drains them). */--function mirrorPending(table) {-- return get('SELECT COUNT(*) AS n FROM live_mirror_outbox WHERE tbl = ?', [table])?.n || 0;--}----const _colCache = new Map();--function _columns(table) {-- if (!_colCache.has(table)) _colCache.set(table, new Set(getDb().prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));-- return _colCache.get(table);--}----/** A staged write refuses while Live still writes the table: one writer at a time. */--function _assertChatWrites(table) {-- if (tableAuthority(table) === 'chat') return;-- const err = new Error(`${table} is written by Live (table_authority live)`);-- err.code = 'table.not_chat';-- throw err;--}----/**-- * What a staged write returns over the bridge: `value` — what Live's function of the same name-- * returns (its callers keep working), and `mirror` — the rows as they are now, in the Live mirror's-- * change shape, so Live's copy is right at once (the mirror sends the same rows again later).-- */--function _staged(value, table, rows = [], deletedPks = []) {++// ── The six tables' writes (channel moderators, settings, emotes, tags, chat AI) ──++// Chat has been the only writer of these six since the C-04 cutover, so there is no authority gate++// any more. Until Live N+1 is deployed, Live's current release still calls these functions over the++// bridge (op `db`) and reads both halves of the answer: `value` — what Live's function of the same++// name returns (its callers keep working) — and `mirror` — the rows as they are now, in the Live++// mirror's change shape, which Live applies to its own copy at once. Past Live N+1 nothing reads++// either half; the callers here use `.value` only.++function _writeResult(value, table, rows = [], deletedPks = []) {const plainValue = value && typeof value === 'object' && 'changes' in value && 'lastInsertRowid' in value? { changes: value.changes, lastInsertRowid: Number(value.lastInsertRowid) } : value;return {@@@ -1053,21 +985,19 @@ function _staged(value, table, rows = [], deletedPks = []) {// Channel moderators, settings and alert sounds (Live's /api/channels, the dashboard, /slow).function addChannelModerator(channelId, userId, addedBy) {-- _assertChatWrites('channel_moderators');const out = transaction(() => {const res = run('INSERT OR IGNORE INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)', [channelId, userId, addedBy]);-- return _staged(res, 'channel_moderators', all('SELECT * FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]));++ return _writeResult(res, 'channel_moderators', all('SELECT * FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]));});_ctx().invalidateChannel(channelId);return out;}function removeChannelModerator(channelId, userId) {-- _assertChatWrites('channel_moderators');const out = transaction(() => {const gone = all('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);const res = run('DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);-- return _staged(res, 'channel_moderators', [], gone.map((r) => ({ id: r.id })));++ return _writeResult(res, 'channel_moderators', [], gone.map((r) => ({ id: r.id })));});_ctx().invalidateChannel(channelId);return out;@@@ -1075,7 +1005,6 @@ function removeChannelModerator(channelId, userId) {// Live's upsertChannelModerationSettings, clamps and all.function upsertChannelModerationSettings(channelId, fields) {-- _assertChatWrites('channel_moderation_settings');const out = transaction(() => {const existing = get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);if (existing) {@@@ -1179,7 +1108,7 @@ function upsertChannelModerationSettings(channelId, fields) {}if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);const row = get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);-- return _staged(row, 'channel_moderation_settings', [row]);++ return _writeResult(row, 'channel_moderation_settings', [row]);});_ctx().invalidateChannel(channelId);return out;@@@ -1187,14 +1116,13 @@ function upsertChannelModerationSettings(channelId, fields) {// Donation / goal alert sounds live on the settings row (url = the file's path in the sounds dir).function setChannelAlertSound(channelId, kind, url, mime) {-- _assertChatWrites('channel_moderation_settings');const out = transaction(() => {if (!get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId])) {run('INSERT INTO channel_moderation_settings (channel_id) VALUES (?)', [channelId]);}const col = kind === 'goal' ? 'goal_sound' : 'donation_sound';const res = run(`UPDATE channel_moderation_settings SET ${col}_url = ?, ${col}_mime = ? WHERE channel_id = ?`, [url || null, mime || null, channelId]);-- return _staged(res, 'channel_moderation_settings', [get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId])]);++ return _writeResult(res, 'channel_moderation_settings', [get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId])]);});_ctx().invalidateChannel(channelId);return out;@@@ -1203,69 +1131,62 @@ function setChannelAlertSound(channelId, kind, url, mime) {// Emotes (Live's /api/emotes; media_url/media_asset_id are OpenVibe.Media's copy — Live's// asset-sync filled them onto the shared rows, Chat uploads through its own Media token now).function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100, media_url = null, media_asset_id = null }) {-- _assertChatWrites('emotes');return transaction(() => {const res = run(`INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size, media_url, media_asset_id)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,[user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100)), media_url || null, media_asset_id || null]);-- return _staged(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);++ return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);});}function updateEmote(id, { code, size } = {}) {-- _assertChatWrites('emotes');const sets = [];const params = [];if (code !== undefined) { sets.push('code = ?'); params.push(code); }if (size !== undefined) { sets.push('size = ?'); params.push(Math.min(400, Math.max(25, parseInt(size, 10) || 100))); }-- if (!sets.length) return _staged({ changes: 0 }, 'emotes');++ if (!sets.length) return _writeResult({ changes: 0 }, 'emotes');return transaction(() => {const res = run(`UPDATE emotes SET ${sets.join(', ')} WHERE id = ?`, [...params, id]);-- return _staged(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);++ return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);});}function deleteEmote(id) {-- _assertChatWrites('emotes');return transaction(() => {const had = get('SELECT id FROM emotes WHERE id = ?', [id]);const res = run('DELETE FROM emotes WHERE id = ?', [id]);-- return _staged(res, 'emotes', [], had ? [{ id: had.id }] : []);++ return _writeResult(res, 'emotes', [], had ? [{ id: had.id }] : []);});}/** The emote's copy on OpenVibe.Media (Live's asset-sync). */function setEmoteMedia(id, mediaUrl, mediaAssetId) {-- _assertChatWrites('emotes');return transaction(() => {const res = run('UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?', [mediaUrl || null, mediaAssetId || null, id]);-- return _staged(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);++ return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);});}// User tags (owned chat tags; Live has had no writer since its game tags went read-only).function grantUserTag(userId, tagId, source = 'shop') {-- _assertChatWrites('user_tags');return transaction(() => {const res = run('INSERT OR IGNORE INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)', [userId, String(tagId), source || 'shop']);-- return _staged(res, 'user_tags', [get('SELECT * FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)])]);++ return _writeResult(res, 'user_tags', [get('SELECT * FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)])]);});}function revokeUserTag(userId, tagId) {-- _assertChatWrites('user_tags');return transaction(() => {const gone = all('SELECT id FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);const res = run('DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);-- return _staged(res, 'user_tags', [], gone.map((r) => ({ id: r.id })));++ return _writeResult(res, 'user_tags', [], gone.map((r) => ({ id: r.id })));});}// Chat AI (Live's server/ai/chat-ai.js): rolling summaries and the append-only timeline.function upsertChatAiSummary(sfx) {-- _assertChatWrites('chat_ai_summaries');const {scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',message_count = 0, window_message_count = 0, last_message_id = 0,@@@ -1291,13 +1212,12 @@ function upsertChatAiSummary(sfx) {[scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,window_message_count, last_message_id, window_label, window_start, window_end]);-- return _staged(res, 'chat_ai_summaries', [get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?', [scope, subject_id || 0, window])]);++ return _writeResult(res, 'chat_ai_summaries', [get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?', [scope, subject_id || 0, window])]);});}function addChatTimelineEvents(scope, subjectId, events) {-- _assertChatWrites('chat_timeline_events');-- if (!Array.isArray(events) || !events.length) return _staged(0, 'chat_timeline_events');++ if (!Array.isArray(events) || !events.length) return _writeResult(0, 'chat_timeline_events');return transaction(() => {let n = 0;const ids = [];@@@ -1310,7 +1230,7 @@ function addChatTimelineEvents(scope, subjectId, events) {n++; // Live counts every event it tried, the duplicates included} catch { /* */ }}-- return _staged(n, 'chat_timeline_events', ids.map((id) => get('SELECT * FROM chat_timeline_events WHERE id = ?', [id])));++ return _writeResult(n, 'chat_timeline_events', ids.map((id) => get('SELECT * FROM chat_timeline_events WHERE id = ?', [id])));});}@@@ -1461,75 +1381,12 @@ function getAnonsNeedingChatAi({ threshold = 12, staleCutoffIso, sinceTs, limitreturn all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);}--/**-- * Live's changes to a staged table it still writes (its capture, relayed over the bridge): the rows-- * as they are in Live now, or a delete. Applied only while the table is at 'live' — once Chat writes-- * it, Live's copy is the mirror and never flows back. REPLACE: the authority's row wins over-- * whatever holds its key or one of its unique columns here.-- */--function applyStagedChanges(changes) {-- const out = { applied: 0, skipped: [] };-- transaction(() => {-- for (const c of Array.isArray(changes) ? changes : []) {-- const pk = STAGED_KEYS[c && c.table];-- if (!pk) { out.skipped.push({ table: c && c.table, reason: 'not a staged table' }); continue; }-- if (tableAuthority(c.table) !== 'live') { out.skipped.push({ table: c.table, reason: 'Chat writes this table (table_authority chat)' }); continue; }-- const have = _columns(c.table);-- try {-- if (c.op === 'delete' && c.pk) {-- run(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`, pk.map((k) => c.pk[k]));-- } else if (c.op === 'upsert' && c.row && pk.every((k) => c.row[k] != null)) {-- const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));-- run(`INSERT OR REPLACE INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, cols.map((k) => c.row[k]));-- } else { out.skipped.push({ table: c.table, reason: 'bad change' }); continue; }-- out.applied++;-- } catch (err) {-- out.skipped.push({ table: c.table, pk: c.pk || (c.row && Object.fromEntries(pk.map((k) => [k, c.row[k]]))), reason: err.message });-- }-- }-- });-- for (const c of Array.isArray(changes) ? changes : []) {-- if (c && (c.table === 'channel_moderators' || c.table === 'channel_moderation_settings')) {-- const ch = (c.row && c.row.channel_id) || (c.pk && c.pk.channel_id);-- if (ch) _ctx().invalidateChannel(ch);-- }-- }-- return out;--}----/**-- * Hash of a list of rows over `columns` (Live's server/chat/chat-tables-sync.js computes the same):-- * sha256 of the JSON array of rows, each row the array of its values in column order.-- */--function sliceHash(columns, rows) {-- const body = JSON.stringify(rows.map((r) => columns.map((c) => (r[c] === undefined ? null : r[c]))));-- return require('crypto').createHash('sha256').update(body).digest('hex');--}----/**-- * One slice of a staged table for Live's dual read: the rows whose columns equal `where` (null-safe),-- * ordered by key → { count, hash, columns, rows (when 50 or fewer) }. `columns` limits the hash to-- * the ones Live has too.-- */--function stagedSlice(table, where = {}, columns = null) {-- const pk = STAGED_KEYS[table];-- if (!pk) throw new Error(`${table} is not a staged table`);-- const have = _columns(table);-- const keys = Object.keys(where || {});-- if (keys.some((k) => !have.has(k))) throw new Error(`unknown column in ${table}`);-- const cols = (Array.isArray(columns) && columns.length ? columns.filter((c) => have.has(c)) : [...have]).sort();-- const rows = all(`SELECT ${cols.join(', ')} FROM ${table} WHERE ${keys.map((k) => `${k} IS ?`).join(' AND ') || '1'} ORDER BY ${pk.join(', ')}`, keys.map((k) => where[k]));-- return { count: rows.length, hash: sliceHash(cols, rows), columns: cols, rows: rows.length <= 50 ? rows : undefined };--}--// ── Meta ─────────────────────────────────────────────────────function getMeta(key) { return get('SELECT value FROM chat_meta WHERE key = ?', [key])?.value ?? null; }function setMeta(key, value) { return run('INSERT INTO chat_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value == null ? null : String(value)]); }module.exports = {CHAT_TABLES,-- STAGED_TABLES,-- STAGED_KEYS,getDb,initDb,installMirrorTriggers,@@@ -1605,14 +1462,7 @@ module.exports = {getEmotesByUser,getModerationActions,searchChannelChatMessages,-- // staged tables (C-04): authority, Live's changes, dual-read slices, and the writes once Chat owns them-- tableAuthority,-- setTableAuthority,-- stagedAuthorities,-- mirrorPending,-- applyStagedChanges,-- stagedSlice,-- sliceHash,++ // The six tables Chat owns (C-04 done): moderators, settings, emotes, tags, chat AIaddChannelModerator,removeChannelModerator,upsertChannelModerationSettings,@@@ -8,10 +8,9 @@-- sound keeps the id it had in Live. Rows still carry Live's user ids; *subject_id-- columns add the OpenVibe.Network subject (usr_…) where it is known, so later waves can-- drop Live ids.++-- 2. The six chat-target tables — channel moderators and settings, emotes, user tags,++-- chat-AI summaries and timeline. Since the C-04 cutover Chat is their only writer;++-- there is no authority switch any more (Live's N+1 reads them through Chat's API).-- 3. Bookkeeping: projections of Live data (ctx_*), the events outbox, the Live mirror-- outbox, import holds.--@@@ -185,7 +184,15 @@ CREATE INDEX IF NOT EXISTS idx_mod_actions_created ON moderation_actions(createdCREATE INDEX IF NOT EXISTS idx_mod_actions_actor ON moderation_actions(actor_user_id);CREATE INDEX IF NOT EXISTS idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);++-- Kept one release for N-1 (ADR-016): the previous release reads it, and its rows say 'chat' for all six, so a++-- rollback still leaves Chat the writer. Nothing in this release reads or writes it; the next release drops it.++CREATE TABLE IF NOT EXISTS table_authority (++ table_name TEXT PRIMARY KEY,++ authority TEXT NOT NULL CHECK(authority IN ('chat', 'live')),++ note TEXT++);++++-- ── 2. The six chat-target tables (Chat is their only writer since C-04) ──CREATE TABLE IF NOT EXISTS channel_moderators (id INTEGER PRIMARY KEY AUTOINCREMENT,@@@ -304,13 +311,6 @@ CREATE TABLE IF NOT EXISTS chat_timeline_events ();CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label);--CREATE TABLE IF NOT EXISTS table_authority (-- table_name TEXT PRIMARY KEY,-- authority TEXT NOT NULL CHECK(authority IN ('chat', 'live')),-- note TEXT--);---- ── 3. Bookkeeping ──────────────────────────────────────────────-- Projections of Live data, maintained by server/live-context.js only. Never authority.@@@ -47,8 +47,8 @@ async function start() {mirror.start();relay.start();events.start();-- // The chat-AI job (Live's server/ai/chat-ai.js, now Chat's): off unless CHAT_AI_ENABLED=1, and-- // its writes skip with one log line while Live is still the two summaries tables' authority.++ // The chat-AI job (Live's server/ai/chat-ai.js, now Chat's): off unless CHAT_AI_ENABLED=1; it++ // writes Chat's own chat_ai_summaries/chat_timeline_events rows directly (C-04 done).chatAi.start();await new Promise((resolve) => server.listen(config.port, config.host, resolve));@@@ -354,10 +354,10 @@ async function setChannelEmoteSources(userId, sources) {return clean;}--// ── Channel policy: moderation settings, moderators, language, alert sounds ──────────────────++// ── Channel policy: language (Live), moderation settings, moderators, alert sounds (Chat) ────--// Everyone who wants to know when a channel's settings were read anew (the chat server announces a--// slow mode or sub-only mode the dashboard changed). Called with every fresh read; the listener++// Everyone who wants to know when a channel's settings changed (the chat server announces a slow++// mode or sub-only mode the dashboard changed). Called with every fresh local read; the listener// compares with what it last saw.const _settingsListeners = new Set();function onChannelSettings(fn) { _settingsListeners.add(fn); return () => _settingsListeners.delete(fn); }@@@ -367,34 +367,23 @@ function _emitSettings(channelId, settings) {}}++// Live's answer carries the channel projection and its language (the part Live still owns). The++// moderation settings and moderator ids are Chat's own rows now, read in place.const _policy = new Swr(TTL.policy, async (channelId) => {const data = await read(`/channels/${channelId}/policy`);if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);-- // While Chat writes the settings itself, Live's copy is a mirror that may lag: not announced.-- if (!_chatWrites('channel_moderation_settings')) _emitSettings(channelId, data.settings || defaultModerationSettings(channelId));-- return {-- settings: data.settings || null,-- moderators: new Set((data.moderator_ids || []).map(Number)),-- language: data.language || 'en',-- };++ // The moderation settings are Chat's own rows: a fresh channel read refreshes the listeners'++ // baseline (the chat server records what the room was last told; the first read announces nothing).++ _emitSettings(channelId, getChannelModerationSettings(channelId));++ return { language: data.language || 'en' };});function _policyFor(channelId) { return channelId ? _policy.peek(Number(channelId)) : undefined; }--// Once Chat writes a staged table (table_authority 'chat', roadmap C-04) its rows here are the truth:--// read in place, like Live's own readers (an indexed row, no network, no cache to go stale).--const _chatWrites = (table) => db.tableAuthority(table) === 'chat';++// Chat is the only writer of these tables (C-04 done): an indexed local row, no network, no cache.function getChannelModerationSettings(channelId) {-- if (_chatWrites('channel_moderation_settings')) {-- return (channelId && db.get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)])) || defaultModerationSettings(channelId);-- }-- const p = _policyFor(channelId);-- return (p && p.settings) || defaultModerationSettings(channelId);++ return (channelId && db.get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)])) || defaultModerationSettings(channelId);}function isChannelModerator(userId, channelId) {-- if (_chatWrites('channel_moderators')) {-- return !!(userId && channelId && db.get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [Number(userId), Number(channelId)]));-- }-- const p = _policyFor(channelId);-- return !!(p && p.moderators.has(Number(userId)));++ return !!(userId && channelId && db.get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [Number(userId), Number(channelId)]));}function channelLanguage(channelUserId) {const ch = getChannelByUserId(channelUserId);@@@ -413,7 +402,7 @@ function ensurePolicy(channelId) { return channelId ? _policy.ensure(Number(chanfunction invalidateChannel(channelId) {if (!channelId) return;_policy.invalidate(Number(channelId));-- if (_chatWrites('channel_moderation_settings')) _emitSettings(channelId, getChannelModerationSettings(channelId));++ _emitSettings(channelId, getChannelModerationSettings(channelId));}/*** A fresh policy read that starts after the call (Chat just had Live write a setting): a load already@@@ -742,8 +731,7 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {if (ownerId && (!channel || channel.id !== ownerId)) tasks.push(ensurePolicy(ownerId));if (user && user.id) { tasks.push(_follows.ensure(Number(user.id))); tasks.push(ensureDecor([user.id])); if (ip) tasks.push(ensureAnonFirstSeen(ip)); }await Promise.all(tasks.map((t) => Promise.resolve(t).catch(() => {})));-- const policy = channel ? _policyFor(channel.id) : null;-- if (policy && policy.settings && policy.settings.ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});++ if (channel && getChannelModerationSettings(channel.id).ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});// Sub-only room: the viewer's subscription answer, so their first line does not wait for it.if (channel && user && user.id && ownerId && Number(ownerId) !== Number(user.id) && getChannelModerationSettings(channel.id).sub_only) {await ensureSubscriber(user.id, ownerId).catch(() => {});@@@ -897,26 +885,19 @@ const effects = {setUserColor: (userId, color) => effect('user-color', { user_id: userId, color }).then((r) => { db.run('UPDATE ctx_users SET profile_color = ? WHERE id = ?', [color, userId]); return r; }),// The ban is in Chat's cache before the moderator is answered (and before their next check).ban: (body) => effect('ban', body).then((r) => invalidateBans().then(() => r)),-- // /slow and alert sounds: written by Live while it owns channel_moderation_settings, here once Chat-- // does (C-04; the callers already checked the moderator / the channel owner, as Live re-checks).-- // The saved value is what chat enforces: at 'live' the policy is read again (awaited) before the-- // caller answers, so the next line already follows it. Chat sets slow_mode_seconds and sub_only.++ // /slow and alert sounds: Chat writes channel_moderation_settings itself (C-04 done; the callers++ // already checked the moderator / the channel owner). Chat sets slow_mode_seconds and sub_only.updateChannelModerationSettings: (channelId, fields, actorUserId) => {const f = {};if (fields && fields.slow_mode_seconds !== undefined) f.slow_mode_seconds = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);if (fields && fields.sub_only !== undefined) f.sub_only = fields.sub_only ? 1 : 0;if (!channelId || !Object.keys(f).length) return Promise.reject(new LiveError(400, 'no chat-settable fields'));-- if (!_chatWrites('channel_moderation_settings')) {-- return effect('channel-settings', { channel_id: channelId, fields: f, actor_user_id: actorUserId })-- .then((r) => reloadPolicy(channelId).catch(() => {}).then(() => r));-- }return Promise.resolve().then(() => {db.upsertChannelModerationSettings(Number(channelId), f);return { ok: true };});},setChannelAlertSound: (channelId, kind, url, mime, actorUserId) => {-- if (!_chatWrites('channel_moderation_settings')) return effect('alert-sound', { channel_id: channelId, kind, url, mime, actor_user_id: actorUserId }).then((r) => { invalidateChannel(channelId); return r; });return Promise.resolve().then(() => {const file = url ? require('path').resolve(String(url)) : null;if (file) {@@@ -4,9 +4,9 @@* - network.account.export_requested sends Chat's part to Network: their messages, DMs they sent, conversations,* blocks, and moderation taken on them without who acted.* - network.account.deleted erases what the subject and its merged-in aliases wrote or set up: chat, room and DM-- * messages, DM memberships (a conversation left empty goes), blocks both ways, the channel's first-chat stats, and-- * the Live mirrors (the user mirror as a tombstone). Moderation history stays. A staged table is left to Live-- * while Live writes it.++ * messages, DM memberships (a conversation left empty goes), blocks both ways, the channel's first-chat stats, their++ * tags, and the Live mirrors (the user mirror as a tombstone). Moderation history stays. The six chat tables are++ * Chat's (C-04 done) and are erased here.* Chat confirms with counts; a failed confirmation is retried without erasing again, and a redelivery sends nothing* twice. Chat subscribes to both.*/@@@ -67,12 +67,12 @@ t('export, then erase and confirm', async () => {assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ?', pair).map((p) => p.user_id), [otto.id]);assert.strictEqual(q('SELECT * FROM network_blocks').length, 0, 'blocks both ways');assert.strictEqual(q('SELECT * FROM moderation_actions').length, 1, 'moderation history stays');-- assert.strictEqual(q('SELECT * FROM user_tags').length, 1, 'a staged table Live still writes is left to Live');++ assert.strictEqual(q('SELECT * FROM user_tags').length, 0, 'the tags table is Chat’s and goes');assert.strictEqual(d.prepare('SELECT username FROM ctx_users WHERE id = ?').get(dana.id).username, `deleted-${dana.id}`);assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');const conf = sent[1];assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));-- assert.deepStrictEqual([conf.body.erased.messages, conf.body.erased.direct_messages, conf.body.retained.moderation_actions, conf.body.retained.erased_by_live], [2, 1, 1, 1]);++ assert.deepStrictEqual([conf.body.erased.messages, conf.body.erased.direct_messages, conf.body.erased.tags, conf.body.retained.moderation_actions], [2, 1, 1, 1]);assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');assert.strictEqual(sent.length, 2);});@@@ -68,6 +68,7 @@ t('boot', async () => {listenerUser = h.addUser('listener');chatters = ['ann', 'ben', 'cat', 'dan'].map((n) => h.addUser(n));channelId = h.addChannel(streamer.id, { moderators: [mod.id] });++ h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)streamId = h.addStream(streamer.id, channelId);await h.ctx.sync();listener = await joined(listenerUser, '198.51.100.90');@@@ -190,13 +190,11 @@ t('deploy notices: stored as one rolling global row, shown to everyone, folded olate.close();});--t('Live’s own writes to data Chat caches invalidate it at once (invalidate op)', async () => {-- await h.ctx.ensurePolicy(channelId);++t('the invalidate op is accepted for a channel and refuses an unknown kind', async () => {assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), false);-- h.live.policies.get(channelId).moderator_ids = [viewer.id]; // added on Live's dashboard++ h.db.addChannelModerator(channelId, viewer.id, streamer.id); // the moderator row is Chat's own (C-04)const r = await calls([{ op: 'invalidate', args: ['channel', channelId] }, { op: 'invalidate', args: ['nonsense', 1] }]);assert.deepStrictEqual(r.body.results.map((x) => x.ok), [true, false]);-- await h.sleep(150);assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), true);});@@@ -2,7 +2,6 @@/*** Plan T3 step 2 — the chat-AI job and its routes, moved into Chat (decision 5):* - the job's selection and write path over Chat's own messages (stub AI client)-- * - the authority-not-flipped skip (one log line, never forced)* - the extractive fallback when AI does not answer* - each of the six routes' shape, visibility and error codes* - the job's switch (off unless CHAT_AI_ENABLED)@@@ -22,7 +21,6 @@ function msg(o = {}) {o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow()]).lastInsertRowid;}--const asChat = () => { db.setTableAuthority('chat_ai_summaries', 'chat'); db.setTableAuthority('chat_timeline_events', 'chat'); };t('boot', async () => {h = await boot({ env: { CHAT_AI_ENABLED: '1' } });@@@ -36,10 +34,9 @@ t('boot', async () => {channelId = h.addChannel(streamer.id);streamId = h.addStream(streamer.id, channelId);await h.ctx.sync();-- asChat();});--t('the job selects Chat messages and writes the summary through the staged writers', async () => {++t('the job selects Chat messages and writes the summary', async () => {const calls = [];aiClient._setClient({runs: {@@@ -72,18 +69,6 @@ t('the job selects Chat messages and writes the summary through the staged writeassert.ok(events.some((e) => e.label === 'a moment'), 'the timeline event was appended');});--t('a write while Live is still the authority is skipped, never forced', async () => {-- db.setTableAuthority('chat_ai_summaries', 'live');-- db.setTableAuthority('chat_timeline_events', 'live');-- const before = db.getChatAiSummary('global', 0, 'global');-- msg({ user_id: alice.id, username: 'alice', message: 'while Live owns the table' });-- await chatAi._tick(); // logs once, returns; no AI call, no write-- const after = db.getChatAiSummary('global', 0, 'global');-- assert.strictEqual(after.overview, before.overview);-- assert.strictEqual(after.message_count, before.message_count);-- asChat();--});--t('falls back to the extractive summary when AI does not answer', async () => {aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed' } }) } });db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-40 * 60 * 1000)]);@@@ -1,11 +1,11 @@'use strict';/**-- * Plan T3 step 1 — the APIs Chat serves for the six staged tables:++ * Plan T3 step 1 — the APIs Chat serves for the six chat tables:* - Live's internal read API (/internal/moderation/*, capability chat.moderation.read)* - the public emote API (/api/emotes, Live's paths and shapes; bytes on OpenVibe.Media)* - channel moderators & moderation settings (/api/chat/channels/:id/…)* - the bridge alert-sound op (Live says "play the alert", Chat resolves the sound)-- * The authority-not-flipped refusal (503 { ok:false, error:'not yet' }) is checked for every writer.++ * Chat is the only writer of the six tables (C-04 done), so the writes never refuse.*/const assert = require('assert');const fs = require('fs');@@@ -15,7 +15,6 @@ const { boot, suite } = require('./helpers');const t = suite('chat-apis');let h, RO, BRIDGE, streamer, other, admin, mod, stranger, channelId, streamId;--const STAGED_TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes'];const deletedAssets = [];let assets = { upload: null, delete: null };@@@ -23,7 +22,6 @@ function checkContract(ref, body) {const r = validate(ref, body);assert.ok(r.valid, `${ref} invalid: ${JSON.stringify(r.errors)} (${JSON.stringify(body)})`);}--const asChat = (table) => h.db.setTableAuthority(table, 'chat');t('boot', async () => {// 3 emotes per channel, so both the clash check and the cap can be exercised.@@@ -72,7 +70,6 @@ t('internal read API: settings + moderator_ids validate, defaults for a channel});t('internal read API: moderator_ids oldest first; the channels a user moderates validate', async () => {-- asChat('channel_moderators');h.db.addChannelModerator(channelId, mod.id, streamer.id);h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-01-01 00:00:00', mod.id]);h.db.addChannelModerator(channelId, stranger.id, streamer.id);@@@ -111,15 +108,7 @@ async function upload(userId, token, fields = {}, bytes = PNG, type = 'image/pngreturn { status: res.status, body: await res.json().catch(() => null) };}--t('emote API: the authority-not-flipped refusal (503 not yet, nothing written)', async () => {-- const r = await upload(streamer.id, streamer.token, { code: 'refused' });-- assert.strictEqual(r.status, 503, JSON.stringify(r.body));-- assert.deepStrictEqual(r.body, { ok: false, error: 'not yet' });-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM emotes').n, 0);--});--t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap', async () => {-- asChat('emotes');// Public list shapes need no auth.let r = await h.http('GET', '/api/emotes/defaults');assert.strictEqual(r.status, 200);@@@ -195,7 +184,6 @@ t('emote API: not-mine is refused, sources round-trip through the Live-context e// ── Moderators & moderation settings ─────────────────────────t('moderators: owner and admin add/remove, a stranger is refused', async () => {-- asChat('channel_moderators');let r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: stranger.token, body: { username: 'moddy' } });assert.strictEqual(r.status, 403, r.text);r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: streamer.token, body: { username: 'moddy' } });@@@ -223,17 +211,9 @@ t('moderators: list is public to any signed-in reader; moderation settings are massert.ok(mine.body.channels.some((c) => c.id === channelId));});--t('moderation settings: owner writes, a mod cannot change policy keys, authority gate first', async () => {-- // While Live writes the settings, the write refuses.-- h.db.setTableAuthority('channel_moderation_settings', 'live');-- let r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 5 } });-- assert.strictEqual(r.status, 503, r.text);-- assert.deepStrictEqual(r.body, { ok: false, error: 'not yet' });---- asChat('channel_moderation_settings');-- asChat('channel_moderators');++t('moderation settings: owner writes, a mod cannot change policy keys', async () => {h.db.addChannelModerator(channelId, mod.id, streamer.id);-- r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 12, ip_approval_mode: 1 } });++ let r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 12, ip_approval_mode: 1 } });assert.strictEqual(r.status, 200, r.text);assert.strictEqual(r.body.settings.slow_mode_seconds, 12);assert.strictEqual(r.body.settings.ip_approval_mode, 1);@@@ -257,7 +237,6 @@ t('moderation settings: owner writes, a mod cannot change policy keys, authority// ── Alert-sound op ───────────────────────────────────────────t('alert op: Chat resolves the sound from its own row and broadcasts it', async () => {-- asChat('channel_moderation_settings');const snd = path.join(h.tmp, 'sounds', 'donation.mp3');fs.writeFileSync(snd, 'ID3alert');h.db.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg');@@@ -318,6 +318,16 @@ async function boot({ env = {} } = {}) {live.policies.set(id, { settings, moderator_ids: moderators });return id;},++ /** Write the channel's own moderation settings row (the six chat tables are Chat's; C-04). */++ setChannelSettings(channelId, settings) {++ const d = h.db.getDb();++ d.prepare('DELETE FROM channel_moderation_settings WHERE channel_id = ?').run(Number(channelId));++ if (settings) {++ const keys = Object.keys(settings).filter((k) => /^[a-z_]+$/.test(k));++ d.prepare(`INSERT INTO channel_moderation_settings (channel_id, ${keys.join(', ')}) VALUES (?, ${keys.map(() => '?').join(', ')})`).run(Number(channelId), ...keys.map((k) => settings[k]));++ }++ h.ctx.invalidateChannel(Number(channelId));++ },addStream(userId, channelId, { title = 'Live now', is_live = 1, managed = null } = {}) {const id = 500 + live.streams.size;if (managed) live.managed.set(managed.id, { user_id: userId, sort_order: 0, created_at: sqliteNow(), ...managed });@@@ -3,11 +3,9 @@* scripts/import-from-live.js against a fake Live snapshot built from Live's real table* definitions (test/fixtures/live-chat-schema.sql): counts per table, a dry run unless --apply (and* it writes nothing), --apply backs Chat's database up first, ids kept, Network subjects filled in,-- * id headroom, projections seeded, idempotent re-runs, Chat's edits win on chat tables while staged-- * tables at 'live' are made equal to Live's (refreshed, and pruned of rows Live removed) and staged-- * tables at 'chat' are only compared, conflicting rows and transient-table rows held (never-- * dropped), rows Live wrote after the first pass brought over, schema drift refused; and-- * scripts/parity-check.js --tables (row counts and a content hash per staged table).++ * id headroom, projections seeded, idempotent re-runs, Chat's edits win on chat tables (the six++ * tables Chat owns are not imported at all), conflicting rows and transient-table rows held (never++ * dropped), rows Live wrote after the first pass brought over, schema drift refused.*/const assert = require('assert');const fs = require('fs');@@@ -79,7 +77,6 @@ t('dry run reports and writes nothing (the default; --dry-run says it too)', ()assert.strictEqual(r.report.backup, null);}const r = importer([]);-- assert.strictEqual(r.report.tables.channel_moderators.authority, 'live');assert.strictEqual(r.report.tables.chat_messages.inserted, 5);assert.strictEqual(r.report.tables.chat_messages_new.held, 1);const c = new Database(chatDb, { readonly: true });@@@ -97,7 +94,7 @@ t('import copies every table with ids kept, subjects filled, headroom set', () =assert.strictEqual(b.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, 0, 'the backup is the database before the run');b.close();const T = r.report.tables;-- const expect = { chat_messages: 5, dm_conversations: 1, dm_participants: 2, dm_messages: 1, dm_blocks: 1, tts_voice_overrides: 1, channel_sounds: 1, relay_users: 1, hidden_relay_users: 1, pending_ip_messages: 1, stream_first_chats: 1, moderation_actions: 1, channel_moderators: 1, channel_moderation_settings: 1, emotes: 1, user_tags: 1, chat_ai_summaries: 1, chat_timeline_events: 1 };++ const expect = { chat_messages: 5, dm_conversations: 1, dm_participants: 2, dm_messages: 1, dm_blocks: 1, tts_voice_overrides: 1, channel_sounds: 1, relay_users: 1, hidden_relay_users: 1, pending_ip_messages: 1, stream_first_chats: 1, moderation_actions: 1 };for (const [tbl, n] of Object.entries(expect)) {assert.strictEqual(T[tbl].live, n, `${tbl} live`);assert.strictEqual(T[tbl].inserted, n, `${tbl} inserted`);@@@ -138,12 +135,11 @@ t('re-running is idempotent', () => {assert.strictEqual(r.report.held_total, 1, 'holds are not duplicated');});--t('after the cutover: Chat’s edits win, staged tables refresh, conflicts are held, late Live rows come over', () => {++t('after the cutover: Chat’s edits win, conflicts are held, late Live rows come over', () => {const c = new Database(chatDb);c.prepare('UPDATE chat_messages SET is_deleted = 1 WHERE id = 101').run(); // a moderator deleted it in Chatc.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (106, 3, 'Bob', 'written in chat')").run();c.close();-- live.prepare('UPDATE channel_moderation_settings SET slow_mode_seconds = 10 WHERE channel_id = 5').run();live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (106, 2, 'Alice', 'a different 106', '2026-09-03 00:00:00')").run();live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (107, 2, 'Alice', 'written in Live after the first pass', '2026-09-03 00:00:01')").run();const r = importer(['--apply', '--no-backup', '--headroom', '1000']);@@@ -152,12 +148,10 @@ t('after the cutover: Chat’s edits win, staged tables refresh, conflicts are hassert.strictEqual(T.chat_messages.chat_kept, 1);assert.strictEqual(T.chat_messages.held, 1);assert.strictEqual(T.chat_messages.inserted, 1);-- assert.strictEqual(T.channel_moderation_settings.refreshed, 1);const d = new Database(chatDb, { readonly: true });assert.strictEqual(d.prepare('SELECT is_deleted FROM chat_messages WHERE id = 101').get().is_deleted, 1);assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 106').get().message, 'written in chat');assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 107').get().message, 'written in Live after the first pass');-- assert.strictEqual(d.prepare('SELECT slow_mode_seconds FROM channel_moderation_settings WHERE channel_id = 5').get().slow_mode_seconds, 10);const held = d.prepare("SELECT * FROM import_hold WHERE source_table = 'chat_messages'").get();assert.match(held.reason, /different row/);assert.strictEqual(JSON.parse(held.row_json).message, 'a different 106');@@@ -165,62 +159,6 @@ t('after the cutover: Chat’s edits win, staged tables refresh, conflicts are hd.close();});--function parity(args) {-- const r = spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'parity-check.js'), '--tables', '--live-db', liveDb, '--chat-db', chatDb, ...args], {-- cwd: path.join(__dirname, '..'), encoding: 'utf8', env: { ...process.env, NODE_ENV: 'test' },-- });-- return { code: r.status, out: r.stdout, err: r.stderr };--}----t('parity --tables: after an import every staged table has the same rows and hash', () => {-- const r = parity([]);-- assert.strictEqual(r.code, 0, r.out + r.err);-- for (const tbl of ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events']) {-- assert.match(r.out, new RegExp(`^same\\s+${tbl}\\s+\\(live\\) rows 1\\b`, 'm'), tbl);-- }-- assert.match(r.out, /all staged tables are the same/);--});----t('staged tables at live follow Live: a removed moderator goes, a re-added one comes back; parity sees each step', () => {-- live.prepare('DELETE FROM channel_moderators WHERE channel_id = 5 AND user_id = 2').run();-- live.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (5, 3, 1)').run();-- let p = parity(['--table', 'channel_moderators']);-- assert.strictEqual(p.code, 1);-- assert.match(p.out, /DIFFERENT channel_moderators\s+\(live\) rows 1 ≠ 1/);-- assert.match(p.out, /only in Live \(1\): \[2\]/);-- assert.match(p.out, /only in Chat \(1\): \[1\]/);-- const dry = importer(['--tables', 'channel_moderators']);-- assert.strictEqual(dry.code, 0, dry.stderr + dry.stdout);-- assert.deepStrictEqual([dry.report.tables.channel_moderators.pruned, dry.report.tables.channel_moderators.inserted], [1, 1], 'the dry run counts it');-- assert.strictEqual(parity(['--table', 'channel_moderators']).code, 1, 'and changes nothing');-- const r = importer(['--apply', '--no-backup', '--tables', 'channel_moderators']);-- assert.strictEqual(r.code, 0, r.stderr + r.stdout);-- assert.deepStrictEqual([r.report.tables.channel_moderators.pruned, r.report.tables.channel_moderators.inserted], [1, 1]);-- p = parity(['--table', 'channel_moderators']);-- assert.strictEqual(p.code, 0, p.out);-- // The same user removed and added again under a new id: the old row goes first, no collision.-- live.prepare('DELETE FROM channel_moderators WHERE user_id = 3').run();-- live.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (5, 3, 1)').run();-- const again = importer(['--apply', '--no-backup', '--tables', 'channel_moderators']);-- assert.deepStrictEqual([again.report.tables.channel_moderators.pruned, again.report.tables.channel_moderators.inserted, again.report.tables.channel_moderators.held], [1, 1, 0]);-- assert.strictEqual(parity(['--table', 'channel_moderators']).code, 0);--});----t('a staged table at chat is only compared: Chat is its authority', () => {-- const c = new Database(chatDb);-- c.prepare("UPDATE table_authority SET authority = 'chat' WHERE table_name = 'emotes'").run();-- c.prepare("UPDATE emotes SET code = 'pogchat' WHERE code = 'pog'").run();-- c.close();-- live.prepare("INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (1, 'kek', '/e/kek.png', 1)").run();-- const r = importer(['--apply', '--no-backup', '--tables', 'emotes']);-- assert.strictEqual(r.code, 0, r.stderr);-- assert.deepStrictEqual(r.report.tables.emotes, { authority: 'chat', live: 2, identical: 0, differs: 1, live_only: 1, chat_only: 0 });-- const d = new Database(chatDb, { readonly: true });-- assert.deepStrictEqual(d.prepare('SELECT code FROM emotes ORDER BY id').all().map((x) => x.code), ['pogchat'], 'nothing written');-- d.close();-- assert.match(parity(['--table', 'emotes']).out, /DIFFERENT emotes\s+\(chat\)/);--});--t('schema drift is refused before anything is written', () => {live.exec('ALTER TABLE chat_messages ADD COLUMN surprise TEXT');live.prepare("INSERT INTO chat_messages (id, username, message, surprise) VALUES (108, 'x', 'y', 'z')").run();@@@ -17,6 +17,9 @@ t('boot', async () => {alice = h.addUser('alice');mod = h.addUser('moddy');channelId = h.addChannel(streamer.id, { moderators: [mod.id], settings: { max_message_length: 60 } });++ // The settings and moderator rows are Chat's own (C-04 done); seed them here.++ h.setChannelSettings(channelId, { max_message_length: 60 });++ h.db.addChannelModerator(channelId, mod.id, streamer.id);streamId = h.addStream(streamer.id, channelId);h.live.follows.set(alice.id, [streamer.id]);await h.ctx.sync();@@@ -42,7 +45,7 @@ t('no read call to Live per chat message (only the one effect)', async () => {assert.strictEqual(during.filter((p) => p === '/internal/chat-effects/chat-message').length, 3, 'one effect per message');});--t('channel rules come from the warm policy: message length', async () => {++t('channel rules come from the local settings row: message length', async () => {aliceWs.sendJson({ type: 'chat', message: 'x'.repeat(61) });await aliceWs.next((m) => m.type === 'system' && m.message === 'Message too long. Max 60 characters.');});@@@ -67,9 +70,8 @@ t('invalidation never leaves a gap: a moderator stays a moderator while the poliassert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);await h.sleep(100);assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);-- // A moderator removed in Live loses the power after the reload.-- h.live.policies.get(channelId).moderator_ids = [];-- h.ctx.invalidateChannel(channelId);++ // A moderator removed from Chat's own row loses the power.++ h.db.removeChannelModerator(channelId, mod.id);await h.sleep(150);assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), false);});@@@ -116,8 +118,7 @@ t('a ban written through Live is in Chat’s cache when the effect answers, even});t('followers-only uses the viewer’s warm follow list', async () => {-- h.live.policies.get(channelId).settings = { followers_only: 1 };-- h.ctx.invalidateChannel(channelId);++ h.setChannelSettings(channelId, { followers_only: 1 });await h.sleep(150);const stranger = h.addUser('stranger');await h.ctx.sync();@@@ -62,6 +62,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sybob = h.addUser('bob', { subject: 'usr_01J9PAR1TY00000000000000B1' });dave = h.addUser('dave');channelId = h.addChannel(streamer.id, { moderators: [mod.id] });++ h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)streamId = h.addStream(streamer.id, channelId);h.live.subscribers.add(`${alice.id}|${streamer.id}`); // A subscribes to the channel; B does notawait h.ctx.sync();@@@ -194,11 +195,9 @@ t('sub-only fails closed: while Live cannot say whether someone subscribes they});t('slow mode is the channel’s saved setting: the dashboard’s value is enforced and announced, and a restart keeps it', async () => {-- const pol = h.live.policies.get(channelId);const a = await p.join('a'); const b = await p.join('b');-- // The dashboard saves 3 s in Live, which tells Chat (the bridge's invalidate: Live chat-remote.js OBSERVED_DB).-- pol.settings = { ...(pol.settings || {}), slow_mode_seconds: 3 };-- await bridge([{ op: 'invalidate', args: ['channel', channelId] }]);++ // The dashboard saves 3 s in Chat's own settings row, which announces the change at once.++ h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 3 });assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, 3, 'the room is told');await p.system(b, 'Slow mode enabled: 3s between messages');const slowed = async (ws, label) => {@@@ -217,8 +216,7 @@ t('slow mode is the channel’s saved setting: the dashboard’s value is enforcassert.strictEqual(again.auth.slowmode_seconds, 3, 'read back after a restart');await slowed(again, 'after a restart');// The dashboard turns it off.-- pol.settings.slow_mode_seconds = 0;-- await bridge([{ op: 'invalidate', args: ['channel', channelId] }]);++ h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 0 });assert.strictEqual((await b.next((m) => m.type === 'slowmode' && m.seconds === 0)).seconds, 0);await p.system(b, 'Slow mode disabled.');await p.closeAll();@@@ -87,8 +87,7 @@ t('/me, /clear and ban notices from global chat or a channel room stay in that r});t('/me and /tts obey followers-only and the slur filter', async () => {-- h.live.policies.get(chanX).settings = { followers_only: 1, slur_filter_enabled: 1, slur_filter_terms: 'bannedword' };-- h.ctx.invalidateChannel(chanX);++ h.setChannelSettings(chanX, { followers_only: 1, slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });const viewer = await joined({ ip: '198.51.100.20', token: streamer.token, stream: streamX }, { streamId: streamX });const bobWs = await joined({ ip: '198.51.100.21', token: bob.token, stream: streamX }, { streamId: streamX });bobWs.sendJson({ type: 'chat', message: 'plain line' });@@@ -100,8 +99,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {assert.ok(await viewer.none((m) => /sneaks past/.test(m.message || ''), 500), '/me or /tts bypassed followers-only');h.live.follows.set(bob.id, [streamer.id]);-- h.live.policies.get(chanX).settings = { slur_filter_enabled: 1, slur_filter_terms: 'bannedword' };-- h.ctx.invalidateChannel(chanX);++ h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });const bob2 = await joined({ ip: '198.51.100.22', token: bob.token, stream: streamX }, { streamId: streamX });bob2.sendJson({ type: 'chat', message: '/me says bannedword' });await bob2.next((m) => m.type === 'slur-blocked');@@@ -109,8 +107,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {bob2.sendJson({ type: 'chat', message: '/tts bannedword' });await bob2.next((m) => m.type === 'slur-blocked');assert.ok(await viewer.none((m) => /bannedword/.test(m.message || ''), 300), 'slur filter bypassed by /me or /tts');-- h.live.policies.get(chanX).settings = null;-- h.ctx.invalidateChannel(chanX);++ h.setChannelSettings(chanX, null);for (const w of [viewer, bobWs, bob2]) w.close();});@@@ -127,8 +124,7 @@ t('joining the channel room without the stream id does not skip slow mode or IPassert.ok(await own.none((m) => m.message === 'second within slow mode'), 'slow mode bypassed from the channel room');// (/slow 30 is the channel's saved setting now; the policy set below replaces it)-- h.live.policies.get(chanX).settings = { ip_approval_mode: 1 };-- h.ctx.invalidateChannel(chanX);++ h.setChannelSettings(chanX, { ip_approval_mode: 1 });const fresh = await joined({ ip: '198.51.100.32' }, { channelUserId: streamer.id });fresh.sendJson({ type: 'chat', message: 'unapproved hello' });await fresh.next((m) => m.type === 'system' && /IP approval mode/.test(m.message));@@@ -137,14 +133,12 @@ t('joining the channel room without the stream id does not skip slow mode or IPawait h.sleep(1100);fresh.sendJson({ type: 'chat', message: '/me unapproved action' });assert.ok(await own.none((m) => /unapproved action/.test(m.message || ''), 400), '/me bypassed IP approval');-- h.live.policies.get(chanX).settings = null;-- h.ctx.invalidateChannel(chanX);++ h.setChannelSettings(chanX, null);for (const w of [own, sneaky, fresh]) w.close();});t('a catastrophic custom slur regex cannot freeze the chat server', async () => {-- h.live.policies.get(chanX).settings = { slur_filter_enabled: 1, slur_filter_use_builtin: 0, slur_filter_regexes: '^(a|a)*$' };-- h.ctx.invalidateChannel(chanX);++ h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_use_builtin: 0, slur_filter_regexes: '^(a|a)*$' });const viewer = await joined({ ip: '198.51.100.40', token: streamer.token, stream: streamX }, { streamId: streamX });const bobWs = await joined({ ip: '198.51.100.41', token: bob.token, stream: streamX }, { streamId: streamX });const line = 'a'.repeat(30) + 'b';@@@ -152,8 +146,7 @@ t('a catastrophic custom slur regex cannot freeze the chat server', async () =>bobWs.sendJson({ type: 'chat', message: line });await viewer.next((m) => m.type === 'chat' && m.message === line, 20000);assert.ok(Date.now() - started < 1500, `chat blocked for ${Date.now() - started}ms by a streamer regex`);-- h.live.policies.get(chanX).settings = null;-- h.ctx.invalidateChannel(chanX);++ h.setChannelSettings(chanX, null);for (const w of [viewer, bobWs]) w.close();});@@@ -1,7 +1,7 @@'use strict';/**-- * /api/sounds (upload → MP3, list, serve, !command playback in chat, delete rules, alert sounds-- * written to Live's channel settings) and /api/tts admin settings (admins; credentials owner-only;++ * /api/sounds (upload → MP3, list, serve, !command playback in chat, delete rules, alert sounds on++ * Chat's own channel settings row) and /api/tts admin settings (admins; credentials owner-only;* written through Live).*/const assert = require('assert');@@@ -88,14 +88,13 @@ t('delete: not someone else’s sound; the uploader can; Live drops the Media coassert.strictEqual(h.db.getChannelSoundById(soundId), undefined);});--t('alert sounds: the streamer’s own channel, stored through Live', async () => {++t('alert sounds: the streamer’s own channel, stored on Chat’s own settings row (C-04)', async () => {const r = await upload('/api/sounds/alert/donation', streamer.token, {}, wav());assert.strictEqual(r.status, 200, JSON.stringify(r.body));assert.strictEqual(r.body.kind, 'donation');-- const fx = h.live.effects.find((e) => e.name === 'alert-sound');-- assert.strictEqual(fx.body.channel_id, channelId);-- assert.strictEqual(fx.body.actor_user_id, streamer.id);-- assert.ok(fx.body.url.startsWith(path.resolve(process.env.SOUNDS_PATH)));++ const row = h.db.get('SELECT donation_sound_url, donation_sound_mime FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);++ assert.ok(row.donation_sound_url.startsWith(path.resolve(process.env.SOUNDS_PATH)));++ assert.strictEqual(row.donation_sound_mime, 'audio/mpeg');});t('TTS admin settings: admins only; credentials only the owner; written by Live', async () => {ddeleted file mode 100644@@@ -1,239 +0,0 @@--'use strict';--/**-- * The staged tables (roadmap C-04, docs/staged-tables-cutover.md): table_authority per table, kept-- * across restarts; at 'live' the staged writes refuse and Live's captured changes keep this copy-- * current (never mirrored back); the handoff over the bridge (to 'chat' needs the mirror, back to-- * 'live' waits until the mirror has sent the table's changes); at 'chat' every write Live's writers-- * make — moderators, settings and alert sounds, emote CRUD with the Media fields, user tags, AI-- * summaries and timeline events — answers Live's own return value plus the rows, once per key, and-- * reaches Live through the mirror; Chat's chat server then reads the moderation tables in place and-- * writes /slow itself; the dual-read slice; scripts/table-authority.js.-- */--const assert = require('assert');--const path = require('path');--const fs = require('fs');--const { spawnSync } = require('child_process');--const { boot, suite } = require('./helpers');----const t = suite('staged-tables');--let h, BRIDGE, streamer, mod, viewer, channelId, streamId;--const STAGED = ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events'];--let seq = 0;----const calls = (ops) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'live-boot-staged', ops: ops.map((o) => ({ seq: ++seq, ...o })) } });--/** One op, its result (throws with Chat's error when it was refused). */--async function op(name, args, { key } = {}) {-- const r = await calls([{ op: name, args, key }]);-- assert.strictEqual(r.status, 200, r.text);-- const res = r.body.results[0];-- if (!res.ok) throw Object.assign(new Error(res.error), { refused: true });-- return res.result;--}--const dbOp = (fn, args, opts) => op('db', [fn, ...args], opts);--const count = (table, where = '1', params = []) => h.db.get(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`, params).n;--const stagedPending = () => h.db.get(`SELECT COUNT(*) AS n FROM live_mirror_outbox WHERE tbl IN (${STAGED.map(() => '?').join(',')})`, STAGED).n;----t('boot with the Live mirror on', async () => {-- h = await boot({ env: { LIVE_MIRROR: '1', LIVE_MIRROR_INTERVAL_MS: '3600000' } });-- BRIDGE = h.serviceToken(['chat.live_bridge.write']);-- streamer = h.addUser('streamer', { role: 'streamer' });-- mod = h.addUser('moddy');-- viewer = h.addUser('viewer');-- channelId = h.addChannel(streamer.id, { settings: { channel_id: 0, slow_mode_seconds: 99 } });-- streamId = h.addStream(streamer.id, channelId);-- await h.ctx.sync();--});----t('every staged table starts at live; a restart keeps the authority it was handed', async () => {-- assert.deepStrictEqual(await op('tableAuthority', []), Object.fromEntries(STAGED.map((s) => [s, 'live'])));-- h.db.setTableAuthority('emotes', 'chat');-- h.db.initDb();-- assert.strictEqual(h.db.tableAuthority('emotes'), 'chat', 'initDb never moves a table back');-- h.db.setTableAuthority('emotes', 'live');-- assert.strictEqual(h.db.tableAuthority('chat_messages'), 'chat', 'Chat’s own tables stay Chat’s');-- assert.throws(() => h.db.setTableAuthority('chat_messages', 'live'), /not a staged table/);--});----t('at live: every staged write refuses, one writer at a time', async () => {-- const tries = [-- ['addChannelModerator', [channelId, mod.id, streamer.id]],-- ['upsertChannelModerationSettings', [channelId, { slow_mode_seconds: 1 }]],-- ['createEmote', [{ user_id: streamer.id, code: 'nope', url: '/e/nope.png' }]],-- ['grantUserTag', [viewer.id, 'og']],-- ['upsertChatAiSummary', [{ scope: 'global', subject_id: 0, window: 'rolling', overview: 'x' }]],-- ['addChatTimelineEvents', ['global', 0, [{ ts: '2026-09-25 10:00:00', label: 'x' }]]],-- ];-- for (const [fn, args] of tries) await assert.rejects(dbOp(fn, args), /written by Live \(table_authority live\)/, fn);-- for (const s of STAGED) assert.strictEqual(count(s), 0, `${s} untouched`);--});----t('at live: Live’s changes keep this copy current, idempotently, and never go back to Live', async () => {-- const changes = [-- { table: 'channel_moderators', op: 'upsert', row: { id: 7, channel_id: channelId, user_id: mod.id, added_by: streamer.id, created_at: '2026-09-25 10:00:00' } },-- { table: 'channel_moderation_settings', op: 'upsert', row: { channel_id: channelId, slow_mode_seconds: 4, emote_scale: 120 } },-- { table: 'emotes', op: 'upsert', row: { id: 3, user_id: streamer.id, code: 'pog', url: '/e/pog.png', channel_owner_id: streamer.id, media_url: null } },-- { table: 'user_tags', op: 'upsert', row: { id: 5, user_id: viewer.id, tag_id: 'legacy', source: 'migration' } },-- { table: 'chat_ai_summaries', op: 'upsert', row: { id: 9, scope: 'global', subject_id: 0, window: 'rolling', overview: 'busy' } },-- { table: 'chat_timeline_events', op: 'upsert', row: { id: 11, scope: 'global', subject_id: 0, ts: '2026-09-25 10:00:00', label: 'raid' } },-- { table: 'chat_messages', op: 'upsert', row: { id: 1, message: 'not staged' } },-- ];-- let r = await op('stagedApply', [changes]);-- assert.strictEqual(r.applied, 6);-- assert.deepStrictEqual(r.skipped.map((s) => s.reason), ['not a staged table']);-- r = await op('stagedApply', [changes.slice(0, 6)]);-- assert.strictEqual(r.applied, 6, 'again: the same rows');-- for (const s of STAGED) assert.strictEqual(count(s), 1, s);-- assert.strictEqual(h.db.get('SELECT emote_scale FROM channel_moderation_settings WHERE channel_id = ?', [channelId]).emote_scale, 120);-- // Removed in Live and added again under a new id: the new row replaces the old one.-- r = await op('stagedApply', [[{ table: 'channel_moderators', op: 'upsert', row: { id: 8, channel_id: channelId, user_id: mod.id, added_by: streamer.id } }]]);-- assert.deepStrictEqual(h.db.all('SELECT id FROM channel_moderators').map((x) => x.id), [8]);-- r = await op('stagedApply', [[{ table: 'user_tags', op: 'delete', pk: { id: 5 } }]]);-- assert.strictEqual(count('user_tags'), 0);-- assert.strictEqual(stagedPending(), 0, 'Live’s own rows are never mirrored back');-- // Chat still reads the moderation tables through Live while Live writes them.-- await h.ctx.ensurePolicy(channelId);-- assert.strictEqual(h.ctx.getChannelModerationSettings(channelId).slow_mode_seconds, 99);-- assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), false);--});----t('the dual-read slice: count and hash over the columns asked for, rows when few', async () => {-- const s = await op('stagedSlice', ['channel_moderators', { channel_id: channelId }, ['id', 'channel_id', 'user_id', 'added_by', 'created_at', 'not_a_column']]);-- assert.strictEqual(s.count, 1);-- assert.deepStrictEqual(s.columns, ['added_by', 'channel_id', 'created_at', 'id', 'user_id']);-- assert.strictEqual(s.hash, h.db.sliceHash(s.columns, s.rows));-- assert.strictEqual((await op('stagedSlice', ['emotes', { channel_owner_id: null }])).count, 0, 'null-safe equality');-- await assert.rejects(op('stagedSlice', ['emotes', { 'code; DROP TABLE emotes': 1 }]), /unknown column/);-- await assert.rejects(op('stagedSlice', ['chat_messages', {}]), /not a staged table/);--});----t('the handoff: to chat needs the Live mirror', async () => {-- const { handOver } = require('../server/bridge/live-bridge');-- await assert.rejects(handOver('emotes', 'chat', { mirror: h.mirrorRelay, config: { live: { mirror: false } } }), /LIVE_MIRROR is off/);-- assert.strictEqual(h.db.tableAuthority('emotes'), 'live');-- await assert.rejects(op('setTableAuthority', ['chat_messages', 'live']), /not a staged table/);-- for (const s of STAGED) assert.deepStrictEqual(await op('setTableAuthority', [s, 'chat']), { table: s, authority: 'chat', mirror_pending: 0 });-- assert.strictEqual((await h.http('GET', '/ready')).body.table_authority.emotes, 'chat');--});----let emoteId;--t('at chat: moderators and settings — Live’s return values, the rows, once per key', async () => {-- let r = await dbOp('addChannelModerator', [channelId, viewer.id, streamer.id], { key: 'live:k1' });-- assert.strictEqual(r.value.changes, 1);-- assert.deepStrictEqual(r.mirror.map((c) => [c.table, c.op, c.row.user_id]), [['channel_moderators', 'upsert', viewer.id]]);-- r = await dbOp('addChannelModerator', [channelId, viewer.id, streamer.id], { key: 'live:k1' });-- assert.strictEqual(count('channel_moderators', 'user_id = ?', [viewer.id]), 1, 'a retried key applies once');-- r = await dbOp('removeChannelModerator', [channelId, viewer.id]);-- assert.strictEqual(r.value.changes, 1);-- assert.strictEqual(r.mirror[0].op, 'delete');-- assert.strictEqual(count('channel_moderators', 'user_id = ?', [viewer.id]), 0);-- r = await dbOp('upsertChannelModerationSettings', [channelId, { slow_mode_seconds: 7, emote_scale: 999, tts_max_length: 5, followers_only: true }]);-- assert.deepStrictEqual([r.value.slow_mode_seconds, r.value.emote_scale, r.value.tts_max_length, r.value.followers_only], [7, 300, 10, 1], 'Live’s clamps');-- r = await dbOp('upsertChannelModerationSettings', [channelId + 1000, { slow_mode_seconds: 2 }]);-- assert.deepStrictEqual([r.value.channel_id, r.value.slow_mode_seconds, r.value.allow_anonymous, r.value.max_message_length], [channelId + 1000, 2, 1, 500], 'a new row with Live’s defaults');-- r = await dbOp('setChannelAlertSound', [channelId, 'goal', '/sounds/goal.mp3', 'audio/mpeg']);-- assert.strictEqual(r.mirror[0].row.goal_sound_url, '/sounds/goal.mp3');-- assert.strictEqual(r.mirror[0].row.slow_mode_seconds, 7, 'the whole row travels');--});----t('at chat: emote CRUD with the Media fields, user tags, AI summaries and timeline', async () => {-- let r = await dbOp('createEmote', [{ user_id: streamer.id, code: 'hype', url: '/data/emotes/hype.png', animated: true, channel_owner_id: streamer.id, size: 5 }]);-- emoteId = r.value.lastInsertRowid;-- assert.ok(emoteId > 3);-- assert.deepStrictEqual([r.mirror[0].row.code, r.mirror[0].row.animated, r.mirror[0].row.size], ['hype', 1, 25]);-- await assert.rejects(dbOp('createEmote', [{ user_id: streamer.id, code: 'hype', url: '/x.png', channel_owner_id: streamer.id }]), /UNIQUE/, 'one code per channel');-- r = await dbOp('updateEmote', [emoteId, { code: 'hype2', size: 1000 }]);-- assert.deepStrictEqual([r.value.changes, r.mirror[0].row.code, r.mirror[0].row.size], [1, 'hype2', 400]);-- assert.deepStrictEqual((await dbOp('updateEmote', [emoteId, {}])).value, { changes: 0 });-- r = await dbOp('setEmoteMedia', [emoteId, 'https://openvibe.media/a/77', 77]);-- assert.deepStrictEqual([r.mirror[0].row.media_url, r.mirror[0].row.media_asset_id], ['https://openvibe.media/a/77', 77]);-- r = await dbOp('deleteEmote', [3]);-- assert.deepStrictEqual(r.mirror, [{ table: 'emotes', op: 'delete', pk: { id: 3 } }]);-- r = await dbOp('grantUserTag', [viewer.id, 'founder', 'grant']);-- assert.strictEqual(r.mirror[0].row.source, 'grant');-- await dbOp('grantUserTag', [viewer.id, 'founder']);-- assert.strictEqual(count('user_tags'), 1, 'a tag is owned once');-- r = await dbOp('revokeUserTag', [viewer.id, 'founder']);-- assert.strictEqual(r.value.changes, 1);-- r = await dbOp('upsertChatAiSummary', [{ scope: 'user', subject_id: viewer.id, window: 'rolling', overview: 'first', last_message_id: 10 }]);-- r = await dbOp('upsertChatAiSummary', [{ scope: 'user', subject_id: viewer.id, window: 'rolling', overview: 'second', last_message_id: 20 }]);-- assert.deepStrictEqual([r.mirror[0].row.overview, r.mirror[0].row.last_message_id], ['second', 20]);-- assert.strictEqual(count('chat_ai_summaries', "scope = 'user'"), 1);-- r = await dbOp('addChatTimelineEvents', ['global', 0, [{ ts: '2026-09-25 11:00:00', label: 'clip' }, { ts: '2026-09-25 11:00:00', label: 'clip' }, { label: 'no ts' }]]);-- assert.strictEqual(r.value, 2, 'Live counts the duplicate it tried');-- assert.strictEqual(r.mirror.length, 1);-- // Live's changes no longer apply to a table Chat writes.-- const refused = await op('stagedApply', [[{ table: 'emotes', op: 'delete', pk: { id: emoteId } }]]);-- assert.deepStrictEqual([refused.applied, refused.skipped[0].reason], [0, 'Chat writes this table (table_authority chat)']);-- assert.strictEqual(count('emotes', 'id = ?', [emoteId]), 1);--});----t('at chat: every change reaches Live through the mirror, newest state per row', async () => {-- assert.ok(stagedPending() > 0);-- await h.mirrorRelay.flush();-- assert.strictEqual(stagedPending(), 0);-- const got = (table) => h.live.mirror.filter((c) => c.table === table);-- assert.deepStrictEqual(got('emotes').find((c) => c.row && c.row.id === emoteId).row.code, 'hype2');-- assert.ok(got('emotes').some((c) => c.op === 'delete' && c.pk.id === 3));-- assert.ok(got('channel_moderators').some((c) => c.op === 'delete'));-- assert.strictEqual(got('channel_moderation_settings').find((c) => c.row.channel_id === channelId).row.goal_sound_url, '/sounds/goal.mp3');-- for (const s of ['user_tags', 'chat_ai_summaries', 'chat_timeline_events']) assert.ok(got(s).length >= 1, s);--});----t('at chat: Chat’s chat server reads the moderation tables in place and persists /slow itself', async () => {-- await dbOp('addChannelModerator', [channelId, mod.id, streamer.id]);-- assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true, 'Live’s policy lists no moderators; the local row counts');-- assert.strictEqual(h.ctx.getChannelModerationSettings(channelId).slow_mode_seconds, 7, 'the local row, not Live’s 99');-- assert.strictEqual(h.ctx.getChannelModerationSettings(424242).allow_anonymous, 1, 'defaults when there is no row');-- const effectsBefore = h.live.effects.length;-- const ws = await h.ws({ ip: '198.51.100.60', token: mod.token, stream: streamId });-- ws.sendJson({ type: 'join', streamId, token: mod.token });-- await ws.next((m) => m.type === 'auth');-- ws.sendJson({ type: 'chat', message: '/slow 12' });-- assert.strictEqual((await ws.next((m) => m.type === 'slowmode')).seconds, 12, 'a moderator by the local table');-- await h.sleep(100);-- assert.strictEqual(h.db.get('SELECT slow_mode_seconds FROM channel_moderation_settings WHERE channel_id = ?', [channelId]).slow_mode_seconds, 12);-- assert.ok(!h.live.effects.slice(effectsBefore).some((e) => e.name === 'channel-settings'), 'Live is not asked to write it');-- ws.close();-- // Alert sounds: files in the sounds directory only, written here.-- await assert.rejects(h.ctx.effects.setChannelAlertSound(channelId, 'donation', '/etc/hostname', 'audio/mpeg', streamer.id), /sounds directory/);-- const snd = path.join(h.tmp, 'sounds', 'alert.mp3');-- fs.writeFileSync(snd, 'ID3');-- await h.ctx.effects.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg', streamer.id);-- assert.strictEqual(h.ctx.getChannelAlertSoundsByUser(streamer.id).donation_sound_url, snd);-- assert.ok(!h.live.effects.slice(effectsBefore).some((e) => e.name === 'alert-sound'));--});----t('back to live: waits for the mirror, refused while Live refuses it, then Live writes again', async () => {-- await dbOp('addChannelModerator', [channelId, viewer.id, streamer.id]);-- assert.ok(h.db.mirrorPending('channel_moderators') > 0);-- h.live.mirrorStatus = 409;-- await assert.rejects(op('setTableAuthority', ['channel_moderators', 'live']), /not in Live yet/);-- assert.strictEqual(h.db.tableAuthority('channel_moderators'), 'chat', 'nothing moved');-- h.live.mirrorStatus = 200;-- assert.deepStrictEqual(await op('setTableAuthority', ['channel_moderators', 'live']), { table: 'channel_moderators', authority: 'live', mirror_pending: 0 });-- assert.ok(h.live.mirror.some((c) => c.table === 'channel_moderators' && c.row && c.row.user_id === viewer.id), 'Chat’s newest row reached Live first');-- await assert.rejects(dbOp('addChannelModerator', [channelId, streamer.id, streamer.id]), /written by Live/);-- assert.strictEqual((await op('stagedApply', [[{ table: 'channel_moderators', op: 'delete', pk: { id: 8 } }]])).applied, 1, 'Live’s changes apply again');-- assert.strictEqual(stagedPending(), 0, 'and are not mirrored back');--});----t('scripts/table-authority.js: status; set only with --force; never back to live with changes queued', async () => {-- const run = (...args) => spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'table-authority.js'), ...args], { encoding: 'utf8', env: { ...process.env } });-- let r = run();-- assert.strictEqual(r.status, 0, r.stderr);-- const st = JSON.parse(r.stdout).tables;-- assert.deepStrictEqual([st.channel_moderators.authority, st.emotes.authority], ['live', 'chat']);-- assert.strictEqual(run('set', 'emotes', 'live').status, 2, 'needs --force');-- await dbOp('createEmote', [{ user_id: viewer.id, code: 'late', url: '/e/late.png' }]);-- r = run('set', 'emotes', 'live', '--force');-- assert.strictEqual(r.status, 2);-- assert.match(r.stderr, /have not reached Live yet/);-- await h.mirrorRelay.flush();-- r = run('set', 'emotes', 'live', '--force');-- assert.strictEqual(r.status, 0, r.stderr);-- assert.deepStrictEqual(JSON.parse(r.stdout), { table: 'emotes', before: 'chat', after: 'live' });-- await h.sleep(1100); // the service's authority cache-- await assert.rejects(dbOp('createEmote', [{ user_id: viewer.id, code: 'later', url: '/e/later.png' }]), /written by Live/);--});----t.run(async () => { if (h) await h.close(); });@@@ -22,6 +22,7 @@ t('boot Chat against a stub Live', async () => {bob = h.addUser('bob');admin = h.addUser('boss', { role: 'admin' });channelId = h.addChannel(streamer.id, { moderators: [mod.id] });++ h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)streamId = h.addStream(streamer.id, channelId, { managed: { id: 71, slug: 'main-slot', title: 'Main slot' } });await h.ctx.sync();assert.ok(h.ctx.getStreamById(streamId), 'stream projected');@@@ -216,15 +217,15 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asyawait h.sleep(1100);modWs.sendJson({ type: 'chat', message: '/clear' });await aliceWs.next((m) => m.type === 'clear');-- // /slow: slowmode event + system line to the room, persisted through Live. It applies to-- // viewers; the room's moderators keep only the 1 s flood limit (the check runs before commands,-- // so a slowed moderator could not even turn it off).++ // /slow: slowmode event + system line to the room, persisted in Chat's own settings row (C-04).++ // It applies to viewers; the room's moderators keep only the 1 s flood limit (the check runs++ // before commands, so a slowed moderator could not even turn it off).await h.sleep(1100);modWs.sendJson({ type: 'chat', message: '/slow 5' });assert.strictEqual((await aliceWs.next((m) => m.type === 'slowmode')).seconds, 5);await aliceWs.next((m) => m.type === 'system' && m.message === 'Slow mode enabled: 5s between messages');await h.sleep(100);-- assert.ok(h.live.effects.some((e) => e.name === 'channel-settings' && e.body.fields.slow_mode_seconds === 5 && e.body.actor_user_id === mod.id));++ assert.strictEqual(h.ctx.getChannelModerationSettings(channelId).slow_mode_seconds, 5, 'saved on Chat’s own row');assert.strictEqual(h.chatServer.slowModeByStream.get(streamId), 5000);// Every action is logged (and announced as an internal moderation event).const actions = h.db.all('SELECT action_type FROM moderation_actions ORDER BY id').map((r) => r.action_type);
@@ -56,9 +56,10 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -56,9 +56,10 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
- **Import:** `scripts/import-from-live.js --live-db <snapshot> [--apply]` — a dry run unless
- **Import:** `scripts/import-from-live.js --live-db <snapshot> [--apply]` — a dry run unless
`--apply` (which backs Chat's database up first), idempotent, never drops a row of Chat's tables
`--apply` (which backs Chat's database up first), idempotent, never drops a row of Chat's tables
(`import_hold`), reports counts per table.
(`import_hold`), reports counts per table.
-- **Staged tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,
- `chat_ai_summaries`, `chat_timeline_events`): Live writes them until each is handed to Chat, one
- table at a time (`table_authority`, register C-04). Runbook: `docs/staged-tables-cutover.md`.
+- **The six chat tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,
+ `chat_ai_summaries`, `chat_timeline_events`): Chat's own since the C-04 cutover — Chat is their only
+ writer, there is no authority switch any more. Live's current release's writers still call Chat over
+ the bridge (op `db`) until Live N+1 is deployed.
- **The features on those tables, served by Chat** (plan T3, plan step 1) — built here so Live can
- **The features on those tables, served by Chat** (plan T3, plan step 1) — built here so Live can
stop touching them at the flip:
stop touching them at the flip:
- `GET /api/emotes/global`, `/channel/:userId`, `/mine`, `/defaults`, `/sources` (GET/PUT),
- `GET /api/emotes/global`, `/channel/:userId`, `/mine`, `/defaults`, `/sources` (GET/PUT),
@@ -81,16 +82,13 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -81,16 +82,13 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
- **The chat-AI summaries** (plan T3 step 2, decision 5) — Live's `server/ai/chat-ai.js` job moved
- **The chat-AI summaries** (plan T3 step 2, decision 5) — Live's `server/ai/chat-ai.js` job moved
here: a background poller (off unless `CHAT_AI_ENABLED=1`) that folds Chat's own messages into
here: a background poller (off unless `CHAT_AI_ENABLED=1`) that folds Chat's own messages into
rolling global/per-user/per-relay/per-anon insights and the append-only timeline, writing
rolling global/per-user/per-relay/per-anon insights and the append-only timeline, writing
- `chat_ai_summaries`/`chat_timeline_events` through the staged writers. The model is OpenVibe.AI
+ Chat's own `chat_ai_summaries`/`chat_timeline_events` tables. The model is OpenVibe.AI
called with Chat's own service token (`ai.run.create`/`ai.run.read`, audience `openvibe.ai`,
called with Chat's own service token (`ai.run.create`/`ai.run.read`, audience `openvibe.ai`,
namespace `chat.*`, workflows `chat.global`/`chat.profile`); when AI does not answer the job
namespace `chat.*`, workflows `chat.global`/`chat.profile`); when AI does not answer the job
stores a deterministic extractive summary instead, so the routes always have something to serve.
stores a deterministic extractive summary instead, so the routes always have something to serve.
Routes (Live's paths, parameters, shapes and public visibility, now under `/api/chat/ai/`):
Routes (Live's paths, parameters, shapes and public visibility, now under `/api/chat/ai/`):
`GET /global`, `/timeline` (`?before=&since=&q=&limit=`), `/user/:id`, `/anon/:anonId`,
`GET /global`, `/timeline` (`?before=&since=&q=&limit=`), `/user/:id`, `/anon/:anonId`,
- `/relay/:platform/:username`, `/timeline/:username`. A write while Live still owns the two
- tables is skipped with one log line, never forced.
- - Every write keeps the `table_authority` gate: while Live still writes the table the route
- answers `503 { ok: false, error: 'not yet' }` and never writes.
+ `/relay/:platform/:username`, `/timeline/:username`.
## How it fits the network
## How it fits the network
@@ -100,7 +98,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -100,7 +98,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) |
| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) |
| Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) |
| Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) |
| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |
| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |
-| The six staged tables (moderators, moderation settings, emotes, tags, AI summaries, timeline) | **Chat** (after the T3 flip; Live until then) | Chat's own SQLite; Live reads through `GET /internal/moderation/*` (`chat.moderation.read`), 30 s cached |
+| The six chat tables (moderators, moderation settings, emotes, tags, AI summaries, timeline) | **Chat** (C-04 done) | Chat's own SQLite; Live reads through `GET /internal/moderation/*` (`chat.moderation.read`), 30 s cached |
| Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` |
| Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` |
| Chat-AI summaries (global / per-chatter insight + timeline) | **OpenVibe.AI** (namespace `chat.*`) | `server/ai/client.js` (openvibe-sdk `createAiClient`), Chat's service token (`ai.run.create` / `ai.run.read`); workflows `chat.global` / `chat.profile`; extractive fallback when AI does not answer |
| Chat-AI summaries (global / per-chatter insight + timeline) | **OpenVibe.AI** (namespace `chat.*`) | `server/ai/client.js` (openvibe-sdk `createAiClient`), Chat's service token (`ai.run.create` / `ai.run.read`); workflows `chat.global` / `chat.profile`; extractive fallback when AI does not answer |
| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
@@ -116,10 +114,9 @@ Chat owns (from the cutover): `chat_messages`, `dm_conversations`, `dm_participa
@@ -116,10 +114,9 @@ Chat owns (from the cutover): `chat_messages`, `dm_conversations`, `dm_participa
`subject_id` / `sender_subject_id` / `blocker_subject_id` / `actor_subject_id` /
`subject_id` / `sender_subject_id` / `blocker_subject_id` / `actor_subject_id` /
`created_by_subject_id` (the Network `usr_…` subject, filled on new rows and by the importer).
`created_by_subject_id` (the Network `usr_…` subject, filled on new rows and by the importer).
-Staged here, still written by Live in this wave (their routes have not moved): `channel_moderators`,
-`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events` —
-imported (and refreshed by later import runs) so their move is a switch; Chat reads the live values
-through `live-context`. `table_authority` records which is which.
+Chat owns too (C-04 done; there is no authority switch any more): `channel_moderators`,
+`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`.
+The importer no longer copies them; Live reads them through `GET /internal/moderation/*`.
Stays in Live (decided with evidence, `docs/cutover.md`): `media_requests`, `media_request_settings`.
Stays in Live (decided with evidence, `docs/cutover.md`): `media_requests`, `media_request_settings`.
@@ -357,8 +354,6 @@ npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,s
@@ -357,8 +354,6 @@ npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,s
npm test # Node 22; stub Live and Network in-process
npm test # Node 22; stub Live and Network in-process
node scripts/import-from-live.js --live-db /tmp/live-snapshot.db # dry run; --apply writes
node scripts/import-from-live.js --live-db /tmp/live-snapshot.db # dry run; --apply writes
node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"
node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"
-node scripts/parity-check.js --tables --live-db /tmp/live-snapshot.db --chat-db /tmp/chat-snapshot.db
-node scripts/table-authority.js # who writes each staged table (the handoff is Live's)
node scripts/mirror-flush.js # rollback helper: push queued mirror rows to Live
node scripts/mirror-flush.js # rollback helper: push queued mirror rows to Live
node scripts/subscribe-events.js --dry-run # Chat's Events subscriptions (boot creates missing ones)
node scripts/subscribe-events.js --dry-run # Chat's Events subscriptions (boot creates missing ones)
node scripts/parity.js # chat parity scenarios: a dry run; --apply only on test accounts (docs/parity.md)
node scripts/parity.js # chat parity scenarios: a dry run; --apply only on test accounts (docs/parity.md)
@@ -434,8 +429,8 @@ server/calls/ moved from Live: the call server (/ws/call), its REST
@@ -434,8 +429,8 @@ server/calls/ moved from Live: the call server (/ws/call), its REST
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
-scripts/ import-from-live, parity-check, parity, mirror-flush, table-authority, migrate-chat-preferences, subscribe-events, n-1-record
-docs/ cutover.md, calls-cutover.md, staged-tables-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
+scripts/ import-from-live, parity-check, parity, mirror-flush, migrate-chat-preferences, subscribe-events, n-1-record
+docs/ cutover.md, calls-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
```
```
## Owns
## Owns
@@ -41,13 +41,13 @@ nginx).
@@ -41,13 +41,13 @@ nginx).
| Table (Live baseline, target OpenVibe.Chat) | W6 authority | Notes |
| Table (Live baseline, target OpenVibe.Chat) | W6 authority | Notes |
| --- | --- | --- |
| --- | --- | --- |
| `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` | **Chat** | Imported with their ids. Live keeps a read mirror (Chat → `POST /internal/chat-effects/mirror`); Live's remaining writers (AI viewers, relays, donations, `/api/mod`, `/api/channels` deletes, emote renames) forward to Chat. |
| `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` | **Chat** | Imported with their ids. Live keeps a read mirror (Chat → `POST /internal/chat-effects/mirror`); Live's remaining writers (AI viewers, relays, donations, `/api/mod`, `/api/channels` deletes, emote renames) forward to Chat. |
-| `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events` | **Live** (staged in Chat) until each is handed over | Moved one table at a time by `docs/staged-tables-cutover.md` (register C-04): at `live` Live writes and its changes reach Chat's copy over the bridge; at `chat` Live's writers call Chat and the mirror copies the table back. Chat reads the moderation tables through `live-context` while Live writes them and in place once Chat does. |
+| `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events` | **Chat** (C-04 done) | Chat's own tables; Chat is their only writer. Live reads what it needs through `GET /internal/moderation/*` (`chat.moderation.read`), and Live's current release's writers call Chat over the bridge (op `db`) until Live N+1 is deployed. There is no authority switch any more. |
| `media_requests`, `media_request_settings` | **Live** (not moved) | Decision with evidence: every writer is `server/media/media-queue.js` / `server/media/routes.js` (`/api/media`: gold payment, yt-dlp download, playback state, the streamer's overlay and dashboard). Chat only has the `!sr/!queue/!np/!skip` entry points, which call Live (`POST /internal/chat-effects/media-queue`). Not chat-owned in practice; it moves with the queue lifecycle the charter describes, not before. The importer reports them as `not_moved`. |
| `media_requests`, `media_request_settings` | **Live** (not moved) | Decision with evidence: every writer is `server/media/media-queue.js` / `server/media/routes.js` (`/api/media`: gold payment, yt-dlp download, playback state, the streamer's overlay and dashboard). Chat only has the `!sr/!queue/!np/!skip` entry points, which call Live (`POST /internal/chat-effects/media-queue`). Not chat-owned in practice; it moves with the queue lifecycle the charter describes, not before. The importer reports them as `not_moved`. |
| `chat_messages_new`, `emotes_new`, `channel_sounds_new` | — | Transient tables of Live's table rebuilds; empty in a consistent snapshot. Rows found there go to `import_hold`. |
| `chat_messages_new`, `emotes_new`, `channel_sounds_new` | — | Transient tables of Live's table rebuilds; empty in a consistent snapshot. Rows found there go to `import_hold`. |
Chat also keeps `ctx_users`, `ctx_streams`, `ctx_managed_streams`, `ctx_channels` — projections of
Chat also keeps `ctx_users`, `ctx_streams`, `ctx_managed_streams`, `ctx_channels` — projections of
Live data maintained by `live-context` (never authority), `events_outbox`, `live_mirror_outbox`,
Live data maintained by `live-context` (never authority), `events_outbox`, `live_mirror_outbox`,
-`bridge_applied`, `import_hold`, `import_runs`, `table_authority`.
+`bridge_applied`, `import_hold`, `import_runs`.
## Prerequisites
## Prerequisites
@@ -60,9 +60,8 @@ The gaps the script found on 2026-09-25, with the product decisions taken for th
@@ -60,9 +60,8 @@ The gaps the script found on 2026-09-25, with the product decisions taken for th
- **Sub-only mode.** A channel setting (`channel_moderation_settings.sub_only`, in Live and Chat),
- **Sub-only mode.** A channel setting (`channel_moderation_settings.sub_only`, in Live and Chat),
set by `/subonly` and `/subonly off` (the streamer, channel moderators and chat staff) and by the
set by `/subonly` and `/subonly off` (the streamer, channel moderators and chat staff) and by the
- dashboard's moderation settings, written where that table's authority says (Live's
- `channel-settings` effect while it is at `live`, Chat's `upsertChannelModerationSettings` once Chat
- writes it). When it is on, only people with an **active Live channel subscription** to the streamer
+ dashboard's moderation settings, written by Chat's `upsertChannelModerationSettings` (the table is
+ Chat's own since the C-04 cutover). When it is on, only people with an **active Live channel subscription** to the streamer
(Live's `subscriptions`: status active, the paid period not over; Billing's entitlement under
(Live's `subscriptions`: status active, the paid period not over; Billing's entitlement under
`BILLING_AUTHORITY=billing`), the streamer, channel moderators and chat staff may chat; Network VIP
`BILLING_AUTHORITY=billing`), the streamer, channel moderators and chat staff may chat; Network VIP
does not count and anonymous viewers cannot chat. Chat asks Live `GET
does not count and anonymous viewers cannot chat. Chat asks Live `GET
deleted file mode 100644
deleted file mode 100644
@@ -1,288 +0,0 @@
@@ -1,288 +0,0 @@
-# Staged tables cutover — Live → OpenVibe.Chat (WS-I task 2, register C-04)
-
-Six chat tables are still written by Live since the Wave 6 cutover (`docs/cutover.md`). This moves
-their authority to Chat **one table at a time**, each move reversible:
-
-| Table | Live's writers today | After the flip |
-| --- | --- | --- |
-| `chat_timeline_events` | `server/ai/chat-ai.js` (global timeline, seeding) | Live's chat AI calls Chat's `addChatTimelineEvents` |
-| `chat_ai_summaries` | `server/ai/chat-ai.js` (global, user, relay-user, anon insights) | Live's chat AI calls Chat's `upsertChatAiSummary` |
-| `user_tags` | none (chat tags are read-only in Live since the game moved to OpenVibe.Games) | data only; Chat has `grantUserTag` / `revokeUserTag` |
-| `emotes` | `POST/PATCH/DELETE /api/emotes`, Media asset-sync (`media_url`, `media_asset_id`) | Live's routes and asset-sync call Chat's `createEmote`, `updateEmote`, `deleteEmote`, `setEmoteMedia` |
-| `channel_moderation_settings` | `PUT /api/channels/:id/moderation` (dashboard), Chat's `/slow`, `/subonly` and alert sounds (Live's `/internal/chat-effects/channel-settings` and `alert-sound`) | the dashboard calls Chat's `upsertChannelModerationSettings`; `/slow`, `/subonly` and alert sounds are written by Chat itself |
-| `channel_moderators` | `POST/DELETE /api/channels/:id/mods` | Live's routes call Chat's `addChannelModerator` / `removeChannelModerator` |
-
-`media_requests` is **not** part of this: it is not a chat table. Its writers are Live's media
-queue (`server/media/media-queue.js`, `server/media/routes.js`, `server/tips/delivery-routes.js`:
-gold payment, yt-dlp download, playback state); Chat only has the `!sr/!queue/!np/!skip` entry
-points, which call Live; the plan's own media namespaces list it as `live.media_requests`. The
-decision of `docs/cutover.md` ("Data authority") stands.
-
-- [How it works](#how-it-works)
-- [Calendar](#calendar)
-- [Shell helpers](#shell-helpers)
-- [0. Deploy](#0-deploy)
-- [1. Import](#1-import)
-- [2. Parity](#2-parity)
-- [3. Dual read for one release](#3-dual-read-for-one-release)
-- [4. Flip each table](#4-flip-each-table)
-- [5. Live stops writing](#5-live-stops-writing)
-- [6. Rollback](#6-rollback)
-
-## How it works
-
-Two records say who writes each table, and they move together:
-
-- **Chat:** `table_authority` in `chat.db` (`scripts/table-authority.js`, `GET /ready` →
- `table_authority`). A restart never changes it.
-- **Live:** app_state `chat_table_authority` (JSON; a missing table is `live`), read by
- `server/chat/chat-tables.js`; `GET /internal/chat-tables` shows both sides. Without
- `CHAT_AUTHORITY=chat` Live treats every table as `live`.
-
-**At `live`** (every table after the deploy): Live writes its own table as before. TEMP triggers
-capture every change into `chat_staged_outbox`, and a relay sends the rows as they are now to Chat's
-bridge (`POST /internal/live/calls`, op `stagedApply`, capability `chat.live_bridge.write`) every
-2 s, so Chat's copy stays current. Chat's staged writes refuse (`table.not_chat`). Chat's chat
-server reads moderators and settings through Live (`live-context`), as it has since Wave 6. With a
-table's **dual-read** flag on, Live's reads of it are compared in the background with Chat's copy
-(op `stagedSlice`: row count and a sha256 over the rows) and counted in `chat_dual_read_stats`;
-Live's answers never change.
-
-**The flip** (`POST /internal/chat-tables/:table {"authority":"chat"}` on Live, never automatic):
-the table's writers wait; Live's queued changes reach Chat; Chat takes the table (op
-`setTableAuthority`; refused if `LIVE_MIRROR` is off); Live records `chat` and stops capturing.
-
-**At `chat`:** Live's writers call Chat's write of the same name over the bridge (awaited, with an
-idempotency key); Chat answers Live's own return value plus the rows, which Live applies to its copy
-at once. Live's table is from then on a read mirror: Chat's triggers queue every change of the table
-in `live_mirror_outbox` and the mirror sends it to Live (`/internal/chat-effects/mirror`, as for
-Chat's own tables), which takes staged rows only for a table Chat writes. Live's readers do not
-change. Chat's chat server reads `channel_moderators` and `channel_moderation_settings` in place,
-and writes `/slow`, `/subonly` and alert sounds itself.
-
-**The flip back** (`{"authority":"live"}`): Chat sends Live every queued change of the table and
-refuses to give it back while any is left; then Live records `live` and captures again. Running a
-flip again is safe: it brings both sides back into agreement after a failed attempt.
-
-## Calendar
-
-The plan (WS-I task 2) asks for dual read during one release before the flip. Live's rollback
-window is a week (`test/rollback-newer-writes.test.js`: production may roll back to any release of
-the last seven days), and a Live release from before this change would write a flipped table itself.
-So a table is flipped only when **both** hold: the dual-read counters stayed at 0 mismatches
-through at least one Live release shipped after D0, and every Live release of the last seven days
-has this change (at the earliest D0 + 7 days).
-
-| When (D0 = 2026-09-28) | What |
-| --- | --- |
-| D0, Mon 2026-09-28 | Deploy Chat, then Live. Import (dry run, apply). Parity. Dual read on for all six tables. |
-| D0 … D0 + 7 | Watch the counters daily. At least one more Live release ships in this week (any deploy counts), with dual read still on. |
-| Mon 2026-10-05 | Flip `chat_timeline_events`. |
-| Tue 2026-10-06 | Flip `chat_ai_summaries`. |
-| Wed 2026-10-07 | Flip `user_tags`. |
-| Thu 2026-10-08 | Flip `emotes`. |
-| Fri 2026-10-09 | Flip `channel_moderation_settings`. |
-| Mon 2026-10-12 | Flip `channel_moderators` (last: it decides who may moderate). |
-| until 2026-11-06 | Keep the flip back available. C-01 to C-03 (Live's chat mirror and bridge) have that target date and carry these tables too. |
-| not before 2026-12-18 | Contract step (register C-04 target): remove Live's local write path, the capture and the relay for the staged tables, after 30 days on `chat` without a flip back. |
-
-If the deploy slips, shift every date by the same number of days. If the counters show mismatches,
-the flips wait until a full release passes without any.
-
-## Shell helpers
-
-Live's internal routes answer only on loopback with `X-Internal-Key`. The helper takes the key from
-the running service and passes it to curl on stdin, so it is never printed or put on a command line.
-
-```bash
-live_tables() {
- local key
- key="$(sudo cat /proc/"$(systemctl show -p MainPID --value openvibe-live)"/environ | tr '\0' '\n' | sed -n 's/^INTERNAL_API_KEY=//p')"
- printf 'X-Internal-Key: %s\n' "$key" | curl -sS -H @- -H 'Content-Type: application/json' "$@"
-}
-LT=http://127.0.0.1:3000/internal/chat-tables
-# Chat's side (same database as the service)
-chat_tables() { (cd /opt/openvibe.chat && sudo -u ubuntu env CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db node scripts/table-authority.js "$@"); }
-STAGED=channel_moderators,channel_moderation_settings,emotes,user_tags,chat_ai_summaries,chat_timeline_events
-# The overview used below
-status() { live_tables "$LT" | jq '{relay, chat_error, tables: (.tables | map_values({authority, chat_authority, disagree, dual_read, outbox_pending, dr: (.dual_read_stats | {compared, matched, mismatched, inconclusive, errors, last_mismatch_at})}))}'; }
-```
-
-## 0. Deploy
-
-Chat first (it must know the new bridge ops before Live starts relaying), then Live. Nothing changes
-for users: every table stays at `live`.
-
-```bash
-cd /opt/openvibe.chat && sudo -u ubuntu git pull --ff-only
-sudo systemctl restart openvibe-chat && until curl -sf 127.0.0.1:4400/ready >/dev/null; do sleep 1; done
-curl -s 127.0.0.1:4400/ready | jq '{status, table_authority, mirror}'
-
-cd /opt/openvibe.live/current && sudo deploy/scripts/deploy.sh --wait-idle
-until curl -sf https://openvibe.live/api/ready >/dev/null; do sleep 1; done
-journalctl -u openvibe-live -n 300 | grep ChatTables
-status
-```
-
-Look at:
-
-- Chat `/ready`: `status` `ready`, every `table_authority` value `live`, `mirror.last_error` null.
-- Live's journal: `[ChatTables] staged tables: channel_moderators=live, …` (the capture and relay
- started). No `relay to Chat waiting` lines after the first minute.
-- `status`: every table `authority` and `chat_authority` `live`, no `disagree`, `relay.pending`
- near 0, `relay.last_error` null, `relay.paused` false.
-
-## 1. Import
-
-Chat's copy was last filled at the Wave 6 cutover. The import makes it equal to Live's: refreshed
-rows, missing rows added, rows Live no longer has removed (`pruned`). The relay is held meanwhile
-(Live keeps capturing), so every change made after the pause reaches Chat after the import and the
-import never undoes one.
-
-```bash
-# 1a. hold the relay
-live_tables -X POST -d '{"paused":true}' "$LT/relay" | jq .relay # paused: true
-# 1b. snapshot Live (online, consistent)
-sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-staged-import.db"
-# 1c. dry run (the default: nothing is written)
-cd /opt/openvibe.chat
-sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-staged-import.db \
- --chat-db /var/lib/openvibe-chat/chat.db --tables "$STAGED" --no-projections | tee /tmp/staged-import-dry.json | jq '.tables, .held_total'
-# 1d. apply (copies chat.db to /var/lib/openvibe-chat/chat.db.pre-import-<time> first)
-sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-staged-import.db \
- --chat-db /var/lib/openvibe-chat/chat.db --tables "$STAGED" --no-projections --apply | tee /tmp/staged-import.json | jq '.backup, .tables, .held_total'
-# 1e. release the relay: the changes since 1a go now
-live_tables -X POST -d '{"paused":false}' "$LT/relay" | jq .relay
-sleep 5; status
-```
-
-Look at:
-
-- Dry run and apply report the same numbers. Per table: `authority` `live`; `inserted + identical +
- refreshed` = `live` (the snapshot's rows); `pruned` = rows only Chat had (removed moderators,
- deleted emotes since 2026-09-23); `held` 0. `held_total` unchanged from before the run (anything
- new: `sqlite3 /var/lib/openvibe-chat/chat.db "select source_table, reason from import_hold order by id desc limit 5"`).
-- `backup` names the copy of `chat.db` taken before the write.
-- After 1e: `relay.paused` false, `relay.pending` back near 0, `relay.last_error` null.
-- A second dry run (1c again) reports `inserted` 0 and `pruned` 0 for every table (idempotent).
-
-## 2. Parity
-
-Row counts and a content hash per table, on two snapshots taken back to back:
-
-```bash
-status | jq .relay.pending # 0 (nothing on its way)
-sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-parity.db"
-sudo -u ubuntu sqlite3 /var/lib/openvibe-chat/chat.db ".backup /tmp/chat-parity.db"
-cd /opt/openvibe.chat && sudo -u ubuntu node scripts/parity-check.js --tables --live-db /tmp/live-parity.db --chat-db /tmp/chat-parity.db
-```
-
-Look at: `all staged tables are the same`, one `same` line per table with its row count and
-authority `(live)`. A `DIFFERENT` line lists the keys only in Live, only in Chat, or different. A
-write between the two snapshots shows up this way once: run the three commands again a minute
-later. A key that differs twice is real: look at both rows
-(`sqlite3 … "select * from <table> where id = <key>"`), then repeat step 1 for that table
-(`--tables <table>`) and step 2. Afterwards delete the snapshots (they hold user data):
-`rm -f /tmp/live-staged-import.db /tmp/live-parity.db /tmp/chat-parity.db /tmp/staged-import*.json`.
-
-## 3. Dual read for one release
-
-```bash
-for t in ${STAGED//,/ }; do live_tables -X POST -d '{"dual_read":true}' "$LT/$t" | jq -c '{t: "'$t'", dual_read: .table.dual_read}'; done
-status
-```
-
-Every day until the flips (D0 … D0 + 7, and through at least one more Live release — each release
-restarts Live; the counters are kept in `chat_dual_read_stats` and survive it):
-
-```bash
-status
-journalctl -u openvibe-live --since -24h | grep -E 'ChatTables\] (dual read|relay)'
-ls /opt/openvibe.live/releases # a release after D0 has shipped
-```
-
-Look at:
-
-- `dr.mismatched` stays **0** for every table; `dr.compared` grows for the tables Live reads
- (`channel_moderation_settings`, `channel_moderators`, `emotes`, `chat_ai_summaries`,
- `chat_timeline_events`). `user_tags` has no reader in Live, so it compares nothing: parity (step
- 2) is its check — run step 2 again the day before its flip.
-- `dr.inconclusive` counts comparisons made while a change was on its way to Chat; it is not a
- mismatch. `dr.errors` counts Chat not answering; a few during Chat restarts are expected.
-- `relay.pending` near 0 and `relay.last_error` null at every look.
-- A mismatch: `live_tables "$LT" | jq '.tables.<table>.dual_read_stats.last_mismatch'` gives the
- slice (`where`), both counts and hashes, and the keys (`only_live`, `only_chat`, `differ`). Find
- the cause, repeat steps 1 and 2 for that table, reset its counters
- (`live_tables -X POST -d '{"reset_counters":true}' "$LT/<table>"`) and start the release over.
-
-## 4. Flip each table
-
-One table per day, in the order of the calendar. Before each flip:
-
-```bash
-status # this table: mismatched 0, outbox_pending 0, no disagree
-curl -s 127.0.0.1:4400/ready | jq '{status, mirror}' # mirror.enabled true, pending near 0, last_error null
-C04=d6480da # the Live commit of this change (git -C /opt/openvibe.live/repo log --oneline --grep 'C-04')
-for r in /opt/openvibe.live/releases/*; do git -C /opt/openvibe.live/repo merge-base --is-ancestor "$C04" "${r##*-}" && echo "has it $r" || echo "OLDER $r"; done
-```
-
-Every release of the last seven days must say `has it` (`--rollback` must never land on one that
-writes the table itself). Then flip:
-
-```bash
-T=chat_timeline_events # the table of the day
-live_tables -X POST -d '{"authority":"chat","by":"'"$USER"'"}' "$LT/$T" | jq
-```
-
-Look at:
-
-- The answer: `ok` true, `handoff.authority` `chat`, `table.authority` and `table.chat_authority`
- both `chat`, `table.outbox_pending` 0. `ok` false: nothing moved (`error` says why; Chat not
- reachable, `LIVE_MIRROR is off`, Live's queue not drained). Fix it and run the same command again.
-- `journalctl -u openvibe-live -n 50 | grep "ChatTables\] $T"` → `live → chat`;
- `journalctl -u openvibe-chat -n 50 | grep "$T: Chat writes it now"`.
-- `chat_tables` → `"$T": { "authority": "chat", … }`; `curl -s 127.0.0.1:4400/ready | jq .table_authority`.
-
-## 5. Live stops writing
-
-The flip is the moment Live stops writing the table: its writers call Chat and Live's copy only
-takes Chat's rows. Check it the same day, with a real write through the feature:
-
-| Table | A write to make | Then |
-| --- | --- | --- |
-| `chat_timeline_events`, `chat_ai_summaries` | none: the chat AI writes within 30 minutes while chat is active (`journalctl -u openvibe-live \| grep ChatAI`) | the newest row by `id` / `updated_at` is the same in both databases |
-| `user_tags` | none | step 2 for `user_tags` |
-| `emotes` | upload a test emote on a test channel, rename it, delete it (dashboard → Emotes) | each step shows in Chat's `emotes` with the same `id` as in Live's, then in Live's |
-| `channel_moderation_settings` | change a setting on a test channel in the dashboard; `/slow 5` then `/slow off` as its moderator | Chat's row changes first, Live's copy follows; slow mode works in the stream chat |
-| `channel_moderators` | add and remove a test moderator in the dashboard | the moderator can `/timeout` a test account in that channel's chat, and cannot after removal |
-
-```bash
-sudo -u ubuntu sqlite3 /var/lib/openvibe-chat/chat.db "select * from $T order by rowid desc limit 3"
-sqlite3 /opt/openvibe.live/data/live.db "select * from $T order by rowid desc limit 3"
-status | jq ".tables.$T" # outbox_pending stays 0: Live captures nothing now
-curl -s 127.0.0.1:4400/ready | jq .mirror # pending near 0, last_error null
-journalctl -u openvibe-live --since -1h | grep -E "ChatTables|refused" | grep -v 'staged tables:'
-```
-
-Look at: the same rows (same ids) on both sides; `outbox_pending` 0; no `OpenVibe.Chat refused`
-and no `not applied here` lines; Chat's mirror queue drains. For the emote, settings and moderator
-checks, also run step 2 for the table (the `same` line says `(chat)`). A write that fails while Chat
-is unreachable answers an error to the user and changes nothing in Live: that is the table being
-Chat's. After the last table (2026-10-12), run step 2 for all six.
-
-## 6. Rollback
-
-| Step | Rollback | Look at |
-| --- | --- | --- |
-| 0. Deploy | Nothing to undo while every table is `live`. Before rolling Live (`deploy.sh --rollback`) or Chat back past these changes, every table must be `live` again (row 4). | `status`: no table at `chat` |
-| 1. Import | The staged copy in Chat is read by nothing while its table is `live`, so a bad import has no effect on users. Repeat step 1 (pause, fresh snapshot, `--apply`, resume). **Never copy the `.pre-import-*` backup over `chat.db`**: it would drop the chat messages written since; it is for looking at. If step 1 stopped between 1a and 1e, release the relay: `live_tables -X POST -d '{"paused":false}' "$LT/relay"`. | `status`: `relay.paused` false, pending drains |
-| 2. Parity | Read-only. | — |
-| 3. Dual read | `for t in ${STAGED//,/ }; do live_tables -X POST -d '{"dual_read":false}' "$LT/$t" >/dev/null; done`. It never changed an answer. | `status`: `dual_read` false |
-| 4. Flip | `live_tables -X POST -d '{"authority":"live","by":"'"$USER"'"}' "$LT/$T" \| jq`: Chat sends its queued changes of the table to Live first, then Live writes it again. Refused while any change is still queued (`not in Live yet`): look at `curl -s 127.0.0.1:4400/ready \| jq .mirror` and run it again. | answer `table.authority` / `chat_authority` `live`; `outbox_pending` counting Live's new writes, then draining |
-| 4, Chat down | 1) send Chat's queue to Live: `cd /opt/openvibe.chat && sudo -u ubuntu env $(sudo cat /etc/openvibe/chat.env \| xargs) CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db node scripts/mirror-flush.js` (exit 0 = drained; Live takes the rows only while the table is still `chat` there, so this comes first); 2) Chat's side: `chat_tables set $T live --force`; 3) Live's side: `live_tables -X POST -d '{"authority":"live","force":true}' "$LT/$T"`. | `status`: `authority` `live`; once Chat runs again `chat_authority` `live`, no `disagree` |
-| 5. Live stops writing | Same as step 4: the flip back makes Live the writer again. | as step 4 |
-| Wave 6 rollback | Before `docs/cutover.md` "Rollback", flip every table back (row 4). If Chat cannot run, its mirror flush there (step 2 of that rollback) carries the staged tables at `chat` too; then set each table back with the "Chat down" row. | `status` |
-
-If `status` shows `disagree` for a table (one side `chat`, the other `live`, after a failed or forced
-flip), run the flip that states the side you want once more; Live's relay keeps any change Chat
-refused in the meantime and sends it once both sides agree.
@@ -19,11 +19,9 @@
@@ -19,11 +19,9 @@
* - Never drops a row. What cannot be represented goes to import_hold with the reason:
* - Never drops a row. What cannot be represented goes to import_hold with the reason:
* an id already used by a DIFFERENT row, a constraint the row breaks, rows of Live's
* an id already used by a DIFFERENT row, a constraint the row breaks, rows of Live's
* transient *_new migration tables.
* transient *_new migration tables.
- * - Chat's tables: an existing Chat row always wins (after the cutover Chat is the authority
- * and may have edited it). Staged tables (C-04) follow their table_authority: at 'live' this copy
- * is made equal to Live's — rows refreshed, and rows Live no longer has removed (`pruned`: a
- * moderator removed in Live must not keep powers here); at 'chat' Chat is the authority and the
- * run only reports how Live's copy differs (`differs`, `live_only`, `chat_only`).
+ * - Chat's tables: an existing Chat row always wins (after the cutover Chat is the authority and may
+ * have edited it). The six tables Chat owns (channel moderators/settings, emotes, user tags,
+ * chat-AI summaries/timeline; C-04 done) are not imported — Chat is their only writer.
* - New columns: *subject_id is filled from Live's linked_accounts (the Network subject).
* - New columns: *subject_id is filled from Live's linked_accounts (the Network subject).
* - media_requests / media_request_settings stay in Live (decision: docs/cutover.md) and are
* - media_requests / media_request_settings stay in Live (decision: docs/cutover.md) and are
* only reported.
* only reported.
@@ -107,10 +105,9 @@ function run(opts) {
@@ -107,10 +105,9 @@ function run(opts) {
const liveTables = new Set(live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
const liveTables = new Set(live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
const colsOf = (conn, t) => conn.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
const colsOf = (conn, t) => conn.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
- // A staged table Chat already writes is compared, never written (Chat is its authority).
+ // The six tables Chat owns (C-04 done) are not imported: Chat is their only writer.
const plan = [
const plan = [
- ...Object.entries(db.CHAT_TABLES).map(([t, pk]) => ({ table: t, pk, kind: 'chat' })),
- ...Object.entries(db.STAGED_KEYS).map(([t, pk]) => ({ table: t, pk, kind: db.tableAuthority(t) === 'chat' ? 'staged-chat' : 'staged' })),
+ ...Object.entries(db.CHAT_TABLES).map(([t, pk]) => ({ table: t, pk })),
].filter((p) => !opts.tables || opts.tables.includes(p.table));
].filter((p) => !opts.tables || opts.tables.includes(p.table));
// Schema drift check first: nothing is written when Live has columns Chat cannot hold.
// Schema drift check first: nothing is written when Live has columns Chat cannot hold.
@@ -137,35 +134,13 @@ function run(opts) {
@@ -137,35 +134,13 @@ function run(opts) {
const holdStmt = chat.prepare('INSERT OR IGNORE INTO import_hold (source_table, source_pk, reason, row_json) VALUES (?, ?, ?, ?)');
const holdStmt = chat.prepare('INSERT OR IGNORE INTO import_hold (source_table, source_pk, reason, row_json) VALUES (?, ?, ?, ?)');
const hold = (table, pkObj, reason, row) => holdStmt.run(table, JSON.stringify(pkObj), String(reason).slice(0, 300), JSON.stringify(row)).changes;
const hold = (table, pkObj, reason, row) => holdStmt.run(table, JSON.stringify(pkObj), String(reason).slice(0, 300), JSON.stringify(row)).changes;
- // A staged table Chat writes: count how Live's copy (its mirror) differs, write nothing.
- function compareOnly(p, r) {
- const chatCols = new Set(colsOf(chat, p.table));
- const cols = colsOf(live, p.table).filter((c) => chatCols.has(c));
- const getChat = chat.prepare(`SELECT ${cols.join(', ')} FROM ${p.table} WHERE ${p.pk.map((k) => `${k} = ?`).join(' AND ')}`);
- const seen = new Set();
- for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${p.table}`).iterate()) {
- r.live++;
- seen.add(JSON.stringify(p.pk.map((k) => row[k])));
- const mine = getChat.get(...p.pk.map((k) => row[k]));
- if (!mine) r.live_only++;
- else if (cols.every((c) => mine[c] === row[c] || (mine[c] == null && row[c] == null))) r.identical++;
- else r.differs++;
- }
- for (const row of chat.prepare(`SELECT ${p.pk.join(', ')} FROM ${p.table}`).iterate()) {
- if (!seen.has(JSON.stringify(p.pk.map((k) => row[k])))) r.chat_only++;
- }
- }
-
const report = { live_db: path.resolve(opts.liveDb), dry_run: !!opts.dryRun, backup: null, tables: {} };
const report = { live_db: path.resolve(opts.liveDb), dry_run: !!opts.dryRun, backup: null, tables: {} };
if (!opts.dryRun && opts.backup) report.backup = backupChat(chat, opts.backupPath);
if (!opts.dryRun && opts.backup) report.backup = backupChat(chat, opts.backupPath);
for (const p of plan) {
for (const p of plan) {
- const r = p.kind === 'staged-chat'
- ? { authority: 'chat', live: 0, identical: 0, differs: 0, live_only: 0, chat_only: 0 }
- : { live: 0, inserted: 0, identical: 0, refreshed: 0, chat_kept: 0, held: 0, ...(p.kind === 'staged' ? { authority: 'live', pruned: 0 } : {}) };
+ const r = { live: 0, inserted: 0, identical: 0, chat_kept: 0, held: 0 };
report.tables[p.table] = r;
report.tables[p.table] = r;
if (!liveTables.has(p.table)) { r.missing_in_live = true; continue; }
if (!liveTables.has(p.table)) { r.missing_in_live = true; continue; }
- if (p.kind === 'staged-chat') { compareOnly(p, r); continue; }
const chatCols = new Set(colsOf(chat, p.table));
const chatCols = new Set(colsOf(chat, p.table));
const cols = colsOf(live, p.table).filter((c) => chatCols.has(c));
const cols = colsOf(live, p.table).filter((c) => chatCols.has(c));
const subjectCols = (SUBJECT_COLUMNS[p.table] || []).filter(([sc]) => chatCols.has(sc));
const subjectCols = (SUBJECT_COLUMNS[p.table] || []).filter(([sc]) => chatCols.has(sc));
@@ -173,22 +148,10 @@ function run(opts) {
@@ -173,22 +148,10 @@ function run(opts) {
const where = p.pk.map((k) => `${k} = ?`).join(' AND ');
const where = p.pk.map((k) => `${k} = ?`).join(' AND ');
const getChat = chat.prepare(`SELECT * FROM ${p.table} WHERE ${where}`);
const getChat = chat.prepare(`SELECT * FROM ${p.table} WHERE ${where}`);
const ins = chat.prepare(`INSERT INTO ${p.table} (${insertCols.join(', ')}) VALUES (${insertCols.map(() => '?').join(', ')})`);
const ins = chat.prepare(`INSERT INTO ${p.table} (${insertCols.join(', ')}) VALUES (${insertCols.map(() => '?').join(', ')})`);
- const upd = cols.filter((c) => !p.pk.includes(c));
- const refresh = upd.length ? chat.prepare(`UPDATE ${p.table} SET ${upd.map((c) => `${c} = ?`).join(', ')} WHERE ${where}`) : null;
const identity = IDENTITY[p.table] || null;
const identity = IDENTITY[p.table] || null;
const orderBy = p.pk.join(', ');
const orderBy = p.pk.join(', ');
const apply = () => {
const apply = () => {
- // A staged table at 'live' is a copy: what Live no longer has goes first (a row Live
- // removed and added again under a new id would otherwise collide on its unique columns).
- if (p.kind === 'staged') {
- const keyOf = (row) => JSON.stringify(p.pk.map((k) => row[k]));
- const inLive = new Set(live.prepare(`SELECT ${p.pk.join(', ')} FROM ${p.table}`).all().map(keyOf));
- const del = chat.prepare(`DELETE FROM ${p.table} WHERE ${where}`);
- for (const row of chat.prepare(`SELECT ${p.pk.join(', ')} FROM ${p.table}`).all()) {
- if (!inLive.has(keyOf(row))) r.pruned += del.run(...p.pk.map((k) => row[k])).changes;
- }
- }
for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${p.table} ORDER BY ${orderBy}`).iterate()) {
for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${p.table} ORDER BY ${orderBy}`).iterate()) {
r.live++;
r.live++;
const pkVals = p.pk.map((k) => row[k]);
const pkVals = p.pk.map((k) => row[k]);
@@ -210,16 +173,7 @@ function run(opts) {
@@ -210,16 +173,7 @@ function run(opts) {
r.held += hold(p.table, pkObj, 'id already used by a different row in chat', row);
r.held += hold(p.table, pkObj, 'id already used by a different row in chat', row);
continue;
continue;
}
}
- if (p.kind === 'staged' && refresh) {
- try {
- refresh.run(...upd.map((c) => row[c]), ...pkVals);
- r.refreshed++;
- } catch (err) {
- r.held += hold(p.table, pkObj, `refresh failed: ${err.message}`, row);
- }
- } else {
- r.chat_kept++;
- }
+ r.chat_kept++;
}
}
};
};
@@ -5,14 +5,6 @@
@@ -5,14 +5,6 @@
* node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 \
* node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 \
* [--before "2026-09-22 10:00:00"] [--stream 123]... [--channel 45]... [--token <jwt>]
* [--before "2026-09-22 10:00:00"] [--stream 123]... [--channel 45]... [--token <jwt>]
*
*
- * node scripts/parity-check.js --tables --live-db <live.db copy> --chat-db <chat.db or a copy> \
- * [--table emotes]...
- *
- * --tables compares the staged tables (C-04, docs/staged-tables-cutover.md) row for row: per table
- * the row count and a sha256 over every row (the columns both copies have, ordered by key — the hash
- * Chat's dual read uses, database.js sliceHash), and on a difference the first keys that are only in
- * one copy or differ. Both files are opened read-only. Exit 1 on any difference.
- *
* --before pins history pages to messages older than the snapshot, so rows written since do not
* --before pins history pages to messages older than the snapshot, so rows written since do not
* count as differences. --token (a Network JWT of a test account) adds the DM routes. Prints one
* count as differences. --token (a Network JWT of a test account) adds the DM routes. Prints one
* line per path (same / DIFFERENT with the first differing keys) and exits 1 on any difference.
* line per path (same / DIFFERENT with the first differing keys) and exits 1 on any difference.
@@ -22,52 +14,15 @@
@@ -22,52 +14,15 @@
'use strict';
'use strict';
function parseArgs(argv) {
function parseArgs(argv) {
- const o = { streams: [], channels: [], only: [] };
+ const o = { streams: [], channels: [] };
for (let i = 0; i < argv.length; i++) {
for (let i = 0; i < argv.length; i++) {
const a = argv[i], v = argv[i + 1];
const a = argv[i], v = argv[i + 1];
- if (a === '--live') { o.live = v; i++; } else if (a === '--chat') { o.chat = v; i++; } else if (a === '--before') { o.before = v; i++; } else if (a === '--stream') { o.streams.push(v); i++; } else if (a === '--channel') { o.channels.push(v); i++; } else if (a === '--token') { o.token = v; i++; } else if (a === '--tables') { o.tables = true; } else if (a === '--live-db') { o.liveDb = v; i++; } else if (a === '--chat-db') { o.chatDb = v; i++; } else if (a === '--table') { o.only.push(v); i++; } else throw new Error(`unknown argument ${a}`);
+ if (a === '--live') { o.live = v; i++; } else if (a === '--chat') { o.chat = v; i++; } else if (a === '--before') { o.before = v; i++; } else if (a === '--stream') { o.streams.push(v); i++; } else if (a === '--channel') { o.channels.push(v); i++; } else if (a === '--token') { o.token = v; i++; } else throw new Error(`unknown argument ${a}`);
}
}
- if (o.tables) { if (!o.liveDb || !o.chatDb) throw new Error('--tables needs --live-db and --chat-db'); } else if (!o.live || !o.chat) throw new Error('--live and --chat are required');
+ if (!o.live || !o.chat) throw new Error('--live and --chat are required');
return o;
return o;
}
}
-/** --tables: the staged tables of two database files, row for row. → number of tables that differ */
-function tableParity(o, log = console.log) {
- const path = require('path');
- const Database = require('better-sqlite3');
- const { STAGED_KEYS, sliceHash } = require('../server/db/database');
- const live = new Database(path.resolve(o.liveDb), { readonly: true, fileMustExist: true });
- const chat = new Database(path.resolve(o.chatDb), { readonly: true, fileMustExist: true });
- const colsOf = (conn, t) => conn.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
- let authority = {};
- try { authority = Object.fromEntries(chat.prepare('SELECT table_name, authority FROM table_authority').all().map((r) => [r.table_name, r.authority])); } catch { /* an old copy */ }
- let bad = 0;
- for (const [t, pk] of Object.entries(STAGED_KEYS)) {
- if (o.only.length && !o.only.includes(t)) continue;
- const lc = colsOf(live, t), cc = colsOf(chat, t);
- if (!lc.length || !cc.length) { bad++; log(`MISSING ${t}: ${!lc.length ? 'not in the Live copy' : 'not in the Chat copy'}`); continue; }
- const cols = lc.filter((c) => cc.includes(c)).sort();
- const q = `SELECT ${cols.join(', ')} FROM ${t} ORDER BY ${pk.join(', ')}`;
- const a = live.prepare(q).all(), b = chat.prepare(q).all();
- const ha = sliceHash(cols, a), hb = sliceHash(cols, b);
- const label = `${t.padEnd(28)} (${authority[t] || '?'})`;
- if (a.length === b.length && ha === hb) { log(`same ${label} rows ${a.length} sha256 ${ha.slice(0, 16)}`); continue; }
- bad++;
- const keyOf = (r) => JSON.stringify(pk.map((k) => r[k]));
- const rowOf = (r) => JSON.stringify(cols.map((c) => (r[c] === undefined ? null : r[c])));
- const ma = new Map(a.map((r) => [keyOf(r), rowOf(r)])), mb = new Map(b.map((r) => [keyOf(r), rowOf(r)]));
- const onlyLive = [...ma.keys()].filter((k) => !mb.has(k));
- const onlyChat = [...mb.keys()].filter((k) => !ma.has(k));
- const differ = [...ma.keys()].filter((k) => mb.has(k) && mb.get(k) !== ma.get(k));
- log(`DIFFERENT ${label} rows ${a.length} ≠ ${b.length} sha256 ${ha.slice(0, 16)} ≠ ${hb.slice(0, 16)}`);
- const show = (name, list) => { if (list.length) log(` ${name} (${list.length}): ${list.slice(0, 5).join(' ')}${list.length > 5 ? ' …' : ''}`); };
- show('only in Live', onlyLive); show('only in Chat', onlyChat); show('differ', differ);
- }
- live.close(); chat.close();
- log(bad ? `\n${bad} table(s) differ` : '\nall staged tables are the same');
- return bad;
-}
-
const IGNORE = new Set(['request_id', 'trace_id', 'latest_id']);
const IGNORE = new Set(['request_id', 'trace_id', 'latest_id']);
function diff(a, b, at = '', out = []) {
function diff(a, b, at = '', out = []) {
if (out.length > 5) return out;
if (out.length > 5) return out;
@@ -93,7 +48,6 @@ async function get(base, p, token) {
@@ -93,7 +48,6 @@ async function get(base, p, token) {
async function main() {
async function main() {
const o = parseArgs(process.argv.slice(2));
const o = parseArgs(process.argv.slice(2));
- if (o.tables) process.exit(tableParity(o) ? 1 : 0);
const before = o.before ? `&before=${encodeURIComponent(o.before)}` : '';
const before = o.before ? `&before=${encodeURIComponent(o.before)}` : '';
const paths = [
const paths = [
`/api/chat/global/history?limit=200${before}`,
`/api/chat/global/history?limit=200${before}`,
@@ -120,4 +74,4 @@ async function main() {
@@ -120,4 +74,4 @@ async function main() {
if (require.main === module) main().catch((err) => { console.error(err.message); process.exit(2); });
if (require.main === module) main().catch((err) => { console.error(err.message); process.exit(2); });
-module.exports = { parseArgs, tableParity };
+module.exports = { parseArgs };
deleted file mode 100644
deleted file mode 100644
@@ -1,51 +0,0 @@
@@ -1,51 +0,0 @@
-#!/usr/bin/env node
-/**
- * Who writes each staged table (roadmap C-04, docs/staged-tables-cutover.md), from Chat's side.
- *
- * node scripts/table-authority.js → { tables: { t: { authority, rows, mirror_pending } } }
- * node scripts/table-authority.js set <table> <live|chat> --force
- *
- * The normal way to move a table is Live's handoff (POST /internal/chat-tables/:table on Live), which
- * drains both sides and moves Chat and Live together. `set` changes only Chat's table_authority, for
- * when Live cannot run the handoff (Live down during a rollback); afterwards make Live agree with the
- * same handoff call. Back to 'live' is refused while changes of the table still wait for the Live
- * mirror (scripts/mirror-flush.js sends them). Uses the service's env (CHAT_DB_PATH).
- */
-'use strict';
-
-const db = require('../server/db/database');
-
-function status() {
- const tables = {};
- for (const t of Object.keys(db.STAGED_KEYS)) {
- tables[t] = { authority: db.tableAuthority(t), rows: db.get(`SELECT COUNT(*) AS n FROM ${t}`).n, mirror_pending: db.mirrorPending(t) };
- }
- return { tables };
-}
-
-function set(table, authority) {
- if (!db.STAGED_KEYS[table]) throw new Error(`${table} is not a staged table (${Object.keys(db.STAGED_KEYS).join(', ')})`);
- if (authority !== 'live' && authority !== 'chat') throw new Error('authority is live or chat');
- const pending = db.mirrorPending(table);
- if (authority === 'live' && pending) throw new Error(`${pending} change(s) to ${table} have not reached Live yet: run scripts/mirror-flush.js first`);
- return { table, before: db.tableAuthority(table), after: db.setTableAuthority(table, authority) };
-}
-
-if (require.main === module) {
- const [cmd, table, authority] = process.argv.slice(2).filter((a) => !a.startsWith('--'));
- const force = process.argv.includes('--force');
- try {
- db.initDb({ captureMirror: false });
- if (!cmd || cmd === 'status') console.log(JSON.stringify(status(), null, 2));
- else if (cmd === 'set') {
- if (!force) throw new Error('set changes Chat alone; the handoff is Live\'s POST /internal/chat-tables/:table. Add --force if Live cannot run it.');
- console.log(JSON.stringify(set(table, authority), null, 2));
- } else throw new Error('usage: table-authority.js [status] | set <table> <live|chat> --force');
- db.close();
- } catch (err) {
- console.error(err.message);
- process.exit(2);
- }
-}
-
-module.exports = { status, set };
@@ -13,10 +13,9 @@
@@ -13,10 +13,9 @@
* Lower cost + graceful scaling as Live: a DB poller with the high-water mark in
* Lower cost + graceful scaling as Live: a DB poller with the high-water mark in
* chat_ai_summaries.last_message_id, an adaptive window, a few subjects per tick.
* chat_ai_summaries.last_message_id, an adaptive window, a few subjects per tick.
*
*
- * Writes go through Chat's staged writers under the _assertChatWrites rule: while Live is still the
- * table's authority a write is skipped with one log line, never forced. The job itself is off
- * unless config.ai.enabled (CHAT_AI_ENABLED=1) — off in development and the test harness, like
- * Chat's other background jobs.
+ * Chat is the only writer of the two summaries tables since the C-04 cutover: the writes are plain
+ * calls, no authority gate. The job itself is off unless config.ai.enabled (CHAT_AI_ENABLED=1) —
+ * off in development and the test harness, like Chat's other background jobs.
*/
*/
'use strict';
'use strict';
@@ -57,7 +56,6 @@ let _running = false;
@@ -57,7 +56,6 @@ let _running = false;
let _timer = null;
let _timer = null;
let _tickInFlight = false;
let _tickInFlight = false;
let _lastUserPass = 0;
let _lastUserPass = 0;
-let _writeSkipLogged = false;
// ── Small utils ──────────────────────────────────────────────────────────────
// ── Small utils ──────────────────────────────────────────────────────────────
// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (CURRENT_TIMESTAMP). Match that format.
// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (CURRENT_TIMESTAMP). Match that format.
@@ -118,24 +116,7 @@ function _cleanTimeline(arr) {
@@ -118,24 +116,7 @@ function _cleanTimeline(arr) {
.sort((a, b) => _parseSqlTime(a.ts) - _parseSqlTime(b.ts));
.sort((a, b) => _parseSqlTime(a.ts) - _parseSqlTime(b.ts));
}
}
-// ── The authority gate ───────────────────────────────────────────────────────
-// Live still writes the two summaries tables until the cutover: the job skips (one log line), it
-// never forces a write, and writes that would race the flip are caught the same way.
-function _tablesHold() {
- return db.tableAuthority('chat_ai_summaries') === 'chat' && db.tableAuthority('chat_timeline_events') === 'chat';
-}
-function _logWriteSkip() {
- if (_writeSkipLogged) return;
- _writeSkipLogged = true;
- console.log('[ChatAI] chat_ai_summaries/chat_timeline_events are still written by Live (table_authority live) — the job skips its writes');
-}
-function _write(fn, ...args) {
- try { return fn(...args); }
- catch (err) {
- if (err && err.code === 'table.not_chat') { _logWriteSkip(); return null; }
- throw err;
- }
-}
+// Chat is the only writer of the two summaries tables since the C-04 cutover: no authority gate.
// ── GLOBAL ───────────────────────────────────────────────────────────────────
// ── GLOBAL ───────────────────────────────────────────────────────────────────
async function _refreshGlobal() {
async function _refreshGlobal() {
@@ -172,8 +153,8 @@ async function _refreshGlobal() {
@@ -172,8 +153,8 @@ async function _refreshGlobal() {
if (!parsed) return false;
if (!parsed) return false;
const nowIso = _sqlTime(now);
const nowIso = _sqlTime(now);
- _write(db.addChatTimelineEvents, 'global', 0, _stampAdditions(parsed.timeline, nowIso, now));
- _write(db.upsertChatAiSummary, {
+ db.addChatTimelineEvents('global', 0, _stampAdditions(parsed.timeline, nowIso, now));
+ db.upsertChatAiSummary({
scope: 'global', subject_id: 0, window: 'global',
scope: 'global', subject_id: 0, window: 'global',
overview: _clip(parsed.recent_overview || '', 2000),
overview: _clip(parsed.recent_overview || '', 2000),
memory_json: _clip(parsed.memory || priorMemory, MEMORY_MAX_CHARS),
memory_json: _clip(parsed.memory || priorMemory, MEMORY_MAX_CHARS),
@@ -215,7 +196,7 @@ async function _refreshUser(uid, maxId) {
@@ -215,7 +196,7 @@ async function _refreshUser(uid, maxId) {
const nowIso = _sqlTime(now);
const nowIso = _sqlTime(now);
const _lastRow = dayRows[dayRows.length - 1] || {};
const _lastRow = dayRows[dayRows.length - 1] || {};
const activityTs = _lastRow.timestamp || _lastRow.created_at || nowIso;
const activityTs = _lastRow.timestamp || _lastRow.created_at || nowIso;
- _write(db.upsertChatAiSummary, {
+ db.upsertChatAiSummary({
scope: 'user', subject_id: uid, window: 'rolling',
scope: 'user', subject_id: uid, window: 'rolling',
overview: JSON.stringify({
overview: JSON.stringify({
today: _clip(parsed.overview_24h || '', 1200),
today: _clip(parsed.overview_24h || '', 1200),
@@ -271,7 +252,7 @@ async function _refreshRelayUser(ru) {
@@ -271,7 +252,7 @@ async function _refreshRelayUser(ru) {
if (!parsed) return false;
if (!parsed) return false;
const nowIso = _sqlTime(now);
const nowIso = _sqlTime(now);
- _write(db.upsertChatAiSummary, {
+ db.upsertChatAiSummary({
scope: 'relay', subject_id: ru.id, window: 'rolling',
scope: 'relay', subject_id: ru.id, window: 'rolling',
overview: JSON.stringify({
overview: JSON.stringify({
today: _clip(parsed.overview_24h || '', 1200),
today: _clip(parsed.overview_24h || '', 1200),
@@ -325,7 +306,7 @@ async function _refreshAnon(anonId) {
@@ -325,7 +306,7 @@ async function _refreshAnon(anonId) {
if (!parsed) return false;
if (!parsed) return false;
const nowIso = _sqlTime(now);
const nowIso = _sqlTime(now);
- _write(db.upsertChatAiSummary, {
+ db.upsertChatAiSummary({
scope: 'anon', subject_id: subjectId, window: 'rolling',
scope: 'anon', subject_id: subjectId, window: 'rolling',
overview: JSON.stringify({
overview: JSON.stringify({
today: _clip(parsed.overview_24h || '', 1200),
today: _clip(parsed.overview_24h || '', 1200),
@@ -365,7 +346,6 @@ async function _tick() {
@@ -365,7 +346,6 @@ async function _tick() {
_tickInFlight = true;
_tickInFlight = true;
try {
try {
if (!config.ai.enabled) return; // the job's switch (off in dev and tests)
if (!config.ai.enabled) return; // the job's switch (off in dev and tests)
- if (!_tablesHold()) { _logWriteSkip(); return; }
try { await _refreshGlobal(); }
try { await _refreshGlobal(); }
catch (e) { console.warn('[ChatAI] global refresh failed:', e.message); }
catch (e) { console.warn('[ChatAI] global refresh failed:', e.message); }
@@ -390,7 +370,7 @@ async function _seedTimelineEvents() {
@@ -390,7 +370,7 @@ async function _seedTimelineEvents() {
if (!row) return;
if (!row) return;
let tl = []; try { tl = JSON.parse(row.timeline_json || '[]'); } catch { tl = []; }
let tl = []; try { tl = JSON.parse(row.timeline_json || '[]'); } catch { tl = []; }
const cleaned = _cleanTimeline(tl);
const cleaned = _cleanTimeline(tl);
- if (cleaned.length) { _write(db.addChatTimelineEvents, 'global', 0, cleaned); console.log(`[ChatAI] Seeded ${cleaned.length} timeline event(s)`); }
+ if (cleaned.length) { db.addChatTimelineEvents('global', 0, cleaned); console.log(`[ChatAI] Seeded ${cleaned.length} timeline event(s)`); }
} catch { /* */ }
} catch { /* */ }
}
}
@@ -147,8 +147,6 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -147,8 +147,6 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
connections: chatServer.getTotalConnections(),
connections: chatServer.getTotalConnections(),
live: { last_sync_at: ctx.stats.lastSyncAt, last_success_at: ctx.stats.lastSuccessAt, failures: ctx.stats.failures, last_error: ctx.stats.lastError },
live: { last_sync_at: ctx.stats.lastSyncAt, last_success_at: ctx.stats.lastSuccessAt, failures: ctx.stats.failures, last_error: ctx.stats.lastError },
mirror: mirror ? { enabled: config.live.mirror, pending, last_error: mirror.lastError() } : null,
mirror: mirror ? { enabled: config.live.mirror, pending, last_error: mirror.lastError() } : null,
- // Who writes each staged table (C-04, docs/staged-tables-cutover.md).
- table_authority: checks.db.status === 'ok' ? (() => { try { return db.stagedAuthorities(); } catch { return null; } })() : null,
events: {
events: {
enabled: !!config.events.url,
enabled: !!config.events.url,
consumer: events ? { enabled: events.enabled, ...events.stats } : null,
consumer: events ? { enabled: events.enabled, ...events.stats } : null,
@@ -24,18 +24,12 @@
@@ -24,18 +24,12 @@
* without it that op alone is refused ({ ok: false, code: 'capability.denied' }) and the
* without it that op alone is refused ({ ok: false, code: 'capability.denied' }) and the
* rest of the batch still runs.
* rest of the batch still runs.
*
*
- * Staged tables (roadmap C-04, docs/staged-tables-cutover.md), same endpoint and capability:
- * db ops addChannelModerator … addChatTimelineEvents Live's writers once a table is at
- * 'chat' (awaited one by one, with an idempotency key); each answers { value, mirror }:
- * Live's own return value and the rows as they are now, which Live applies to its copy
- * at once. Refused while the table is at 'live'.
- * stagedApply [changes] Live's captured changes while a table is at 'live' (this copy
- * stays current; refused per change once Chat writes the table)
- * stagedSlice [table, where, columns] { count, hash, … } for Live's dual read
- * tableAuthority [] { table: 'live' | 'chat' }
- * setTableAuthority [table, authority] Chat's half of the handoff Live runs (never
- * automatic): to 'chat' needs the Live mirror on; back to 'live' first sends every
- * queued change of the table to Live and refuses while any is left.
+ * The six tables Chat owns (channel moderators/settings, emotes, user tags, chat-AI
+ * summaries/timeline), same endpoint and capability: db ops addChannelModerator …
+ * addChatTimelineEvents. Chat is their only writer since the C-04 cutover (there is no
+ * authority switch any more); Live's current release still calls these ops until its next
+ * deploy, each answering { value, mirror } — Live's own return value and the rows as they are
+ * now, which Live applies to its copy at once.
*
*
* GET /internal/live/presence capability chat.presence.read
* GET /internal/live/presence capability chat.presence.read
* Who is connected where (counts, slow modes, user/anon → ip), for Live's synchronous
* Who is connected where (counts, slow modes, user/anon → ip), for Live's synchronous
@@ -60,7 +54,8 @@ const DB_OPS = new Set([
@@ -60,7 +54,8 @@ const DB_OPS = new Set([
'logModerationAction', 'recordFirstChat', 'setTtsVoiceOverride', 'deleteTtsVoiceOverride',
'logModerationAction', 'recordFirstChat', 'setTtsVoiceOverride', 'deleteTtsVoiceOverride',
'createChannelSound', 'setChannelSoundEmote', 'deleteChannelSound', 'renameChannelSoundCommand',
'createChannelSound', 'setChannelSoundEmote', 'deleteChannelSound', 'renameChannelSoundCommand',
'updateChannelSoundEmoteRefs',
'updateChannelSoundEmoteRefs',
- // Staged tables at 'chat' (each refuses while its table is at 'live').
+ // The six tables Chat owns (C-04 done): Live's current release's writers, kept as thin calls
+ // to the same database functions until Live N+1 is deployed.
'addChannelModerator', 'removeChannelModerator', 'upsertChannelModerationSettings', 'setChannelAlertSound',
'addChannelModerator', 'removeChannelModerator', 'upsertChannelModerationSettings', 'setChannelAlertSound',
'createEmote', 'updateEmote', 'deleteEmote', 'setEmoteMedia', 'grantUserTag', 'revokeUserTag',
'createEmote', 'updateEmote', 'deleteEmote', 'setEmoteMedia', 'grantUserTag', 'revokeUserTag',
'upsertChatAiSummary', 'addChatTimelineEvents',
'upsertChatAiSummary', 'addChatTimelineEvents',
@@ -119,34 +114,6 @@ const REF_TTL_MS = 10 * 60 * 1000; // in memory
@@ -119,34 +114,6 @@ const REF_TTL_MS = 10 * 60 * 1000; // in memory
const REF_KEEP_MS = 24 * 3600 * 1000; // in bridge_refs
const REF_KEEP_MS = 24 * 3600 * 1000; // in bridge_refs
const REF_MIN = -(2 ** 40); // Live's placeholders are ≤ this
const REF_MIN = -(2 ** 40); // Live's placeholders are ≤ this
-/**
- * Chat's half of a staged table's handoff (Live's server/chat/chat-tables.js runs it, its writers
- * waiting meanwhile). To 'chat': Chat writes the table from now on and the mirror copies it to Live —
- * so the mirror must be on. Back to 'live': every change Chat made to the table reaches Live first;
- * the last check and the switch run in one tick, so no Chat write lands between them.
- */
-async function handOver(table, authority, { mirror, config }) {
- if (!db.STAGED_KEYS[table]) throw new Error(`${table} is not a staged table`);
- if (authority !== 'live' && authority !== 'chat') throw new Error('authority is live or chat');
- if (authority === 'chat') {
- if (!mirror || !config.live.mirror) throw new Error('LIVE_MIRROR is off: Live would never get this table back');
- db.setTableAuthority(table, 'chat');
- console.log(`[Bridge] ${table}: Chat writes it now (table_authority chat)`);
- return { table, authority: 'chat', mirror_pending: db.mirrorPending(table) };
- }
- const until = Date.now() + 15000;
- while (db.mirrorPending(table) > 0 && mirror && config.live.mirror && Date.now() < until) {
- const r = await mirror.flush();
- if (r && r.error) break;
- if (r && r.busy) await new Promise((res) => setTimeout(res, 100));
- }
- const left = db.mirrorPending(table);
- if (left) throw new Error(`${left} change(s) to ${table} not in Live yet${mirror && mirror.lastError() ? ` (${mirror.lastError()})` : ''}`);
- db.setTableAuthority(table, 'live');
- console.log(`[Bridge] ${table}: back to Live (table_authority live)`);
- return { table, authority: 'live', mirror_pending: 0 };
-}
-
function createBridge({ chatServer, mirror = null, config = require('../config') }) {
function createBridge({ chatServer, mirror = null, config = require('../config') }) {
const router = express.Router();
const router = express.Router();
const refs = new Map(); // `${boot}|${ref}` → { id, at }
const refs = new Map(); // `${boot}|${ref}` → { id, at }
@@ -262,15 +229,6 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
@@ -262,15 +229,6 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
else throw new Error(`invalidate: unknown kind ${kind}`);
else throw new Error(`invalidate: unknown kind ${kind}`);
return null;
return null;
}
}
- // Staged tables (C-04).
- case 'stagedApply':
- return db.applyStagedChanges(args[0]);
- case 'stagedSlice':
- return db.stagedSlice(String(args[0] || ''), args[1] && typeof args[1] === 'object' ? args[1] : {}, Array.isArray(args[2]) ? args[2] : null);
- case 'tableAuthority':
- return db.stagedAuthorities();
- case 'setTableAuthority':
- return handOver(String(args[0] || ''), String(args[1] || ''), { mirror, config });
default:
default:
throw new Error(`unknown op ${op}`);
throw new Error(`unknown op ${op}`);
}
}
@@ -323,4 +281,4 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
@@ -323,4 +281,4 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
return router;
return router;
}
}
-module.exports = { createBridge, handOver, DB_OPS, SERVER_OPS, MESSAGE_SEND, sendsMessage };
+module.exports = { createBridge, DB_OPS, SERVER_OPS, MESSAGE_SEND, sendsMessage };
@@ -3,9 +3,8 @@
@@ -3,9 +3,8 @@
*
*
* After the cutover Chat is the only writer of its tables, but Live still reads some of them in
* After the cutover Chat is the only writer of its tables, but Live still reads some of them in
* place (home-page stats, recaps, AI chat context, VOD chat replay, /api/mod queues, analytics),
* place (home-page stats, recaps, AI chat context, VOD chat replay, /api/mod queues, analytics),
- * and rollback means Live becomes the authority again. So every change Chat makes to its tables —
- * and to each staged table once it is at 'chat' (C-04, docs/staged-tables-cutover.md) — is copied
- * into Live's tables of the same name, same ids:
+ * and rollback means Live becomes the authority again. So every change Chat makes to its tables
+ * (C-02) is copied into Live's tables of the same name, same ids:
*
*
* - this connection's TEMP triggers (db.initDb({ captureMirror: true })) record each change in
* - this connection's TEMP triggers (db.initDb({ captureMirror: true })) record each change in
* live_mirror_outbox — writes by the importer (rows that came from Live) are never recorded;
* live_mirror_outbox — writes by the importer (rows that came from Live) are never recorded;
@@ -40,8 +39,7 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lo
@@ -40,8 +39,7 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lo
for (const r of rows) byKey.set(`${r.tbl}|${r.pk}`, r);
for (const r of rows) byKey.set(`${r.tbl}|${r.pk}`, r);
const changes = [];
const changes = [];
for (const r of byKey.values()) {
for (const r of byKey.values()) {
- // A staged table (C-04) is queued only while Chat writes it (its triggers check).
- const cols = db.CHAT_TABLES[r.tbl] || db.STAGED_KEYS[r.tbl];
+ const cols = db.CHAT_TABLES[r.tbl];
if (!cols) continue;
if (!cols) continue;
let pk;
let pk;
try { pk = JSON.parse(r.pk); } catch { continue; }
try { pk = JSON.parse(r.pk); } catch { continue; }
@@ -17,10 +17,9 @@
@@ -17,10 +17,9 @@
* requests, their tags, and the AI summaries and timeline about them;
* requests, their tags, and the AI summaries and timeline about them;
* - the channel, stream and user mirrors of their Live account (the user mirror
* - the channel, stream and user mirrors of their Live account (the user mirror
* stays as a tombstone).
* stays as a tombstone).
- * Moderation actions stay as recorded. A staged table (channel_moderators,
- * channel_moderation_settings, emotes, user_tags, chat_ai_summaries,
- * chat_timeline_events) is erased here only while Chat writes it (table_authority);
- * until then Live erases it and its capture keeps this copy current.
+ * Moderation actions stay as recorded. Chat owns the six chat tables
+ * (channel_moderators, channel_moderation_settings, emotes, user_tags,
+ * chat_ai_summaries, chat_timeline_events, C-04 done) and erases them all here.
* Chat then confirms with counts.
* Chat then confirms with counts.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
@@ -92,9 +91,6 @@ function exportPart(d, subject) {
@@ -92,9 +91,6 @@ function exportPart(d, subject) {
// ── Deletion ───────────────────────────────────────────────────
// ── Deletion ───────────────────────────────────────────────────
-const STAGED = ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events'];
-const chatWrites = (d, table) => { try { const r = d.prepare('SELECT authority FROM table_authority WHERE table_name = ?').get(table); return !!r && r.authority === 'chat'; } catch { return false; } };
-
function erase(d, subjects) {
function erase(d, subjects) {
const ids = liveIds(d, subjects);
const ids = liveIds(d, subjects);
const erased = {}; const retained = {};
const erased = {}; const retained = {};
@@ -102,8 +98,6 @@ function erase(d, subjects) {
@@ -102,8 +98,6 @@ function erase(d, subjects) {
const del = (key, table, sc, ic) => {
const del = (key, table, sc, ic) => {
const w = whereFor(d, table, sc, ic, subjects, ids);
const w = whereFor(d, table, sc, ic, subjects, ids);
if (!w) return;
if (!w) return;
- // A staged table Live still writes: Live erases it, and its capture brings the change here.
- if (STAGED.includes(table) && !chatWrites(d, table)) { add(retained, 'erased_by_live', d.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${w[0]}`).get(...w[1]).n); return; }
add(erased, key, d.prepare(`DELETE FROM ${table} WHERE ${w[0]}`).run(...w[1]).changes);
add(erased, key, d.prepare(`DELETE FROM ${table} WHERE ${w[0]}`).run(...w[1]).changes);
};
};
d.transaction(() => {
d.transaction(() => {
@@ -137,8 +131,8 @@ function erase(d, subjects) {
@@ -137,8 +131,8 @@ function erase(d, subjects) {
if (ids.length) {
if (ids.length) {
// Their channel: emotes, sounds, moderators, stats, audio requests, then the Live mirrors.
// Their channel: emotes, sounds, moderators, stats, audio requests, then the Live mirrors.
const channels = has(d, 'ctx_channels', 'user_id') ? d.prepare(`SELECT id FROM ctx_channels WHERE user_id IN ${inList(ids)}`).all(...ids).map((r) => r.id) : [];
const channels = has(d, 'ctx_channels', 'user_id') ? d.prepare(`SELECT id FROM ctx_channels WHERE user_id IN ${inList(ids)}`).all(...ids).map((r) => r.id) : [];
- if (channels.length && has(d, 'channel_moderators', 'channel_id') && chatWrites(d, 'channel_moderators')) add(erased, 'moderator_roles', d.prepare(`DELETE FROM channel_moderators WHERE channel_id IN ${inList(channels)}`).run(...channels).changes);
- if (channels.length && has(d, 'channel_moderation_settings', 'channel_id') && chatWrites(d, 'channel_moderation_settings')) d.prepare(`DELETE FROM channel_moderation_settings WHERE channel_id IN ${inList(channels)}`).run(...channels);
+ if (channels.length && has(d, 'channel_moderators', 'channel_id')) add(erased, 'moderator_roles', d.prepare(`DELETE FROM channel_moderators WHERE channel_id IN ${inList(channels)}`).run(...channels).changes);
+ if (channels.length && has(d, 'channel_moderation_settings', 'channel_id')) d.prepare(`DELETE FROM channel_moderation_settings WHERE channel_id IN ${inList(channels)}`).run(...channels);
del('emotes', 'emotes', [], ['user_id']);
del('emotes', 'emotes', [], ['user_id']);
del('sounds', 'channel_sounds', ['created_by_subject_id'], ['created_by']);
del('sounds', 'channel_sounds', ['created_by_subject_id'], ['created_by']);
del('channel_stats', 'stream_first_chats', [], ['channel_user_id']);
del('channel_stats', 'stream_first_chats', [], ['channel_user_id']);
@@ -14,8 +14,7 @@
@@ -14,8 +14,7 @@
* POST /:channelId/moderation/messages/:messageId/delete - Delete a chat message (mod+)
* POST /:channelId/moderation/messages/:messageId/delete - Delete a chat message (mod+)
*
*
* Channels, users and moderation policy come through live-context; the moderator and settings rows
* Channels, users and moderation policy come through live-context; the moderator and settings rows
- * are Chat's own tables. A write while Live is still the table's writer answers 503
- * { ok: false, error: 'not yet' } and never writes; after the flip a settings or moderator change
+ * are Chat's own tables (C-04 done, Chat is their only writer). A settings or moderator change
* refreshes Chat's policy cache at once (the db helpers call invalidateChannel).
* refreshes Chat's policy cache at once (the db helpers call invalidateChannel).
*/
*/
'use strict';
'use strict';
@@ -28,12 +27,6 @@ const permissions = require('../auth/permissions');
@@ -28,12 +27,6 @@ const permissions = require('../auth/permissions');
const router = express.Router();
const router = express.Router();
-/** A staged-table write while Live is still the authority: 503, never a write. */
-function notYet(res, err) {
- if (err && err.code === 'table.not_chat') { res.status(503).json({ ok: false, error: 'not yet' }); return true; }
- return false;
-}
-
function parseBoolean(value, fallback) {
function parseBoolean(value, fallback) {
if (value === undefined || value === null) return fallback;
if (value === undefined || value === null) return fallback;
if (typeof value === 'boolean') return value;
if (typeof value === 'boolean') return value;
@@ -127,7 +120,6 @@ router.post('/:channelId/mods', requireAuth, async (req, res) => {
@@ -127,7 +120,6 @@ router.post('/:channelId/mods', requireAuth, async (req, res) => {
});
});
res.json({ message: `${targetUser.username} added as channel moderator`, moderators: db.getChannelModerators(channelId) });
res.json({ message: `${targetUser.username} added as channel moderator`, moderators: db.getChannelModerators(channelId) });
} catch (err) {
} catch (err) {
- if (notYet(res, err)) return;
res.status(500).json({ error: 'Failed to add channel moderator' });
res.status(500).json({ error: 'Failed to add channel moderator' });
}
}
});
});
@@ -154,7 +146,6 @@ router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {
@@ -154,7 +146,6 @@ router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {
});
});
res.json({ message: 'Channel moderator removed', moderators: db.getChannelModerators(channelId) });
res.json({ message: 'Channel moderator removed', moderators: db.getChannelModerators(channelId) });
} catch (err) {
} catch (err) {
- if (notYet(res, err)) return;
res.status(500).json({ error: 'Failed to remove channel moderator' });
res.status(500).json({ error: 'Failed to remove channel moderator' });
}
}
});
});
@@ -243,7 +234,6 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
@@ -243,7 +234,6 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
});
});
res.json({ settings: settings.value });
res.json({ settings: settings.value });
} catch (err) {
} catch (err) {
- if (notYet(res, err)) return;
console.error('[ChannelMod] Failed to update moderation settings:', err.message);
console.error('[ChannelMod] Failed to update moderation settings:', err.message);
res.status(500).json({ error: 'Failed to update moderation settings' });
res.status(500).json({ error: 'Failed to update moderation settings' });
}
}
@@ -313,7 +303,6 @@ router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, re
@@ -313,7 +303,6 @@ router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, re
});
});
res.json({ message: 'Message deleted', ids: [messageId] });
res.json({ message: 'Message deleted', ids: [messageId] });
} catch (err) {
} catch (err) {
- if (notYet(res, err)) return;
res.status(500).json({ error: 'Failed to delete message' });
res.status(500).json({ error: 'Failed to delete message' });
}
}
});
});
@@ -2838,8 +2838,8 @@ class ChatServer {
@@ -2838,8 +2838,8 @@ class ChatServer {
}
}
/**
/**
- * /slow and /subonly. The channel's saved setting is the truth: it is written where the table's
- * authority says (Live's effect, or here once Chat writes channel_moderation_settings), then the
+ * /slow and /subonly. The channel's saved setting is the truth: it is written to Chat's own
+ * channel_moderation_settings row (C-04 done), then the
* channel's whole room hears it (every live slot, the offline room, popouts). Nothing changes when
* channel's whole room hears it (every live slot, the offline room, popouts). Nothing changes when
* the write fails. → true when saved.
* the write fails. → true when saved.
*/
*/
@@ -19,8 +19,8 @@
@@ -19,8 +19,8 @@
* GET /api/emotes/all/:streamId - All emotes available in a stream context
* GET /api/emotes/all/:streamId - All emotes available in a stream context
*
*
* Uploaded bytes go to OpenVibe.Media with Chat's own service token; the row stores media_url and
* Uploaded bytes go to OpenVibe.Media with Chat's own service token; the row stores media_url and
- * media_asset_id, and responses use media_url (existing Live rows already carry one). Writes answer
- * 503 { ok: false, error: 'not yet' } while Live is still the table's writer (table_authority live).
+ * media_asset_id, and responses use media_url (existing Live rows already carry one). Chat is the
+ * emotes table's only writer (C-04 done).
*/
*/
'use strict';
'use strict';
@@ -73,11 +73,6 @@ function emoteUploadRateLimited(userId) {
@@ -73,11 +73,6 @@ function emoteUploadRateLimited(userId) {
return false;
return false;
}
}
-/** A staged-table write while Live is still the authority: 503, never a write. */
-function notYet(res, err) {
- if (err && err.code === 'table.not_chat') { res.status(503).json({ ok: false, error: 'not yet' }); return true; }
- return false;
-}
const fileUrl = (e) => e.media_url || `/api/emotes/file/${path.basename(e.url)}`;
const fileUrl = (e) => e.media_url || `/api/emotes/file/${path.basename(e.url)}`;
// ══════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════
@@ -343,7 +338,6 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
@@ -343,7 +338,6 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
},
},
});
});
} catch (err) {
} catch (err) {
- if (notYet(res, err)) return;
console.error('[Emotes] Upload error:', err);
console.error('[Emotes] Upload error:', err);
if (err && /UNIQUE constraint/i.test(err.message || '')) {
if (err && /UNIQUE constraint/i.test(err.message || '')) {
const code = String((req.body && req.body.code) || '').trim();
const code = String((req.body && req.body.code) || '').trim();
@@ -413,7 +407,6 @@ router.patch('/:id', requireAuth, async (req, res) => {
@@ -413,7 +407,6 @@ router.patch('/:id', requireAuth, async (req, res) => {
}
}
res.json({ message: 'Emote updated', code: patch.code ?? emote.code, size: patch.size ?? emote.size });
res.json({ message: 'Emote updated', code: patch.code ?? emote.code, size: patch.size ?? emote.size });
} catch (err) {
} catch (err) {
- if (notYet(res, err)) return;
res.status(500).json({ error: 'Failed to update emote' });
res.status(500).json({ error: 'Failed to update emote' });
}
}
});
});
@@ -437,7 +430,6 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -437,7 +430,6 @@ router.delete('/:id', requireAuth, async (req, res) => {
}
}
res.json({ message: 'Emote deleted' });
res.json({ message: 'Emote deleted' });
} catch (err) {
} catch (err) {
- if (notYet(res, err)) return;
res.status(500).json({ error: 'Failed to delete emote' });
res.status(500).json({ error: 'Failed to delete emote' });
}
}
});
});
@@ -458,8 +458,8 @@ router.post('/alert/:kind', requireAuth, uploadLimit, soundUpload.single('sound'
@@ -458,8 +458,8 @@ router.post('/alert/:kind', requireAuth, uploadLimit, soundUpload.single('sound'
const ext = path.extname(finalPath).toLowerCase();
const ext = path.extname(finalPath).toLowerCase();
const mime = EXT_TO_MIME[ext] || 'audio/mpeg';
const mime = EXT_TO_MIME[ext] || 'audio/mpeg';
- // The alert columns are on channel_moderation_settings (Live's until the C-04 handoff, then
- // Chat's and mirrored); Live's alerts read them.
+ // The alert columns are on channel_moderation_settings, Chat's own since the C-04 cutover
+ // (mirrored to Live, whose alerts read them).
await ctx.effects.setChannelAlertSound(channel.id, kind, finalPath, mime, req.user.id);
await ctx.effects.setChannelAlertSound(channel.id, kind, finalPath, mime, req.user.id);
res.json({ set: true, kind, url: `/api/sounds/file/${path.basename(finalPath)}` });
res.json({ set: true, kind, url: `/api/sounds/file/${path.basename(finalPath)}` });
} catch (err) {
} catch (err) {
@@ -36,29 +36,6 @@ const CHAT_TABLES = {
@@ -36,29 +36,6 @@ const CHAT_TABLES = {
stream_first_chats: ['chatter_key', 'channel_user_id'],
stream_first_chats: ['chatter_key', 'channel_user_id'],
moderation_actions: ['id'],
moderation_actions: ['id'],
};
};
-// Chat-target tables whose writer moves per table (roadmap C-04, docs/staged-tables-cutover.md).
-// table_authority says who writes each one: 'live' (the default) — Live writes, and every change
-// it makes reaches this copy over the bridge (applyStagedChanges); 'chat' — the staged writes
-// below are the only writers, Live's writers call them over the bridge and the Live mirror copies
-// the table back. The notes say who wrote them before the move.
-const STAGED_TABLES = {
- channel_moderators: 'Live /api/channels (channel-mod-routes) while at live',
- channel_moderation_settings: 'Live /api/channels, the dashboard, /slow and alert sounds while at live',
- emotes: 'Live /api/emotes and its Media asset-sync while at live',
- user_tags: 'no writer since Live’s game tags went read-only; data only',
- chat_ai_summaries: 'Live server/ai/chat-ai.js while at live',
- chat_timeline_events: 'Live server/ai/chat-ai.js while at live',
-};
-// Their primary keys (the mirror and Live's changes address rows by them).
-const STAGED_KEYS = {
- channel_moderators: ['id'],
- channel_moderation_settings: ['channel_id'],
- emotes: ['id'],
- user_tags: ['id'],
- chat_ai_summaries: ['id'],
- chat_timeline_events: ['id'],
-};
-
function getDb() {
function getDb() {
if (!db) {
if (!db) {
_dbPath = path.resolve(config.dbPath);
_dbPath = path.resolve(config.dbPath);
@@ -131,12 +108,9 @@ function initDb({ captureMirror = false } = {}) {
@@ -131,12 +108,9 @@ function initDb({ captureMirror = false } = {}) {
}
}
// auth/network-session.js resolves a verified Network token to its user by subject.
// auth/network-session.js resolves a verified Network token to its user by subject.
d.exec('CREATE INDEX IF NOT EXISTS idx_ctx_users_subject ON ctx_users(subject_id)');
d.exec('CREATE INDEX IF NOT EXISTS idx_ctx_users_subject ON ctx_users(subject_id)');
- const upsertAuth = d.prepare('INSERT INTO table_authority (table_name, authority, note) VALUES (?, ?, ?) ON CONFLICT(table_name) DO UPDATE SET authority = excluded.authority, note = excluded.note');
- for (const t of Object.keys(CHAT_TABLES)) upsertAuth.run(t, 'chat', 'Chat writes; Live keeps a read mirror');
- // A staged table keeps the authority it was handed (a restart never moves it back).
- const seedStaged = d.prepare("INSERT INTO table_authority (table_name, authority, note) VALUES (?, 'live', ?) ON CONFLICT(table_name) DO UPDATE SET note = excluded.note");
- for (const [t, note] of Object.entries(STAGED_TABLES)) seedStaged.run(t, note);
- _authority.at = 0;
+ // C-04 is done: Chat is the only writer of the six tables, so there is no authority left to
+ // record. The table's migration drops it from databases an earlier release created; a new
+ // database never gets it (schema.sql no longer has it).
if (captureMirror) installMirrorTriggers();
if (captureMirror) installMirrorTriggers();
return d;
return d;
}
}
@@ -145,9 +119,6 @@ function installMirrorTriggers() {
@@ -145,9 +119,6 @@ function installMirrorTriggers() {
const d = getDb();
const d = getDb();
const tables = [
const tables = [
...Object.entries(CHAT_TABLES).map(([table, pk]) => [table, pk, '']),
...Object.entries(CHAT_TABLES).map(([table, pk]) => [table, pk, '']),
- // A staged table is mirrored only while Chat writes it (read at each change, so a handoff
- // made by another process counts at once).
- ...Object.entries(STAGED_KEYS).map(([table, pk]) => [table, pk, `WHEN (SELECT authority FROM main.table_authority WHERE table_name = '${table}') = 'chat'`]),
];
];
for (const [table, pk, when] of tables) {
for (const [table, pk, when] of tables) {
const obj = (alias) => `json_object(${pk.map((c) => `'${c}', ${alias}.${c}`).join(', ')})`;
const obj = (alias) => `json_object(${pk.map((c) => `'${c}', ${alias}.${c}`).join(', ')})`;
@@ -993,53 +964,14 @@ function searchChannelChatMessages(channel, { query, userId, limit = 50, offset
@@ -993,53 +964,14 @@ function searchChannelChatMessages(channel, { query, userId, limit = 50, offset
};
};
}
}
-// ── Staged tables (roadmap C-04) ──────────────────────────────
-// Who writes each one is table_authority (STAGED_TABLES above). The reads are cached for a second:
-// a handoff made by another process (scripts/table-authority.js) counts within that.
-const _authority = { at: 0, map: new Map() };
-function tableAuthority(table) {
- if (Date.now() - _authority.at > 1000) {
- _authority.map = new Map(all('SELECT table_name, authority FROM table_authority').map((r) => [r.table_name, r.authority]));
- _authority.at = Date.now();
- }
- return _authority.map.get(table) || (CHAT_TABLES[table] ? 'chat' : 'live');
-}
-function setTableAuthority(table, authority) {
- if (!STAGED_KEYS[table]) throw new Error(`${table} is not a staged table`);
- if (authority !== 'live' && authority !== 'chat') throw new Error('authority is live or chat');
- run('UPDATE table_authority SET authority = ? WHERE table_name = ?', [authority, table]);
- _authority.at = 0;
- return tableAuthority(table);
-}
-/** { table: authority } for the staged tables. */
-function stagedAuthorities() {
- return Object.fromEntries(Object.keys(STAGED_KEYS).map((t) => [t, tableAuthority(t)]));
-}
-/** Changes of this table still queued for Live (the mirror drains them). */
-function mirrorPending(table) {
- return get('SELECT COUNT(*) AS n FROM live_mirror_outbox WHERE tbl = ?', [table])?.n || 0;
-}
-
-const _colCache = new Map();
-function _columns(table) {
- if (!_colCache.has(table)) _colCache.set(table, new Set(getDb().prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));
- return _colCache.get(table);
-}
-
-/** A staged write refuses while Live still writes the table: one writer at a time. */
-function _assertChatWrites(table) {
- if (tableAuthority(table) === 'chat') return;
- const err = new Error(`${table} is written by Live (table_authority live)`);
- err.code = 'table.not_chat';
- throw err;
-}
-
-/**
- * What a staged write returns over the bridge: `value` — what Live's function of the same name
- * returns (its callers keep working), and `mirror` — the rows as they are now, in the Live mirror's
- * change shape, so Live's copy is right at once (the mirror sends the same rows again later).
- */
-function _staged(value, table, rows = [], deletedPks = []) {
+// ── The six tables' writes (channel moderators, settings, emotes, tags, chat AI) ──
+// Chat has been the only writer of these six since the C-04 cutover, so there is no authority gate
+// any more. Until Live N+1 is deployed, Live's current release still calls these functions over the
+// bridge (op `db`) and reads both halves of the answer: `value` — what Live's function of the same
+// name returns (its callers keep working) — and `mirror` — the rows as they are now, in the Live
+// mirror's change shape, which Live applies to its own copy at once. Past Live N+1 nothing reads
+// either half; the callers here use `.value` only.
+function _writeResult(value, table, rows = [], deletedPks = []) {
const plainValue = value && typeof value === 'object' && 'changes' in value && 'lastInsertRowid' in value
const plainValue = value && typeof value === 'object' && 'changes' in value && 'lastInsertRowid' in value
? { changes: value.changes, lastInsertRowid: Number(value.lastInsertRowid) } : value;
? { changes: value.changes, lastInsertRowid: Number(value.lastInsertRowid) } : value;
return {
return {
@@ -1053,21 +985,19 @@ function _staged(value, table, rows = [], deletedPks = []) {
@@ -1053,21 +985,19 @@ function _staged(value, table, rows = [], deletedPks = []) {
// Channel moderators, settings and alert sounds (Live's /api/channels, the dashboard, /slow).
// Channel moderators, settings and alert sounds (Live's /api/channels, the dashboard, /slow).
function addChannelModerator(channelId, userId, addedBy) {
function addChannelModerator(channelId, userId, addedBy) {
- _assertChatWrites('channel_moderators');
const out = transaction(() => {
const out = transaction(() => {
const res = run('INSERT OR IGNORE INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)', [channelId, userId, addedBy]);
const res = run('INSERT OR IGNORE INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)', [channelId, userId, addedBy]);
- return _staged(res, 'channel_moderators', all('SELECT * FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]));
+ return _writeResult(res, 'channel_moderators', all('SELECT * FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]));
});
});
_ctx().invalidateChannel(channelId);
_ctx().invalidateChannel(channelId);
return out;
return out;
}
}
function removeChannelModerator(channelId, userId) {
function removeChannelModerator(channelId, userId) {
- _assertChatWrites('channel_moderators');
const out = transaction(() => {
const out = transaction(() => {
const gone = all('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);
const gone = all('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);
const res = run('DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);
const res = run('DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);
- return _staged(res, 'channel_moderators', [], gone.map((r) => ({ id: r.id })));
+ return _writeResult(res, 'channel_moderators', [], gone.map((r) => ({ id: r.id })));
});
});
_ctx().invalidateChannel(channelId);
_ctx().invalidateChannel(channelId);
return out;
return out;
@@ -1075,7 +1005,6 @@ function removeChannelModerator(channelId, userId) {
@@ -1075,7 +1005,6 @@ function removeChannelModerator(channelId, userId) {
// Live's upsertChannelModerationSettings, clamps and all.
// Live's upsertChannelModerationSettings, clamps and all.
function upsertChannelModerationSettings(channelId, fields) {
function upsertChannelModerationSettings(channelId, fields) {
- _assertChatWrites('channel_moderation_settings');
const out = transaction(() => {
const out = transaction(() => {
const existing = get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
const existing = get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
if (existing) {
if (existing) {
@@ -1179,7 +1108,7 @@ function upsertChannelModerationSettings(channelId, fields) {
@@ -1179,7 +1108,7 @@ function upsertChannelModerationSettings(channelId, fields) {
}
}
if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);
if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);
const row = get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
const row = get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
- return _staged(row, 'channel_moderation_settings', [row]);
+ return _writeResult(row, 'channel_moderation_settings', [row]);
});
});
_ctx().invalidateChannel(channelId);
_ctx().invalidateChannel(channelId);
return out;
return out;
@@ -1187,14 +1116,13 @@ function upsertChannelModerationSettings(channelId, fields) {
@@ -1187,14 +1116,13 @@ function upsertChannelModerationSettings(channelId, fields) {
// Donation / goal alert sounds live on the settings row (url = the file's path in the sounds dir).
// Donation / goal alert sounds live on the settings row (url = the file's path in the sounds dir).
function setChannelAlertSound(channelId, kind, url, mime) {
function setChannelAlertSound(channelId, kind, url, mime) {
- _assertChatWrites('channel_moderation_settings');
const out = transaction(() => {
const out = transaction(() => {
if (!get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId])) {
if (!get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId])) {
run('INSERT INTO channel_moderation_settings (channel_id) VALUES (?)', [channelId]);
run('INSERT INTO channel_moderation_settings (channel_id) VALUES (?)', [channelId]);
}
}
const col = kind === 'goal' ? 'goal_sound' : 'donation_sound';
const col = kind === 'goal' ? 'goal_sound' : 'donation_sound';
const res = run(`UPDATE channel_moderation_settings SET ${col}_url = ?, ${col}_mime = ? WHERE channel_id = ?`, [url || null, mime || null, channelId]);
const res = run(`UPDATE channel_moderation_settings SET ${col}_url = ?, ${col}_mime = ? WHERE channel_id = ?`, [url || null, mime || null, channelId]);
- return _staged(res, 'channel_moderation_settings', [get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId])]);
+ return _writeResult(res, 'channel_moderation_settings', [get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId])]);
});
});
_ctx().invalidateChannel(channelId);
_ctx().invalidateChannel(channelId);
return out;
return out;
@@ -1203,69 +1131,62 @@ function setChannelAlertSound(channelId, kind, url, mime) {
@@ -1203,69 +1131,62 @@ function setChannelAlertSound(channelId, kind, url, mime) {
// Emotes (Live's /api/emotes; media_url/media_asset_id are OpenVibe.Media's copy — Live's
// Emotes (Live's /api/emotes; media_url/media_asset_id are OpenVibe.Media's copy — Live's
// asset-sync filled them onto the shared rows, Chat uploads through its own Media token now).
// asset-sync filled them onto the shared rows, Chat uploads through its own Media token now).
function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100, media_url = null, media_asset_id = null }) {
function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100, media_url = null, media_asset_id = null }) {
- _assertChatWrites('emotes');
return transaction(() => {
return transaction(() => {
const res = run(
const res = run(
`INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size, media_url, media_asset_id)
`INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size, media_url, media_asset_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100)), media_url || null, media_asset_id || null]
[user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100)), media_url || null, media_asset_id || null]
);
);
- return _staged(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);
+ return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);
});
});
}
}
function updateEmote(id, { code, size } = {}) {
function updateEmote(id, { code, size } = {}) {
- _assertChatWrites('emotes');
const sets = [];
const sets = [];
const params = [];
const params = [];
if (code !== undefined) { sets.push('code = ?'); params.push(code); }
if (code !== undefined) { sets.push('code = ?'); params.push(code); }
if (size !== undefined) { sets.push('size = ?'); params.push(Math.min(400, Math.max(25, parseInt(size, 10) || 100))); }
if (size !== undefined) { sets.push('size = ?'); params.push(Math.min(400, Math.max(25, parseInt(size, 10) || 100))); }
- if (!sets.length) return _staged({ changes: 0 }, 'emotes');
+ if (!sets.length) return _writeResult({ changes: 0 }, 'emotes');
return transaction(() => {
return transaction(() => {
const res = run(`UPDATE emotes SET ${sets.join(', ')} WHERE id = ?`, [...params, id]);
const res = run(`UPDATE emotes SET ${sets.join(', ')} WHERE id = ?`, [...params, id]);
- return _staged(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);
+ return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);
});
});
}
}
function deleteEmote(id) {
function deleteEmote(id) {
- _assertChatWrites('emotes');
return transaction(() => {
return transaction(() => {
const had = get('SELECT id FROM emotes WHERE id = ?', [id]);
const had = get('SELECT id FROM emotes WHERE id = ?', [id]);
const res = run('DELETE FROM emotes WHERE id = ?', [id]);
const res = run('DELETE FROM emotes WHERE id = ?', [id]);
- return _staged(res, 'emotes', [], had ? [{ id: had.id }] : []);
+ return _writeResult(res, 'emotes', [], had ? [{ id: had.id }] : []);
});
});
}
}
/** The emote's copy on OpenVibe.Media (Live's asset-sync). */
/** The emote's copy on OpenVibe.Media (Live's asset-sync). */
function setEmoteMedia(id, mediaUrl, mediaAssetId) {
function setEmoteMedia(id, mediaUrl, mediaAssetId) {
- _assertChatWrites('emotes');
return transaction(() => {
return transaction(() => {
const res = run('UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?', [mediaUrl || null, mediaAssetId || null, id]);
const res = run('UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?', [mediaUrl || null, mediaAssetId || null, id]);
- return _staged(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);
+ return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);
});
});
}
}
// User tags (owned chat tags; Live has had no writer since its game tags went read-only).
// User tags (owned chat tags; Live has had no writer since its game tags went read-only).
function grantUserTag(userId, tagId, source = 'shop') {
function grantUserTag(userId, tagId, source = 'shop') {
- _assertChatWrites('user_tags');
return transaction(() => {
return transaction(() => {
const res = run('INSERT OR IGNORE INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)', [userId, String(tagId), source || 'shop']);
const res = run('INSERT OR IGNORE INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)', [userId, String(tagId), source || 'shop']);
- return _staged(res, 'user_tags', [get('SELECT * FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)])]);
+ return _writeResult(res, 'user_tags', [get('SELECT * FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)])]);
});
});
}
}
function revokeUserTag(userId, tagId) {
function revokeUserTag(userId, tagId) {
- _assertChatWrites('user_tags');
return transaction(() => {
return transaction(() => {
const gone = all('SELECT id FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);
const gone = all('SELECT id FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);
const res = run('DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);
const res = run('DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);
- return _staged(res, 'user_tags', [], gone.map((r) => ({ id: r.id })));
+ return _writeResult(res, 'user_tags', [], gone.map((r) => ({ id: r.id })));
});
});
}
}
// Chat AI (Live's server/ai/chat-ai.js): rolling summaries and the append-only timeline.
// Chat AI (Live's server/ai/chat-ai.js): rolling summaries and the append-only timeline.
function upsertChatAiSummary(sfx) {
function upsertChatAiSummary(sfx) {
- _assertChatWrites('chat_ai_summaries');
const {
const {
scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',
scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',
message_count = 0, window_message_count = 0, last_message_id = 0,
message_count = 0, window_message_count = 0, last_message_id = 0,
@@ -1291,13 +1212,12 @@ function upsertChatAiSummary(sfx) {
@@ -1291,13 +1212,12 @@ function upsertChatAiSummary(sfx) {
[scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,
[scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,
window_message_count, last_message_id, window_label, window_start, window_end]
window_message_count, last_message_id, window_label, window_start, window_end]
);
);
- return _staged(res, 'chat_ai_summaries', [get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?', [scope, subject_id || 0, window])]);
+ return _writeResult(res, 'chat_ai_summaries', [get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?', [scope, subject_id || 0, window])]);
});
});
}
}
function addChatTimelineEvents(scope, subjectId, events) {
function addChatTimelineEvents(scope, subjectId, events) {
- _assertChatWrites('chat_timeline_events');
- if (!Array.isArray(events) || !events.length) return _staged(0, 'chat_timeline_events');
+ if (!Array.isArray(events) || !events.length) return _writeResult(0, 'chat_timeline_events');
return transaction(() => {
return transaction(() => {
let n = 0;
let n = 0;
const ids = [];
const ids = [];
@@ -1310,7 +1230,7 @@ function addChatTimelineEvents(scope, subjectId, events) {
@@ -1310,7 +1230,7 @@ function addChatTimelineEvents(scope, subjectId, events) {
n++; // Live counts every event it tried, the duplicates included
n++; // Live counts every event it tried, the duplicates included
} catch { /* */ }
} catch { /* */ }
}
}
- return _staged(n, 'chat_timeline_events', ids.map((id) => get('SELECT * FROM chat_timeline_events WHERE id = ?', [id])));
+ return _writeResult(n, 'chat_timeline_events', ids.map((id) => get('SELECT * FROM chat_timeline_events WHERE id = ?', [id])));
});
});
}
}
@@ -1461,75 +1381,12 @@ function getAnonsNeedingChatAi({ threshold = 12, staleCutoffIso, sinceTs, limit
@@ -1461,75 +1381,12 @@ function getAnonsNeedingChatAi({ threshold = 12, staleCutoffIso, sinceTs, limit
return all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);
return all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);
}
}
-/**
- * Live's changes to a staged table it still writes (its capture, relayed over the bridge): the rows
- * as they are in Live now, or a delete. Applied only while the table is at 'live' — once Chat writes
- * it, Live's copy is the mirror and never flows back. REPLACE: the authority's row wins over
- * whatever holds its key or one of its unique columns here.
- */
-function applyStagedChanges(changes) {
- const out = { applied: 0, skipped: [] };
- transaction(() => {
- for (const c of Array.isArray(changes) ? changes : []) {
- const pk = STAGED_KEYS[c && c.table];
- if (!pk) { out.skipped.push({ table: c && c.table, reason: 'not a staged table' }); continue; }
- if (tableAuthority(c.table) !== 'live') { out.skipped.push({ table: c.table, reason: 'Chat writes this table (table_authority chat)' }); continue; }
- const have = _columns(c.table);
- try {
- if (c.op === 'delete' && c.pk) {
- run(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`, pk.map((k) => c.pk[k]));
- } else if (c.op === 'upsert' && c.row && pk.every((k) => c.row[k] != null)) {
- const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));
- run(`INSERT OR REPLACE INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, cols.map((k) => c.row[k]));
- } else { out.skipped.push({ table: c.table, reason: 'bad change' }); continue; }
- out.applied++;
- } catch (err) {
- out.skipped.push({ table: c.table, pk: c.pk || (c.row && Object.fromEntries(pk.map((k) => [k, c.row[k]]))), reason: err.message });
- }
- }
- });
- for (const c of Array.isArray(changes) ? changes : []) {
- if (c && (c.table === 'channel_moderators' || c.table === 'channel_moderation_settings')) {
- const ch = (c.row && c.row.channel_id) || (c.pk && c.pk.channel_id);
- if (ch) _ctx().invalidateChannel(ch);
- }
- }
- return out;
-}
-
-/**
- * Hash of a list of rows over `columns` (Live's server/chat/chat-tables-sync.js computes the same):
- * sha256 of the JSON array of rows, each row the array of its values in column order.
- */
-function sliceHash(columns, rows) {
- const body = JSON.stringify(rows.map((r) => columns.map((c) => (r[c] === undefined ? null : r[c]))));
- return require('crypto').createHash('sha256').update(body).digest('hex');
-}
-
-/**
- * One slice of a staged table for Live's dual read: the rows whose columns equal `where` (null-safe),
- * ordered by key → { count, hash, columns, rows (when 50 or fewer) }. `columns` limits the hash to
- * the ones Live has too.
- */
-function stagedSlice(table, where = {}, columns = null) {
- const pk = STAGED_KEYS[table];
- if (!pk) throw new Error(`${table} is not a staged table`);
- const have = _columns(table);
- const keys = Object.keys(where || {});
- if (keys.some((k) => !have.has(k))) throw new Error(`unknown column in ${table}`);
- const cols = (Array.isArray(columns) && columns.length ? columns.filter((c) => have.has(c)) : [...have]).sort();
- const rows = all(`SELECT ${cols.join(', ')} FROM ${table} WHERE ${keys.map((k) => `${k} IS ?`).join(' AND ') || '1'} ORDER BY ${pk.join(', ')}`, keys.map((k) => where[k]));
- return { count: rows.length, hash: sliceHash(cols, rows), columns: cols, rows: rows.length <= 50 ? rows : undefined };
-}
-
// ── Meta ─────────────────────────────────────────────────────
// ── Meta ─────────────────────────────────────────────────────
function getMeta(key) { return get('SELECT value FROM chat_meta WHERE key = ?', [key])?.value ?? null; }
function getMeta(key) { return get('SELECT value FROM chat_meta WHERE key = ?', [key])?.value ?? null; }
function setMeta(key, value) { return run('INSERT INTO chat_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value == null ? null : String(value)]); }
function setMeta(key, value) { return run('INSERT INTO chat_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value == null ? null : String(value)]); }
module.exports = {
module.exports = {
CHAT_TABLES,
CHAT_TABLES,
- STAGED_TABLES,
- STAGED_KEYS,
getDb,
getDb,
initDb,
initDb,
installMirrorTriggers,
installMirrorTriggers,
@@ -1605,14 +1462,7 @@ module.exports = {
@@ -1605,14 +1462,7 @@ module.exports = {
getEmotesByUser,
getEmotesByUser,
getModerationActions,
getModerationActions,
searchChannelChatMessages,
searchChannelChatMessages,
- // staged tables (C-04): authority, Live's changes, dual-read slices, and the writes once Chat owns them
- tableAuthority,
- setTableAuthority,
- stagedAuthorities,
- mirrorPending,
- applyStagedChanges,
- stagedSlice,
- sliceHash,
+ // The six tables Chat owns (C-04 done): moderators, settings, emotes, tags, chat AI
addChannelModerator,
addChannelModerator,
removeChannelModerator,
removeChannelModerator,
upsertChannelModerationSettings,
upsertChannelModerationSettings,
@@ -8,10 +8,9 @@
@@ -8,10 +8,9 @@
-- sound keeps the id it had in Live. Rows still carry Live's user ids; *subject_id
-- sound keeps the id it had in Live. Rows still carry Live's user ids; *subject_id
-- columns add the OpenVibe.Network subject (usr_…) where it is known, so later waves can
-- columns add the OpenVibe.Network subject (usr_…) where it is known, so later waves can
-- drop Live ids.
-- drop Live ids.
+-- 2. The six chat-target tables — channel moderators and settings, emotes, user tags,
+-- chat-AI summaries and timeline. Since the C-04 cutover Chat is their only writer;
+-- there is no authority switch any more (Live's N+1 reads them through Chat's API).
-- 3. Bookkeeping: projections of Live data (ctx_*), the events outbox, the Live mirror
-- 3. Bookkeeping: projections of Live data (ctx_*), the events outbox, the Live mirror
-- outbox, import holds.
-- outbox, import holds.
--
--
@@ -185,7 +184,15 @@ CREATE INDEX IF NOT EXISTS idx_mod_actions_created ON moderation_actions(created
@@ -185,7 +184,15 @@ CREATE INDEX IF NOT EXISTS idx_mod_actions_created ON moderation_actions(created
CREATE INDEX IF NOT EXISTS idx_mod_actions_actor ON moderation_actions(actor_user_id);
CREATE INDEX IF NOT EXISTS idx_mod_actions_actor ON moderation_actions(actor_user_id);
CREATE INDEX IF NOT EXISTS idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);
CREATE INDEX IF NOT EXISTS idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);
+-- Kept one release for N-1 (ADR-016): the previous release reads it, and its rows say 'chat' for all six, so a
+-- rollback still leaves Chat the writer. Nothing in this release reads or writes it; the next release drops it.
+CREATE TABLE IF NOT EXISTS table_authority (
+ table_name TEXT PRIMARY KEY,
+ authority TEXT NOT NULL CHECK(authority IN ('chat', 'live')),
+ note TEXT
+);
+
+-- ── 2. The six chat-target tables (Chat is their only writer since C-04) ──
CREATE TABLE IF NOT EXISTS channel_moderators (
CREATE TABLE IF NOT EXISTS channel_moderators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -304,13 +311,6 @@ CREATE TABLE IF NOT EXISTS chat_timeline_events (
@@ -304,13 +311,6 @@ CREATE TABLE IF NOT EXISTS chat_timeline_events (
);
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label);
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label);
-CREATE TABLE IF NOT EXISTS table_authority (
- table_name TEXT PRIMARY KEY,
- authority TEXT NOT NULL CHECK(authority IN ('chat', 'live')),
- note TEXT
-);
-
-- ── 3. Bookkeeping ──────────────────────────────────────────────
-- ── 3. Bookkeeping ──────────────────────────────────────────────
-- Projections of Live data, maintained by server/live-context.js only. Never authority.
-- Projections of Live data, maintained by server/live-context.js only. Never authority.
@@ -47,8 +47,8 @@ async function start() {
@@ -47,8 +47,8 @@ async function start() {
mirror.start();
mirror.start();
relay.start();
relay.start();
events.start();
events.start();
- // The chat-AI job (Live's server/ai/chat-ai.js, now Chat's): off unless CHAT_AI_ENABLED=1, and
- // its writes skip with one log line while Live is still the two summaries tables' authority.
+ // The chat-AI job (Live's server/ai/chat-ai.js, now Chat's): off unless CHAT_AI_ENABLED=1; it
+ // writes Chat's own chat_ai_summaries/chat_timeline_events rows directly (C-04 done).
chatAi.start();
chatAi.start();
await new Promise((resolve) => server.listen(config.port, config.host, resolve));
await new Promise((resolve) => server.listen(config.port, config.host, resolve));
@@ -354,10 +354,10 @@ async function setChannelEmoteSources(userId, sources) {
@@ -354,10 +354,10 @@ async function setChannelEmoteSources(userId, sources) {
return clean;
return clean;
}
}
-// ── Channel policy: moderation settings, moderators, language, alert sounds ──────────────────
+// ── Channel policy: language (Live), moderation settings, moderators, alert sounds (Chat) ────
-// Everyone who wants to know when a channel's settings were read anew (the chat server announces a
-// slow mode or sub-only mode the dashboard changed). Called with every fresh read; the listener
+// Everyone who wants to know when a channel's settings changed (the chat server announces a slow
+// mode or sub-only mode the dashboard changed). Called with every fresh local read; the listener
// compares with what it last saw.
// compares with what it last saw.
const _settingsListeners = new Set();
const _settingsListeners = new Set();
function onChannelSettings(fn) { _settingsListeners.add(fn); return () => _settingsListeners.delete(fn); }
function onChannelSettings(fn) { _settingsListeners.add(fn); return () => _settingsListeners.delete(fn); }
@@ -367,34 +367,23 @@ function _emitSettings(channelId, settings) {
@@ -367,34 +367,23 @@ function _emitSettings(channelId, settings) {
}
}
}
}
+// Live's answer carries the channel projection and its language (the part Live still owns). The
+// moderation settings and moderator ids are Chat's own rows now, read in place.
const _policy = new Swr(TTL.policy, async (channelId) => {
const _policy = new Swr(TTL.policy, async (channelId) => {
const data = await read(`/channels/${channelId}/policy`);
const data = await read(`/channels/${channelId}/policy`);
if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
- // While Chat writes the settings itself, Live's copy is a mirror that may lag: not announced.
- if (!_chatWrites('channel_moderation_settings')) _emitSettings(channelId, data.settings || defaultModerationSettings(channelId));
- return {
- settings: data.settings || null,
- moderators: new Set((data.moderator_ids || []).map(Number)),
- language: data.language || 'en',
- };
+ // The moderation settings are Chat's own rows: a fresh channel read refreshes the listeners'
+ // baseline (the chat server records what the room was last told; the first read announces nothing).
+ _emitSettings(channelId, getChannelModerationSettings(channelId));
+ return { language: data.language || 'en' };
});
});
function _policyFor(channelId) { return channelId ? _policy.peek(Number(channelId)) : undefined; }
function _policyFor(channelId) { return channelId ? _policy.peek(Number(channelId)) : undefined; }
-// Once Chat writes a staged table (table_authority 'chat', roadmap C-04) its rows here are the truth:
-// read in place, like Live's own readers (an indexed row, no network, no cache to go stale).
-const _chatWrites = (table) => db.tableAuthority(table) === 'chat';
+// Chat is the only writer of these tables (C-04 done): an indexed local row, no network, no cache.
function getChannelModerationSettings(channelId) {
function getChannelModerationSettings(channelId) {
- if (_chatWrites('channel_moderation_settings')) {
- return (channelId && db.get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)])) || defaultModerationSettings(channelId);
- }
- const p = _policyFor(channelId);
- return (p && p.settings) || defaultModerationSettings(channelId);
+ return (channelId && db.get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)])) || defaultModerationSettings(channelId);
}
}
function isChannelModerator(userId, channelId) {
function isChannelModerator(userId, channelId) {
- if (_chatWrites('channel_moderators')) {
- return !!(userId && channelId && db.get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [Number(userId), Number(channelId)]));
- }
- const p = _policyFor(channelId);
- return !!(p && p.moderators.has(Number(userId)));
+ return !!(userId && channelId && db.get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [Number(userId), Number(channelId)]));
}
}
function channelLanguage(channelUserId) {
function channelLanguage(channelUserId) {
const ch = getChannelByUserId(channelUserId);
const ch = getChannelByUserId(channelUserId);
@@ -413,7 +402,7 @@ function ensurePolicy(channelId) { return channelId ? _policy.ensure(Number(chan
@@ -413,7 +402,7 @@ function ensurePolicy(channelId) { return channelId ? _policy.ensure(Number(chan
function invalidateChannel(channelId) {
function invalidateChannel(channelId) {
if (!channelId) return;
if (!channelId) return;
_policy.invalidate(Number(channelId));
_policy.invalidate(Number(channelId));
- if (_chatWrites('channel_moderation_settings')) _emitSettings(channelId, getChannelModerationSettings(channelId));
+ _emitSettings(channelId, getChannelModerationSettings(channelId));
}
}
/**
/**
* A fresh policy read that starts after the call (Chat just had Live write a setting): a load already
* A fresh policy read that starts after the call (Chat just had Live write a setting): a load already
@@ -742,8 +731,7 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {
@@ -742,8 +731,7 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {
if (ownerId && (!channel || channel.id !== ownerId)) tasks.push(ensurePolicy(ownerId));
if (ownerId && (!channel || channel.id !== ownerId)) tasks.push(ensurePolicy(ownerId));
if (user && user.id) { tasks.push(_follows.ensure(Number(user.id))); tasks.push(ensureDecor([user.id])); if (ip) tasks.push(ensureAnonFirstSeen(ip)); }
if (user && user.id) { tasks.push(_follows.ensure(Number(user.id))); tasks.push(ensureDecor([user.id])); if (ip) tasks.push(ensureAnonFirstSeen(ip)); }
await Promise.all(tasks.map((t) => Promise.resolve(t).catch(() => {})));
await Promise.all(tasks.map((t) => Promise.resolve(t).catch(() => {})));
- const policy = channel ? _policyFor(channel.id) : null;
- if (policy && policy.settings && policy.settings.ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});
+ if (channel && getChannelModerationSettings(channel.id).ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});
// Sub-only room: the viewer's subscription answer, so their first line does not wait for it.
// Sub-only room: the viewer's subscription answer, so their first line does not wait for it.
if (channel && user && user.id && ownerId && Number(ownerId) !== Number(user.id) && getChannelModerationSettings(channel.id).sub_only) {
if (channel && user && user.id && ownerId && Number(ownerId) !== Number(user.id) && getChannelModerationSettings(channel.id).sub_only) {
await ensureSubscriber(user.id, ownerId).catch(() => {});
await ensureSubscriber(user.id, ownerId).catch(() => {});
@@ -897,26 +885,19 @@ const effects = {
@@ -897,26 +885,19 @@ const effects = {
setUserColor: (userId, color) => effect('user-color', { user_id: userId, color }).then((r) => { db.run('UPDATE ctx_users SET profile_color = ? WHERE id = ?', [color, userId]); return r; }),
setUserColor: (userId, color) => effect('user-color', { user_id: userId, color }).then((r) => { db.run('UPDATE ctx_users SET profile_color = ? WHERE id = ?', [color, userId]); return r; }),
// The ban is in Chat's cache before the moderator is answered (and before their next check).
// The ban is in Chat's cache before the moderator is answered (and before their next check).
ban: (body) => effect('ban', body).then((r) => invalidateBans().then(() => r)),
ban: (body) => effect('ban', body).then((r) => invalidateBans().then(() => r)),
- // /slow and alert sounds: written by Live while it owns channel_moderation_settings, here once Chat
- // does (C-04; the callers already checked the moderator / the channel owner, as Live re-checks).
- // The saved value is what chat enforces: at 'live' the policy is read again (awaited) before the
- // caller answers, so the next line already follows it. Chat sets slow_mode_seconds and sub_only.
+ // /slow and alert sounds: Chat writes channel_moderation_settings itself (C-04 done; the callers
+ // already checked the moderator / the channel owner). Chat sets slow_mode_seconds and sub_only.
updateChannelModerationSettings: (channelId, fields, actorUserId) => {
updateChannelModerationSettings: (channelId, fields, actorUserId) => {
const f = {};
const f = {};
if (fields && fields.slow_mode_seconds !== undefined) f.slow_mode_seconds = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);
if (fields && fields.slow_mode_seconds !== undefined) f.slow_mode_seconds = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);
if (fields && fields.sub_only !== undefined) f.sub_only = fields.sub_only ? 1 : 0;
if (fields && fields.sub_only !== undefined) f.sub_only = fields.sub_only ? 1 : 0;
if (!channelId || !Object.keys(f).length) return Promise.reject(new LiveError(400, 'no chat-settable fields'));
if (!channelId || !Object.keys(f).length) return Promise.reject(new LiveError(400, 'no chat-settable fields'));
- if (!_chatWrites('channel_moderation_settings')) {
- return effect('channel-settings', { channel_id: channelId, fields: f, actor_user_id: actorUserId })
- .then((r) => reloadPolicy(channelId).catch(() => {}).then(() => r));
- }
return Promise.resolve().then(() => {
return Promise.resolve().then(() => {
db.upsertChannelModerationSettings(Number(channelId), f);
db.upsertChannelModerationSettings(Number(channelId), f);
return { ok: true };
return { ok: true };
});
});
},
},
setChannelAlertSound: (channelId, kind, url, mime, actorUserId) => {
setChannelAlertSound: (channelId, kind, url, mime, actorUserId) => {
- if (!_chatWrites('channel_moderation_settings')) return effect('alert-sound', { channel_id: channelId, kind, url, mime, actor_user_id: actorUserId }).then((r) => { invalidateChannel(channelId); return r; });
return Promise.resolve().then(() => {
return Promise.resolve().then(() => {
const file = url ? require('path').resolve(String(url)) : null;
const file = url ? require('path').resolve(String(url)) : null;
if (file) {
if (file) {
@@ -4,9 +4,9 @@
@@ -4,9 +4,9 @@
* - network.account.export_requested sends Chat's part to Network: their messages, DMs they sent, conversations,
* - network.account.export_requested sends Chat's part to Network: their messages, DMs they sent, conversations,
* blocks, and moderation taken on them without who acted.
* blocks, and moderation taken on them without who acted.
* - network.account.deleted erases what the subject and its merged-in aliases wrote or set up: chat, room and DM
* - network.account.deleted erases what the subject and its merged-in aliases wrote or set up: chat, room and DM
- * messages, DM memberships (a conversation left empty goes), blocks both ways, the channel's first-chat stats, and
- * the Live mirrors (the user mirror as a tombstone). Moderation history stays. A staged table is left to Live
- * while Live writes it.
+ * messages, DM memberships (a conversation left empty goes), blocks both ways, the channel's first-chat stats, their
+ * tags, and the Live mirrors (the user mirror as a tombstone). Moderation history stays. The six chat tables are
+ * Chat's (C-04 done) and are erased here.
* Chat confirms with counts; a failed confirmation is retried without erasing again, and a redelivery sends nothing
* Chat confirms with counts; a failed confirmation is retried without erasing again, and a redelivery sends nothing
* twice. Chat subscribes to both.
* twice. Chat subscribes to both.
*/
*/
@@ -67,12 +67,12 @@ t('export, then erase and confirm', async () => {
@@ -67,12 +67,12 @@ t('export, then erase and confirm', async () => {
assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ?', pair).map((p) => p.user_id), [otto.id]);
assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ?', pair).map((p) => p.user_id), [otto.id]);
assert.strictEqual(q('SELECT * FROM network_blocks').length, 0, 'blocks both ways');
assert.strictEqual(q('SELECT * FROM network_blocks').length, 0, 'blocks both ways');
assert.strictEqual(q('SELECT * FROM moderation_actions').length, 1, 'moderation history stays');
assert.strictEqual(q('SELECT * FROM moderation_actions').length, 1, 'moderation history stays');
- assert.strictEqual(q('SELECT * FROM user_tags').length, 1, 'a staged table Live still writes is left to Live');
+ assert.strictEqual(q('SELECT * FROM user_tags').length, 0, 'the tags table is Chat’s and goes');
assert.strictEqual(d.prepare('SELECT username FROM ctx_users WHERE id = ?').get(dana.id).username, `deleted-${dana.id}`);
assert.strictEqual(d.prepare('SELECT username FROM ctx_users WHERE id = ?').get(dana.id).username, `deleted-${dana.id}`);
assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');
assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');
const conf = sent[1];
const conf = sent[1];
assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
- assert.deepStrictEqual([conf.body.erased.messages, conf.body.erased.direct_messages, conf.body.retained.moderation_actions, conf.body.retained.erased_by_live], [2, 1, 1, 1]);
+ assert.deepStrictEqual([conf.body.erased.messages, conf.body.erased.direct_messages, conf.body.erased.tags, conf.body.retained.moderation_actions], [2, 1, 1, 1]);
assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');
assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');
assert.strictEqual(sent.length, 2);
assert.strictEqual(sent.length, 2);
});
});
@@ -68,6 +68,7 @@ t('boot', async () => {
@@ -68,6 +68,7 @@ t('boot', async () => {
listenerUser = h.addUser('listener');
listenerUser = h.addUser('listener');
chatters = ['ann', 'ben', 'cat', 'dan'].map((n) => h.addUser(n));
chatters = ['ann', 'ben', 'cat', 'dan'].map((n) => h.addUser(n));
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
+ h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)
streamId = h.addStream(streamer.id, channelId);
streamId = h.addStream(streamer.id, channelId);
await h.ctx.sync();
await h.ctx.sync();
listener = await joined(listenerUser, '198.51.100.90');
listener = await joined(listenerUser, '198.51.100.90');
@@ -190,13 +190,11 @@ t('deploy notices: stored as one rolling global row, shown to everyone, folded o
@@ -190,13 +190,11 @@ t('deploy notices: stored as one rolling global row, shown to everyone, folded o
late.close();
late.close();
});
});
-t('Live’s own writes to data Chat caches invalidate it at once (invalidate op)', async () => {
- await h.ctx.ensurePolicy(channelId);
+t('the invalidate op is accepted for a channel and refuses an unknown kind', async () => {
assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), false);
assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), false);
- h.live.policies.get(channelId).moderator_ids = [viewer.id]; // added on Live's dashboard
+ h.db.addChannelModerator(channelId, viewer.id, streamer.id); // the moderator row is Chat's own (C-04)
const r = await calls([{ op: 'invalidate', args: ['channel', channelId] }, { op: 'invalidate', args: ['nonsense', 1] }]);
const r = await calls([{ op: 'invalidate', args: ['channel', channelId] }, { op: 'invalidate', args: ['nonsense', 1] }]);
assert.deepStrictEqual(r.body.results.map((x) => x.ok), [true, false]);
assert.deepStrictEqual(r.body.results.map((x) => x.ok), [true, false]);
- await h.sleep(150);
assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), true);
assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), true);
});
});
@@ -2,7 +2,6 @@
@@ -2,7 +2,6 @@
/**
/**
* Plan T3 step 2 — the chat-AI job and its routes, moved into Chat (decision 5):
* Plan T3 step 2 — the chat-AI job and its routes, moved into Chat (decision 5):
* - the job's selection and write path over Chat's own messages (stub AI client)
* - the job's selection and write path over Chat's own messages (stub AI client)
- * - the authority-not-flipped skip (one log line, never forced)
* - the extractive fallback when AI does not answer
* - the extractive fallback when AI does not answer
* - each of the six routes' shape, visibility and error codes
* - each of the six routes' shape, visibility and error codes
* - the job's switch (off unless CHAT_AI_ENABLED)
* - the job's switch (off unless CHAT_AI_ENABLED)
@@ -22,7 +21,6 @@ function msg(o = {}) {
@@ -22,7 +21,6 @@ function msg(o = {}) {
o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow()]
o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow()]
).lastInsertRowid;
).lastInsertRowid;
}
}
-const asChat = () => { db.setTableAuthority('chat_ai_summaries', 'chat'); db.setTableAuthority('chat_timeline_events', 'chat'); };
t('boot', async () => {
t('boot', async () => {
h = await boot({ env: { CHAT_AI_ENABLED: '1' } });
h = await boot({ env: { CHAT_AI_ENABLED: '1' } });
@@ -36,10 +34,9 @@ t('boot', async () => {
@@ -36,10 +34,9 @@ t('boot', async () => {
channelId = h.addChannel(streamer.id);
channelId = h.addChannel(streamer.id);
streamId = h.addStream(streamer.id, channelId);
streamId = h.addStream(streamer.id, channelId);
await h.ctx.sync();
await h.ctx.sync();
- asChat();
});
});
-t('the job selects Chat messages and writes the summary through the staged writers', async () => {
+t('the job selects Chat messages and writes the summary', async () => {
const calls = [];
const calls = [];
aiClient._setClient({
aiClient._setClient({
runs: {
runs: {
@@ -72,18 +69,6 @@ t('the job selects Chat messages and writes the summary through the staged write
@@ -72,18 +69,6 @@ t('the job selects Chat messages and writes the summary through the staged write
assert.ok(events.some((e) => e.label === 'a moment'), 'the timeline event was appended');
assert.ok(events.some((e) => e.label === 'a moment'), 'the timeline event was appended');
});
});
-t('a write while Live is still the authority is skipped, never forced', async () => {
- db.setTableAuthority('chat_ai_summaries', 'live');
- db.setTableAuthority('chat_timeline_events', 'live');
- const before = db.getChatAiSummary('global', 0, 'global');
- msg({ user_id: alice.id, username: 'alice', message: 'while Live owns the table' });
- await chatAi._tick(); // logs once, returns; no AI call, no write
- const after = db.getChatAiSummary('global', 0, 'global');
- assert.strictEqual(after.overview, before.overview);
- assert.strictEqual(after.message_count, before.message_count);
- asChat();
-});
-
t('falls back to the extractive summary when AI does not answer', async () => {
t('falls back to the extractive summary when AI does not answer', async () => {
aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed' } }) } });
aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed' } }) } });
db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-40 * 60 * 1000)]);
db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-40 * 60 * 1000)]);
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
'use strict';
'use strict';
/**
/**
- * Plan T3 step 1 — the APIs Chat serves for the six staged tables:
+ * Plan T3 step 1 — the APIs Chat serves for the six chat tables:
* - Live's internal read API (/internal/moderation/*, capability chat.moderation.read)
* - Live's internal read API (/internal/moderation/*, capability chat.moderation.read)
* - the public emote API (/api/emotes, Live's paths and shapes; bytes on OpenVibe.Media)
* - the public emote API (/api/emotes, Live's paths and shapes; bytes on OpenVibe.Media)
* - channel moderators & moderation settings (/api/chat/channels/:id/…)
* - channel moderators & moderation settings (/api/chat/channels/:id/…)
* - the bridge alert-sound op (Live says "play the alert", Chat resolves the sound)
* - the bridge alert-sound op (Live says "play the alert", Chat resolves the sound)
- * The authority-not-flipped refusal (503 { ok:false, error:'not yet' }) is checked for every writer.
+ * Chat is the only writer of the six tables (C-04 done), so the writes never refuse.
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -15,7 +15,6 @@ const { boot, suite } = require('./helpers');
@@ -15,7 +15,6 @@ const { boot, suite } = require('./helpers');
const t = suite('chat-apis');
const t = suite('chat-apis');
let h, RO, BRIDGE, streamer, other, admin, mod, stranger, channelId, streamId;
let h, RO, BRIDGE, streamer, other, admin, mod, stranger, channelId, streamId;
-const STAGED_TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes'];
const deletedAssets = [];
const deletedAssets = [];
let assets = { upload: null, delete: null };
let assets = { upload: null, delete: null };
@@ -23,7 +22,6 @@ function checkContract(ref, body) {
@@ -23,7 +22,6 @@ function checkContract(ref, body) {
const r = validate(ref, body);
const r = validate(ref, body);
assert.ok(r.valid, `${ref} invalid: ${JSON.stringify(r.errors)} (${JSON.stringify(body)})`);
assert.ok(r.valid, `${ref} invalid: ${JSON.stringify(r.errors)} (${JSON.stringify(body)})`);
}
}
-const asChat = (table) => h.db.setTableAuthority(table, 'chat');
t('boot', async () => {
t('boot', async () => {
// 3 emotes per channel, so both the clash check and the cap can be exercised.
// 3 emotes per channel, so both the clash check and the cap can be exercised.
@@ -72,7 +70,6 @@ t('internal read API: settings + moderator_ids validate, defaults for a channel
@@ -72,7 +70,6 @@ t('internal read API: settings + moderator_ids validate, defaults for a channel
});
});
t('internal read API: moderator_ids oldest first; the channels a user moderates validate', async () => {
t('internal read API: moderator_ids oldest first; the channels a user moderates validate', async () => {
- asChat('channel_moderators');
h.db.addChannelModerator(channelId, mod.id, streamer.id);
h.db.addChannelModerator(channelId, mod.id, streamer.id);
h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-01-01 00:00:00', mod.id]);
h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-01-01 00:00:00', mod.id]);
h.db.addChannelModerator(channelId, stranger.id, streamer.id);
h.db.addChannelModerator(channelId, stranger.id, streamer.id);
@@ -111,15 +108,7 @@ async function upload(userId, token, fields = {}, bytes = PNG, type = 'image/png
@@ -111,15 +108,7 @@ async function upload(userId, token, fields = {}, bytes = PNG, type = 'image/png
return { status: res.status, body: await res.json().catch(() => null) };
return { status: res.status, body: await res.json().catch(() => null) };
}
}
-t('emote API: the authority-not-flipped refusal (503 not yet, nothing written)', async () => {
- const r = await upload(streamer.id, streamer.token, { code: 'refused' });
- assert.strictEqual(r.status, 503, JSON.stringify(r.body));
- assert.deepStrictEqual(r.body, { ok: false, error: 'not yet' });
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM emotes').n, 0);
-});
-
t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap', async () => {
t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap', async () => {
- asChat('emotes');
// Public list shapes need no auth.
// Public list shapes need no auth.
let r = await h.http('GET', '/api/emotes/defaults');
let r = await h.http('GET', '/api/emotes/defaults');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
@@ -195,7 +184,6 @@ t('emote API: not-mine is refused, sources round-trip through the Live-context e
@@ -195,7 +184,6 @@ t('emote API: not-mine is refused, sources round-trip through the Live-context e
// ── Moderators & moderation settings ─────────────────────────
// ── Moderators & moderation settings ─────────────────────────
t('moderators: owner and admin add/remove, a stranger is refused', async () => {
t('moderators: owner and admin add/remove, a stranger is refused', async () => {
- asChat('channel_moderators');
let r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: stranger.token, body: { username: 'moddy' } });
let r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: stranger.token, body: { username: 'moddy' } });
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.status, 403, r.text);
r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: streamer.token, body: { username: 'moddy' } });
r = await h.http('POST', `/api/chat/channels/${channelId}/mods`, { token: streamer.token, body: { username: 'moddy' } });
@@ -223,17 +211,9 @@ t('moderators: list is public to any signed-in reader; moderation settings are m
@@ -223,17 +211,9 @@ t('moderators: list is public to any signed-in reader; moderation settings are m
assert.ok(mine.body.channels.some((c) => c.id === channelId));
assert.ok(mine.body.channels.some((c) => c.id === channelId));
});
});
-t('moderation settings: owner writes, a mod cannot change policy keys, authority gate first', async () => {
- // While Live writes the settings, the write refuses.
- h.db.setTableAuthority('channel_moderation_settings', 'live');
- let r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 5 } });
- assert.strictEqual(r.status, 503, r.text);
- assert.deepStrictEqual(r.body, { ok: false, error: 'not yet' });
-
- asChat('channel_moderation_settings');
- asChat('channel_moderators');
+t('moderation settings: owner writes, a mod cannot change policy keys', async () => {
h.db.addChannelModerator(channelId, mod.id, streamer.id);
h.db.addChannelModerator(channelId, mod.id, streamer.id);
- r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 12, ip_approval_mode: 1 } });
+ let r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 12, ip_approval_mode: 1 } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.body.settings.slow_mode_seconds, 12);
assert.strictEqual(r.body.settings.slow_mode_seconds, 12);
assert.strictEqual(r.body.settings.ip_approval_mode, 1);
assert.strictEqual(r.body.settings.ip_approval_mode, 1);
@@ -257,7 +237,6 @@ t('moderation settings: owner writes, a mod cannot change policy keys, authority
@@ -257,7 +237,6 @@ t('moderation settings: owner writes, a mod cannot change policy keys, authority
// ── Alert-sound op ───────────────────────────────────────────
// ── Alert-sound op ───────────────────────────────────────────
t('alert op: Chat resolves the sound from its own row and broadcasts it', async () => {
t('alert op: Chat resolves the sound from its own row and broadcasts it', async () => {
- asChat('channel_moderation_settings');
const snd = path.join(h.tmp, 'sounds', 'donation.mp3');
const snd = path.join(h.tmp, 'sounds', 'donation.mp3');
fs.writeFileSync(snd, 'ID3alert');
fs.writeFileSync(snd, 'ID3alert');
h.db.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg');
h.db.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg');
@@ -318,6 +318,16 @@ async function boot({ env = {} } = {}) {
@@ -318,6 +318,16 @@ async function boot({ env = {} } = {}) {
live.policies.set(id, { settings, moderator_ids: moderators });
live.policies.set(id, { settings, moderator_ids: moderators });
return id;
return id;
},
},
+ /** Write the channel's own moderation settings row (the six chat tables are Chat's; C-04). */
+ setChannelSettings(channelId, settings) {
+ const d = h.db.getDb();
+ d.prepare('DELETE FROM channel_moderation_settings WHERE channel_id = ?').run(Number(channelId));
+ if (settings) {
+ const keys = Object.keys(settings).filter((k) => /^[a-z_]+$/.test(k));
+ d.prepare(`INSERT INTO channel_moderation_settings (channel_id, ${keys.join(', ')}) VALUES (?, ${keys.map(() => '?').join(', ')})`).run(Number(channelId), ...keys.map((k) => settings[k]));
+ }
+ h.ctx.invalidateChannel(Number(channelId));
+ },
addStream(userId, channelId, { title = 'Live now', is_live = 1, managed = null } = {}) {
addStream(userId, channelId, { title = 'Live now', is_live = 1, managed = null } = {}) {
const id = 500 + live.streams.size;
const id = 500 + live.streams.size;
if (managed) live.managed.set(managed.id, { user_id: userId, sort_order: 0, created_at: sqliteNow(), ...managed });
if (managed) live.managed.set(managed.id, { user_id: userId, sort_order: 0, created_at: sqliteNow(), ...managed });
@@ -3,11 +3,9 @@
@@ -3,11 +3,9 @@
* scripts/import-from-live.js against a fake Live snapshot built from Live's real table
* scripts/import-from-live.js against a fake Live snapshot built from Live's real table
* definitions (test/fixtures/live-chat-schema.sql): counts per table, a dry run unless --apply (and
* definitions (test/fixtures/live-chat-schema.sql): counts per table, a dry run unless --apply (and
* it writes nothing), --apply backs Chat's database up first, ids kept, Network subjects filled in,
* it writes nothing), --apply backs Chat's database up first, ids kept, Network subjects filled in,
- * id headroom, projections seeded, idempotent re-runs, Chat's edits win on chat tables while staged
- * tables at 'live' are made equal to Live's (refreshed, and pruned of rows Live removed) and staged
- * tables at 'chat' are only compared, conflicting rows and transient-table rows held (never
- * dropped), rows Live wrote after the first pass brought over, schema drift refused; and
- * scripts/parity-check.js --tables (row counts and a content hash per staged table).
+ * id headroom, projections seeded, idempotent re-runs, Chat's edits win on chat tables (the six
+ * tables Chat owns are not imported at all), conflicting rows and transient-table rows held (never
+ * dropped), rows Live wrote after the first pass brought over, schema drift refused.
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -79,7 +77,6 @@ t('dry run reports and writes nothing (the default; --dry-run says it too)', ()
@@ -79,7 +77,6 @@ t('dry run reports and writes nothing (the default; --dry-run says it too)', ()
assert.strictEqual(r.report.backup, null);
assert.strictEqual(r.report.backup, null);
}
}
const r = importer([]);
const r = importer([]);
- assert.strictEqual(r.report.tables.channel_moderators.authority, 'live');
assert.strictEqual(r.report.tables.chat_messages.inserted, 5);
assert.strictEqual(r.report.tables.chat_messages.inserted, 5);
assert.strictEqual(r.report.tables.chat_messages_new.held, 1);
assert.strictEqual(r.report.tables.chat_messages_new.held, 1);
const c = new Database(chatDb, { readonly: true });
const c = new Database(chatDb, { readonly: true });
@@ -97,7 +94,7 @@ t('import copies every table with ids kept, subjects filled, headroom set', () =
@@ -97,7 +94,7 @@ t('import copies every table with ids kept, subjects filled, headroom set', () =
assert.strictEqual(b.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, 0, 'the backup is the database before the run');
assert.strictEqual(b.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, 0, 'the backup is the database before the run');
b.close();
b.close();
const T = r.report.tables;
const T = r.report.tables;
- const expect = { chat_messages: 5, dm_conversations: 1, dm_participants: 2, dm_messages: 1, dm_blocks: 1, tts_voice_overrides: 1, channel_sounds: 1, relay_users: 1, hidden_relay_users: 1, pending_ip_messages: 1, stream_first_chats: 1, moderation_actions: 1, channel_moderators: 1, channel_moderation_settings: 1, emotes: 1, user_tags: 1, chat_ai_summaries: 1, chat_timeline_events: 1 };
+ const expect = { chat_messages: 5, dm_conversations: 1, dm_participants: 2, dm_messages: 1, dm_blocks: 1, tts_voice_overrides: 1, channel_sounds: 1, relay_users: 1, hidden_relay_users: 1, pending_ip_messages: 1, stream_first_chats: 1, moderation_actions: 1 };
for (const [tbl, n] of Object.entries(expect)) {
for (const [tbl, n] of Object.entries(expect)) {
assert.strictEqual(T[tbl].live, n, `${tbl} live`);
assert.strictEqual(T[tbl].live, n, `${tbl} live`);
assert.strictEqual(T[tbl].inserted, n, `${tbl} inserted`);
assert.strictEqual(T[tbl].inserted, n, `${tbl} inserted`);
@@ -138,12 +135,11 @@ t('re-running is idempotent', () => {
@@ -138,12 +135,11 @@ t('re-running is idempotent', () => {
assert.strictEqual(r.report.held_total, 1, 'holds are not duplicated');
assert.strictEqual(r.report.held_total, 1, 'holds are not duplicated');
});
});
-t('after the cutover: Chat’s edits win, staged tables refresh, conflicts are held, late Live rows come over', () => {
+t('after the cutover: Chat’s edits win, conflicts are held, late Live rows come over', () => {
const c = new Database(chatDb);
const c = new Database(chatDb);
c.prepare('UPDATE chat_messages SET is_deleted = 1 WHERE id = 101').run(); // a moderator deleted it in Chat
c.prepare('UPDATE chat_messages SET is_deleted = 1 WHERE id = 101').run(); // a moderator deleted it in Chat
c.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (106, 3, 'Bob', 'written in chat')").run();
c.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (106, 3, 'Bob', 'written in chat')").run();
c.close();
c.close();
- live.prepare('UPDATE channel_moderation_settings SET slow_mode_seconds = 10 WHERE channel_id = 5').run();
live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (106, 2, 'Alice', 'a different 106', '2026-09-03 00:00:00')").run();
live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (106, 2, 'Alice', 'a different 106', '2026-09-03 00:00:00')").run();
live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (107, 2, 'Alice', 'written in Live after the first pass', '2026-09-03 00:00:01')").run();
live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (107, 2, 'Alice', 'written in Live after the first pass', '2026-09-03 00:00:01')").run();
const r = importer(['--apply', '--no-backup', '--headroom', '1000']);
const r = importer(['--apply', '--no-backup', '--headroom', '1000']);
@@ -152,12 +148,10 @@ t('after the cutover: Chat’s edits win, staged tables refresh, conflicts are h
@@ -152,12 +148,10 @@ t('after the cutover: Chat’s edits win, staged tables refresh, conflicts are h
assert.strictEqual(T.chat_messages.chat_kept, 1);
assert.strictEqual(T.chat_messages.chat_kept, 1);
assert.strictEqual(T.chat_messages.held, 1);
assert.strictEqual(T.chat_messages.held, 1);
assert.strictEqual(T.chat_messages.inserted, 1);
assert.strictEqual(T.chat_messages.inserted, 1);
- assert.strictEqual(T.channel_moderation_settings.refreshed, 1);
const d = new Database(chatDb, { readonly: true });
const d = new Database(chatDb, { readonly: true });
assert.strictEqual(d.prepare('SELECT is_deleted FROM chat_messages WHERE id = 101').get().is_deleted, 1);
assert.strictEqual(d.prepare('SELECT is_deleted FROM chat_messages WHERE id = 101').get().is_deleted, 1);
assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 106').get().message, 'written in chat');
assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 106').get().message, 'written in chat');
assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 107').get().message, 'written in Live after the first pass');
assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 107').get().message, 'written in Live after the first pass');
- assert.strictEqual(d.prepare('SELECT slow_mode_seconds FROM channel_moderation_settings WHERE channel_id = 5').get().slow_mode_seconds, 10);
const held = d.prepare("SELECT * FROM import_hold WHERE source_table = 'chat_messages'").get();
const held = d.prepare("SELECT * FROM import_hold WHERE source_table = 'chat_messages'").get();
assert.match(held.reason, /different row/);
assert.match(held.reason, /different row/);
assert.strictEqual(JSON.parse(held.row_json).message, 'a different 106');
assert.strictEqual(JSON.parse(held.row_json).message, 'a different 106');
@@ -165,62 +159,6 @@ t('after the cutover: Chat’s edits win, staged tables refresh, conflicts are h
@@ -165,62 +159,6 @@ t('after the cutover: Chat’s edits win, staged tables refresh, conflicts are h
d.close();
d.close();
});
});
-function parity(args) {
- const r = spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'parity-check.js'), '--tables', '--live-db', liveDb, '--chat-db', chatDb, ...args], {
- cwd: path.join(__dirname, '..'), encoding: 'utf8', env: { ...process.env, NODE_ENV: 'test' },
- });
- return { code: r.status, out: r.stdout, err: r.stderr };
-}
-
-t('parity --tables: after an import every staged table has the same rows and hash', () => {
- const r = parity([]);
- assert.strictEqual(r.code, 0, r.out + r.err);
- for (const tbl of ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events']) {
- assert.match(r.out, new RegExp(`^same\\s+${tbl}\\s+\\(live\\) rows 1\\b`, 'm'), tbl);
- }
- assert.match(r.out, /all staged tables are the same/);
-});
-
-t('staged tables at live follow Live: a removed moderator goes, a re-added one comes back; parity sees each step', () => {
- live.prepare('DELETE FROM channel_moderators WHERE channel_id = 5 AND user_id = 2').run();
- live.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (5, 3, 1)').run();
- let p = parity(['--table', 'channel_moderators']);
- assert.strictEqual(p.code, 1);
- assert.match(p.out, /DIFFERENT channel_moderators\s+\(live\) rows 1 ≠ 1/);
- assert.match(p.out, /only in Live \(1\): \[2\]/);
- assert.match(p.out, /only in Chat \(1\): \[1\]/);
- const dry = importer(['--tables', 'channel_moderators']);
- assert.strictEqual(dry.code, 0, dry.stderr + dry.stdout);
- assert.deepStrictEqual([dry.report.tables.channel_moderators.pruned, dry.report.tables.channel_moderators.inserted], [1, 1], 'the dry run counts it');
- assert.strictEqual(parity(['--table', 'channel_moderators']).code, 1, 'and changes nothing');
- const r = importer(['--apply', '--no-backup', '--tables', 'channel_moderators']);
- assert.strictEqual(r.code, 0, r.stderr + r.stdout);
- assert.deepStrictEqual([r.report.tables.channel_moderators.pruned, r.report.tables.channel_moderators.inserted], [1, 1]);
- p = parity(['--table', 'channel_moderators']);
- assert.strictEqual(p.code, 0, p.out);
- // The same user removed and added again under a new id: the old row goes first, no collision.
- live.prepare('DELETE FROM channel_moderators WHERE user_id = 3').run();
- live.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (5, 3, 1)').run();
- const again = importer(['--apply', '--no-backup', '--tables', 'channel_moderators']);
- assert.deepStrictEqual([again.report.tables.channel_moderators.pruned, again.report.tables.channel_moderators.inserted, again.report.tables.channel_moderators.held], [1, 1, 0]);
- assert.strictEqual(parity(['--table', 'channel_moderators']).code, 0);
-});
-
-t('a staged table at chat is only compared: Chat is its authority', () => {
- const c = new Database(chatDb);
- c.prepare("UPDATE table_authority SET authority = 'chat' WHERE table_name = 'emotes'").run();
- c.prepare("UPDATE emotes SET code = 'pogchat' WHERE code = 'pog'").run();
- c.close();
- live.prepare("INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (1, 'kek', '/e/kek.png', 1)").run();
- const r = importer(['--apply', '--no-backup', '--tables', 'emotes']);
- assert.strictEqual(r.code, 0, r.stderr);
- assert.deepStrictEqual(r.report.tables.emotes, { authority: 'chat', live: 2, identical: 0, differs: 1, live_only: 1, chat_only: 0 });
- const d = new Database(chatDb, { readonly: true });
- assert.deepStrictEqual(d.prepare('SELECT code FROM emotes ORDER BY id').all().map((x) => x.code), ['pogchat'], 'nothing written');
- d.close();
- assert.match(parity(['--table', 'emotes']).out, /DIFFERENT emotes\s+\(chat\)/);
-});
-
t('schema drift is refused before anything is written', () => {
t('schema drift is refused before anything is written', () => {
live.exec('ALTER TABLE chat_messages ADD COLUMN surprise TEXT');
live.exec('ALTER TABLE chat_messages ADD COLUMN surprise TEXT');
live.prepare("INSERT INTO chat_messages (id, username, message, surprise) VALUES (108, 'x', 'y', 'z')").run();
live.prepare("INSERT INTO chat_messages (id, username, message, surprise) VALUES (108, 'x', 'y', 'z')").run();
@@ -17,6 +17,9 @@ t('boot', async () => {
@@ -17,6 +17,9 @@ t('boot', async () => {
alice = h.addUser('alice');
alice = h.addUser('alice');
mod = h.addUser('moddy');
mod = h.addUser('moddy');
channelId = h.addChannel(streamer.id, { moderators: [mod.id], settings: { max_message_length: 60 } });
channelId = h.addChannel(streamer.id, { moderators: [mod.id], settings: { max_message_length: 60 } });
+ // The settings and moderator rows are Chat's own (C-04 done); seed them here.
+ h.setChannelSettings(channelId, { max_message_length: 60 });
+ h.db.addChannelModerator(channelId, mod.id, streamer.id);
streamId = h.addStream(streamer.id, channelId);
streamId = h.addStream(streamer.id, channelId);
h.live.follows.set(alice.id, [streamer.id]);
h.live.follows.set(alice.id, [streamer.id]);
await h.ctx.sync();
await h.ctx.sync();
@@ -42,7 +45,7 @@ t('no read call to Live per chat message (only the one effect)', async () => {
@@ -42,7 +45,7 @@ t('no read call to Live per chat message (only the one effect)', async () => {
assert.strictEqual(during.filter((p) => p === '/internal/chat-effects/chat-message').length, 3, 'one effect per message');
assert.strictEqual(during.filter((p) => p === '/internal/chat-effects/chat-message').length, 3, 'one effect per message');
});
});
-t('channel rules come from the warm policy: message length', async () => {
+t('channel rules come from the local settings row: message length', async () => {
aliceWs.sendJson({ type: 'chat', message: 'x'.repeat(61) });
aliceWs.sendJson({ type: 'chat', message: 'x'.repeat(61) });
await aliceWs.next((m) => m.type === 'system' && m.message === 'Message too long. Max 60 characters.');
await aliceWs.next((m) => m.type === 'system' && m.message === 'Message too long. Max 60 characters.');
});
});
@@ -67,9 +70,8 @@ t('invalidation never leaves a gap: a moderator stays a moderator while the poli
@@ -67,9 +70,8 @@ t('invalidation never leaves a gap: a moderator stays a moderator while the poli
assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);
assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);
await h.sleep(100);
await h.sleep(100);
assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);
assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);
- // A moderator removed in Live loses the power after the reload.
- h.live.policies.get(channelId).moderator_ids = [];
- h.ctx.invalidateChannel(channelId);
+ // A moderator removed from Chat's own row loses the power.
+ h.db.removeChannelModerator(channelId, mod.id);
await h.sleep(150);
await h.sleep(150);
assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), false);
assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), false);
});
});
@@ -116,8 +118,7 @@ t('a ban written through Live is in Chat’s cache when the effect answers, even
@@ -116,8 +118,7 @@ t('a ban written through Live is in Chat’s cache when the effect answers, even
});
});
t('followers-only uses the viewer’s warm follow list', async () => {
t('followers-only uses the viewer’s warm follow list', async () => {
- h.live.policies.get(channelId).settings = { followers_only: 1 };
- h.ctx.invalidateChannel(channelId);
+ h.setChannelSettings(channelId, { followers_only: 1 });
await h.sleep(150);
await h.sleep(150);
const stranger = h.addUser('stranger');
const stranger = h.addUser('stranger');
await h.ctx.sync();
await h.ctx.sync();
@@ -62,6 +62,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sy
@@ -62,6 +62,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sy
bob = h.addUser('bob', { subject: 'usr_01J9PAR1TY00000000000000B1' });
bob = h.addUser('bob', { subject: 'usr_01J9PAR1TY00000000000000B1' });
dave = h.addUser('dave');
dave = h.addUser('dave');
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
+ h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)
streamId = h.addStream(streamer.id, channelId);
streamId = h.addStream(streamer.id, channelId);
h.live.subscribers.add(`${alice.id}|${streamer.id}`); // A subscribes to the channel; B does not
h.live.subscribers.add(`${alice.id}|${streamer.id}`); // A subscribes to the channel; B does not
await h.ctx.sync();
await h.ctx.sync();
@@ -194,11 +195,9 @@ t('sub-only fails closed: while Live cannot say whether someone subscribes they
@@ -194,11 +195,9 @@ t('sub-only fails closed: while Live cannot say whether someone subscribes they
});
});
t('slow mode is the channel’s saved setting: the dashboard’s value is enforced and announced, and a restart keeps it', async () => {
t('slow mode is the channel’s saved setting: the dashboard’s value is enforced and announced, and a restart keeps it', async () => {
- const pol = h.live.policies.get(channelId);
const a = await p.join('a'); const b = await p.join('b');
const a = await p.join('a'); const b = await p.join('b');
- // The dashboard saves 3 s in Live, which tells Chat (the bridge's invalidate: Live chat-remote.js OBSERVED_DB).
- pol.settings = { ...(pol.settings || {}), slow_mode_seconds: 3 };
- await bridge([{ op: 'invalidate', args: ['channel', channelId] }]);
+ // The dashboard saves 3 s in Chat's own settings row, which announces the change at once.
+ h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 3 });
assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, 3, 'the room is told');
assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, 3, 'the room is told');
await p.system(b, 'Slow mode enabled: 3s between messages');
await p.system(b, 'Slow mode enabled: 3s between messages');
const slowed = async (ws, label) => {
const slowed = async (ws, label) => {
@@ -217,8 +216,7 @@ t('slow mode is the channel’s saved setting: the dashboard’s value is enforc
@@ -217,8 +216,7 @@ t('slow mode is the channel’s saved setting: the dashboard’s value is enforc
assert.strictEqual(again.auth.slowmode_seconds, 3, 'read back after a restart');
assert.strictEqual(again.auth.slowmode_seconds, 3, 'read back after a restart');
await slowed(again, 'after a restart');
await slowed(again, 'after a restart');
// The dashboard turns it off.
// The dashboard turns it off.
- pol.settings.slow_mode_seconds = 0;
- await bridge([{ op: 'invalidate', args: ['channel', channelId] }]);
+ h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 0 });
assert.strictEqual((await b.next((m) => m.type === 'slowmode' && m.seconds === 0)).seconds, 0);
assert.strictEqual((await b.next((m) => m.type === 'slowmode' && m.seconds === 0)).seconds, 0);
await p.system(b, 'Slow mode disabled.');
await p.system(b, 'Slow mode disabled.');
await p.closeAll();
await p.closeAll();
@@ -87,8 +87,7 @@ t('/me, /clear and ban notices from global chat or a channel room stay in that r
@@ -87,8 +87,7 @@ t('/me, /clear and ban notices from global chat or a channel room stay in that r
});
});
t('/me and /tts obey followers-only and the slur filter', async () => {
t('/me and /tts obey followers-only and the slur filter', async () => {
- h.live.policies.get(chanX).settings = { followers_only: 1, slur_filter_enabled: 1, slur_filter_terms: 'bannedword' };
- h.ctx.invalidateChannel(chanX);
+ h.setChannelSettings(chanX, { followers_only: 1, slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });
const viewer = await joined({ ip: '198.51.100.20', token: streamer.token, stream: streamX }, { streamId: streamX });
const viewer = await joined({ ip: '198.51.100.20', token: streamer.token, stream: streamX }, { streamId: streamX });
const bobWs = await joined({ ip: '198.51.100.21', token: bob.token, stream: streamX }, { streamId: streamX });
const bobWs = await joined({ ip: '198.51.100.21', token: bob.token, stream: streamX }, { streamId: streamX });
bobWs.sendJson({ type: 'chat', message: 'plain line' });
bobWs.sendJson({ type: 'chat', message: 'plain line' });
@@ -100,8 +99,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {
@@ -100,8 +99,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {
assert.ok(await viewer.none((m) => /sneaks past/.test(m.message || ''), 500), '/me or /tts bypassed followers-only');
assert.ok(await viewer.none((m) => /sneaks past/.test(m.message || ''), 500), '/me or /tts bypassed followers-only');
h.live.follows.set(bob.id, [streamer.id]);
h.live.follows.set(bob.id, [streamer.id]);
- h.live.policies.get(chanX).settings = { slur_filter_enabled: 1, slur_filter_terms: 'bannedword' };
- h.ctx.invalidateChannel(chanX);
+ h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });
const bob2 = await joined({ ip: '198.51.100.22', token: bob.token, stream: streamX }, { streamId: streamX });
const bob2 = await joined({ ip: '198.51.100.22', token: bob.token, stream: streamX }, { streamId: streamX });
bob2.sendJson({ type: 'chat', message: '/me says bannedword' });
bob2.sendJson({ type: 'chat', message: '/me says bannedword' });
await bob2.next((m) => m.type === 'slur-blocked');
await bob2.next((m) => m.type === 'slur-blocked');
@@ -109,8 +107,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {
@@ -109,8 +107,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {
bob2.sendJson({ type: 'chat', message: '/tts bannedword' });
bob2.sendJson({ type: 'chat', message: '/tts bannedword' });
await bob2.next((m) => m.type === 'slur-blocked');
await bob2.next((m) => m.type === 'slur-blocked');
assert.ok(await viewer.none((m) => /bannedword/.test(m.message || ''), 300), 'slur filter bypassed by /me or /tts');
assert.ok(await viewer.none((m) => /bannedword/.test(m.message || ''), 300), 'slur filter bypassed by /me or /tts');
- h.live.policies.get(chanX).settings = null;
- h.ctx.invalidateChannel(chanX);
+ h.setChannelSettings(chanX, null);
for (const w of [viewer, bobWs, bob2]) w.close();
for (const w of [viewer, bobWs, bob2]) w.close();
});
});
@@ -127,8 +124,7 @@ t('joining the channel room without the stream id does not skip slow mode or IP
@@ -127,8 +124,7 @@ t('joining the channel room without the stream id does not skip slow mode or IP
assert.ok(await own.none((m) => m.message === 'second within slow mode'), 'slow mode bypassed from the channel room');
assert.ok(await own.none((m) => m.message === 'second within slow mode'), 'slow mode bypassed from the channel room');
// (/slow 30 is the channel's saved setting now; the policy set below replaces it)
// (/slow 30 is the channel's saved setting now; the policy set below replaces it)
- h.live.policies.get(chanX).settings = { ip_approval_mode: 1 };
- h.ctx.invalidateChannel(chanX);
+ h.setChannelSettings(chanX, { ip_approval_mode: 1 });
const fresh = await joined({ ip: '198.51.100.32' }, { channelUserId: streamer.id });
const fresh = await joined({ ip: '198.51.100.32' }, { channelUserId: streamer.id });
fresh.sendJson({ type: 'chat', message: 'unapproved hello' });
fresh.sendJson({ type: 'chat', message: 'unapproved hello' });
await fresh.next((m) => m.type === 'system' && /IP approval mode/.test(m.message));
await fresh.next((m) => m.type === 'system' && /IP approval mode/.test(m.message));
@@ -137,14 +133,12 @@ t('joining the channel room without the stream id does not skip slow mode or IP
@@ -137,14 +133,12 @@ t('joining the channel room without the stream id does not skip slow mode or IP
await h.sleep(1100);
await h.sleep(1100);
fresh.sendJson({ type: 'chat', message: '/me unapproved action' });
fresh.sendJson({ type: 'chat', message: '/me unapproved action' });
assert.ok(await own.none((m) => /unapproved action/.test(m.message || ''), 400), '/me bypassed IP approval');
assert.ok(await own.none((m) => /unapproved action/.test(m.message || ''), 400), '/me bypassed IP approval');
- h.live.policies.get(chanX).settings = null;
- h.ctx.invalidateChannel(chanX);
+ h.setChannelSettings(chanX, null);
for (const w of [own, sneaky, fresh]) w.close();
for (const w of [own, sneaky, fresh]) w.close();
});
});
t('a catastrophic custom slur regex cannot freeze the chat server', async () => {
t('a catastrophic custom slur regex cannot freeze the chat server', async () => {
- h.live.policies.get(chanX).settings = { slur_filter_enabled: 1, slur_filter_use_builtin: 0, slur_filter_regexes: '^(a|a)*$' };
- h.ctx.invalidateChannel(chanX);
+ h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_use_builtin: 0, slur_filter_regexes: '^(a|a)*$' });
const viewer = await joined({ ip: '198.51.100.40', token: streamer.token, stream: streamX }, { streamId: streamX });
const viewer = await joined({ ip: '198.51.100.40', token: streamer.token, stream: streamX }, { streamId: streamX });
const bobWs = await joined({ ip: '198.51.100.41', token: bob.token, stream: streamX }, { streamId: streamX });
const bobWs = await joined({ ip: '198.51.100.41', token: bob.token, stream: streamX }, { streamId: streamX });
const line = 'a'.repeat(30) + 'b';
const line = 'a'.repeat(30) + 'b';
@@ -152,8 +146,7 @@ t('a catastrophic custom slur regex cannot freeze the chat server', async () =>
@@ -152,8 +146,7 @@ t('a catastrophic custom slur regex cannot freeze the chat server', async () =>
bobWs.sendJson({ type: 'chat', message: line });
bobWs.sendJson({ type: 'chat', message: line });
await viewer.next((m) => m.type === 'chat' && m.message === line, 20000);
await viewer.next((m) => m.type === 'chat' && m.message === line, 20000);
assert.ok(Date.now() - started < 1500, `chat blocked for ${Date.now() - started}ms by a streamer regex`);
assert.ok(Date.now() - started < 1500, `chat blocked for ${Date.now() - started}ms by a streamer regex`);
- h.live.policies.get(chanX).settings = null;
- h.ctx.invalidateChannel(chanX);
+ h.setChannelSettings(chanX, null);
for (const w of [viewer, bobWs]) w.close();
for (const w of [viewer, bobWs]) w.close();
});
});
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * /api/sounds (upload → MP3, list, serve, !command playback in chat, delete rules, alert sounds
- * written to Live's channel settings) and /api/tts admin settings (admins; credentials owner-only;
+ * /api/sounds (upload → MP3, list, serve, !command playback in chat, delete rules, alert sounds on
+ * Chat's own channel settings row) and /api/tts admin settings (admins; credentials owner-only;
* written through Live).
* written through Live).
*/
*/
const assert = require('assert');
const assert = require('assert');
@@ -88,14 +88,13 @@ t('delete: not someone else’s sound; the uploader can; Live drops the Media co
@@ -88,14 +88,13 @@ t('delete: not someone else’s sound; the uploader can; Live drops the Media co
assert.strictEqual(h.db.getChannelSoundById(soundId), undefined);
assert.strictEqual(h.db.getChannelSoundById(soundId), undefined);
});
});
-t('alert sounds: the streamer’s own channel, stored through Live', async () => {
+t('alert sounds: the streamer’s own channel, stored on Chat’s own settings row (C-04)', async () => {
const r = await upload('/api/sounds/alert/donation', streamer.token, {}, wav());
const r = await upload('/api/sounds/alert/donation', streamer.token, {}, wav());
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.body.kind, 'donation');
assert.strictEqual(r.body.kind, 'donation');
- const fx = h.live.effects.find((e) => e.name === 'alert-sound');
- assert.strictEqual(fx.body.channel_id, channelId);
- assert.strictEqual(fx.body.actor_user_id, streamer.id);
- assert.ok(fx.body.url.startsWith(path.resolve(process.env.SOUNDS_PATH)));
+ const row = h.db.get('SELECT donation_sound_url, donation_sound_mime FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
+ assert.ok(row.donation_sound_url.startsWith(path.resolve(process.env.SOUNDS_PATH)));
+ assert.strictEqual(row.donation_sound_mime, 'audio/mpeg');
});
});
t('TTS admin settings: admins only; credentials only the owner; written by Live', async () => {
t('TTS admin settings: admins only; credentials only the owner; written by Live', async () => {
deleted file mode 100644
deleted file mode 100644
@@ -1,239 +0,0 @@
@@ -1,239 +0,0 @@
-'use strict';
-/**
- * The staged tables (roadmap C-04, docs/staged-tables-cutover.md): table_authority per table, kept
- * across restarts; at 'live' the staged writes refuse and Live's captured changes keep this copy
- * current (never mirrored back); the handoff over the bridge (to 'chat' needs the mirror, back to
- * 'live' waits until the mirror has sent the table's changes); at 'chat' every write Live's writers
- * make — moderators, settings and alert sounds, emote CRUD with the Media fields, user tags, AI
- * summaries and timeline events — answers Live's own return value plus the rows, once per key, and
- * reaches Live through the mirror; Chat's chat server then reads the moderation tables in place and
- * writes /slow itself; the dual-read slice; scripts/table-authority.js.
- */
-const assert = require('assert');
-const path = require('path');
-const fs = require('fs');
-const { spawnSync } = require('child_process');
-const { boot, suite } = require('./helpers');
-
-const t = suite('staged-tables');
-let h, BRIDGE, streamer, mod, viewer, channelId, streamId;
-const STAGED = ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events'];
-let seq = 0;
-
-const calls = (ops) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'live-boot-staged', ops: ops.map((o) => ({ seq: ++seq, ...o })) } });
-/** One op, its result (throws with Chat's error when it was refused). */
-async function op(name, args, { key } = {}) {
- const r = await calls([{ op: name, args, key }]);
- assert.strictEqual(r.status, 200, r.text);
- const res = r.body.results[0];
- if (!res.ok) throw Object.assign(new Error(res.error), { refused: true });
- return res.result;
-}
-const dbOp = (fn, args, opts) => op('db', [fn, ...args], opts);
-const count = (table, where = '1', params = []) => h.db.get(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`, params).n;
-const stagedPending = () => h.db.get(`SELECT COUNT(*) AS n FROM live_mirror_outbox WHERE tbl IN (${STAGED.map(() => '?').join(',')})`, STAGED).n;
-
-t('boot with the Live mirror on', async () => {
- h = await boot({ env: { LIVE_MIRROR: '1', LIVE_MIRROR_INTERVAL_MS: '3600000' } });
- BRIDGE = h.serviceToken(['chat.live_bridge.write']);
- streamer = h.addUser('streamer', { role: 'streamer' });
- mod = h.addUser('moddy');
- viewer = h.addUser('viewer');
- channelId = h.addChannel(streamer.id, { settings: { channel_id: 0, slow_mode_seconds: 99 } });
- streamId = h.addStream(streamer.id, channelId);
- await h.ctx.sync();
-});
-
-t('every staged table starts at live; a restart keeps the authority it was handed', async () => {
- assert.deepStrictEqual(await op('tableAuthority', []), Object.fromEntries(STAGED.map((s) => [s, 'live'])));
- h.db.setTableAuthority('emotes', 'chat');
- h.db.initDb();
- assert.strictEqual(h.db.tableAuthority('emotes'), 'chat', 'initDb never moves a table back');
- h.db.setTableAuthority('emotes', 'live');
- assert.strictEqual(h.db.tableAuthority('chat_messages'), 'chat', 'Chat’s own tables stay Chat’s');
- assert.throws(() => h.db.setTableAuthority('chat_messages', 'live'), /not a staged table/);
-});
-
-t('at live: every staged write refuses, one writer at a time', async () => {
- const tries = [
- ['addChannelModerator', [channelId, mod.id, streamer.id]],
- ['upsertChannelModerationSettings', [channelId, { slow_mode_seconds: 1 }]],
- ['createEmote', [{ user_id: streamer.id, code: 'nope', url: '/e/nope.png' }]],
- ['grantUserTag', [viewer.id, 'og']],
- ['upsertChatAiSummary', [{ scope: 'global', subject_id: 0, window: 'rolling', overview: 'x' }]],
- ['addChatTimelineEvents', ['global', 0, [{ ts: '2026-09-25 10:00:00', label: 'x' }]]],
- ];
- for (const [fn, args] of tries) await assert.rejects(dbOp(fn, args), /written by Live \(table_authority live\)/, fn);
- for (const s of STAGED) assert.strictEqual(count(s), 0, `${s} untouched`);
-});
-
-t('at live: Live’s changes keep this copy current, idempotently, and never go back to Live', async () => {
- const changes = [
- { table: 'channel_moderators', op: 'upsert', row: { id: 7, channel_id: channelId, user_id: mod.id, added_by: streamer.id, created_at: '2026-09-25 10:00:00' } },
- { table: 'channel_moderation_settings', op: 'upsert', row: { channel_id: channelId, slow_mode_seconds: 4, emote_scale: 120 } },
- { table: 'emotes', op: 'upsert', row: { id: 3, user_id: streamer.id, code: 'pog', url: '/e/pog.png', channel_owner_id: streamer.id, media_url: null } },
- { table: 'user_tags', op: 'upsert', row: { id: 5, user_id: viewer.id, tag_id: 'legacy', source: 'migration' } },
- { table: 'chat_ai_summaries', op: 'upsert', row: { id: 9, scope: 'global', subject_id: 0, window: 'rolling', overview: 'busy' } },
- { table: 'chat_timeline_events', op: 'upsert', row: { id: 11, scope: 'global', subject_id: 0, ts: '2026-09-25 10:00:00', label: 'raid' } },
- { table: 'chat_messages', op: 'upsert', row: { id: 1, message: 'not staged' } },
- ];
- let r = await op('stagedApply', [changes]);
- assert.strictEqual(r.applied, 6);
- assert.deepStrictEqual(r.skipped.map((s) => s.reason), ['not a staged table']);
- r = await op('stagedApply', [changes.slice(0, 6)]);
- assert.strictEqual(r.applied, 6, 'again: the same rows');
- for (const s of STAGED) assert.strictEqual(count(s), 1, s);
- assert.strictEqual(h.db.get('SELECT emote_scale FROM channel_moderation_settings WHERE channel_id = ?', [channelId]).emote_scale, 120);
- // Removed in Live and added again under a new id: the new row replaces the old one.
- r = await op('stagedApply', [[{ table: 'channel_moderators', op: 'upsert', row: { id: 8, channel_id: channelId, user_id: mod.id, added_by: streamer.id } }]]);
- assert.deepStrictEqual(h.db.all('SELECT id FROM channel_moderators').map((x) => x.id), [8]);
- r = await op('stagedApply', [[{ table: 'user_tags', op: 'delete', pk: { id: 5 } }]]);
- assert.strictEqual(count('user_tags'), 0);
- assert.strictEqual(stagedPending(), 0, 'Live’s own rows are never mirrored back');
- // Chat still reads the moderation tables through Live while Live writes them.
- await h.ctx.ensurePolicy(channelId);
- assert.strictEqual(h.ctx.getChannelModerationSettings(channelId).slow_mode_seconds, 99);
- assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), false);
-});
-
-t('the dual-read slice: count and hash over the columns asked for, rows when few', async () => {
- const s = await op('stagedSlice', ['channel_moderators', { channel_id: channelId }, ['id', 'channel_id', 'user_id', 'added_by', 'created_at', 'not_a_column']]);
- assert.strictEqual(s.count, 1);
- assert.deepStrictEqual(s.columns, ['added_by', 'channel_id', 'created_at', 'id', 'user_id']);
- assert.strictEqual(s.hash, h.db.sliceHash(s.columns, s.rows));
- assert.strictEqual((await op('stagedSlice', ['emotes', { channel_owner_id: null }])).count, 0, 'null-safe equality');
- await assert.rejects(op('stagedSlice', ['emotes', { 'code; DROP TABLE emotes': 1 }]), /unknown column/);
- await assert.rejects(op('stagedSlice', ['chat_messages', {}]), /not a staged table/);
-});
-
-t('the handoff: to chat needs the Live mirror', async () => {
- const { handOver } = require('../server/bridge/live-bridge');
- await assert.rejects(handOver('emotes', 'chat', { mirror: h.mirrorRelay, config: { live: { mirror: false } } }), /LIVE_MIRROR is off/);
- assert.strictEqual(h.db.tableAuthority('emotes'), 'live');
- await assert.rejects(op('setTableAuthority', ['chat_messages', 'live']), /not a staged table/);
- for (const s of STAGED) assert.deepStrictEqual(await op('setTableAuthority', [s, 'chat']), { table: s, authority: 'chat', mirror_pending: 0 });
- assert.strictEqual((await h.http('GET', '/ready')).body.table_authority.emotes, 'chat');
-});
-
-let emoteId;
-t('at chat: moderators and settings — Live’s return values, the rows, once per key', async () => {
- let r = await dbOp('addChannelModerator', [channelId, viewer.id, streamer.id], { key: 'live:k1' });
- assert.strictEqual(r.value.changes, 1);
- assert.deepStrictEqual(r.mirror.map((c) => [c.table, c.op, c.row.user_id]), [['channel_moderators', 'upsert', viewer.id]]);
- r = await dbOp('addChannelModerator', [channelId, viewer.id, streamer.id], { key: 'live:k1' });
- assert.strictEqual(count('channel_moderators', 'user_id = ?', [viewer.id]), 1, 'a retried key applies once');
- r = await dbOp('removeChannelModerator', [channelId, viewer.id]);
- assert.strictEqual(r.value.changes, 1);
- assert.strictEqual(r.mirror[0].op, 'delete');
- assert.strictEqual(count('channel_moderators', 'user_id = ?', [viewer.id]), 0);
- r = await dbOp('upsertChannelModerationSettings', [channelId, { slow_mode_seconds: 7, emote_scale: 999, tts_max_length: 5, followers_only: true }]);
- assert.deepStrictEqual([r.value.slow_mode_seconds, r.value.emote_scale, r.value.tts_max_length, r.value.followers_only], [7, 300, 10, 1], 'Live’s clamps');
- r = await dbOp('upsertChannelModerationSettings', [channelId + 1000, { slow_mode_seconds: 2 }]);
- assert.deepStrictEqual([r.value.channel_id, r.value.slow_mode_seconds, r.value.allow_anonymous, r.value.max_message_length], [channelId + 1000, 2, 1, 500], 'a new row with Live’s defaults');
- r = await dbOp('setChannelAlertSound', [channelId, 'goal', '/sounds/goal.mp3', 'audio/mpeg']);
- assert.strictEqual(r.mirror[0].row.goal_sound_url, '/sounds/goal.mp3');
- assert.strictEqual(r.mirror[0].row.slow_mode_seconds, 7, 'the whole row travels');
-});
-
-t('at chat: emote CRUD with the Media fields, user tags, AI summaries and timeline', async () => {
- let r = await dbOp('createEmote', [{ user_id: streamer.id, code: 'hype', url: '/data/emotes/hype.png', animated: true, channel_owner_id: streamer.id, size: 5 }]);
- emoteId = r.value.lastInsertRowid;
- assert.ok(emoteId > 3);
- assert.deepStrictEqual([r.mirror[0].row.code, r.mirror[0].row.animated, r.mirror[0].row.size], ['hype', 1, 25]);
- await assert.rejects(dbOp('createEmote', [{ user_id: streamer.id, code: 'hype', url: '/x.png', channel_owner_id: streamer.id }]), /UNIQUE/, 'one code per channel');
- r = await dbOp('updateEmote', [emoteId, { code: 'hype2', size: 1000 }]);
- assert.deepStrictEqual([r.value.changes, r.mirror[0].row.code, r.mirror[0].row.size], [1, 'hype2', 400]);
- assert.deepStrictEqual((await dbOp('updateEmote', [emoteId, {}])).value, { changes: 0 });
- r = await dbOp('setEmoteMedia', [emoteId, 'https://openvibe.media/a/77', 77]);
- assert.deepStrictEqual([r.mirror[0].row.media_url, r.mirror[0].row.media_asset_id], ['https://openvibe.media/a/77', 77]);
- r = await dbOp('deleteEmote', [3]);
- assert.deepStrictEqual(r.mirror, [{ table: 'emotes', op: 'delete', pk: { id: 3 } }]);
- r = await dbOp('grantUserTag', [viewer.id, 'founder', 'grant']);
- assert.strictEqual(r.mirror[0].row.source, 'grant');
- await dbOp('grantUserTag', [viewer.id, 'founder']);
- assert.strictEqual(count('user_tags'), 1, 'a tag is owned once');
- r = await dbOp('revokeUserTag', [viewer.id, 'founder']);
- assert.strictEqual(r.value.changes, 1);
- r = await dbOp('upsertChatAiSummary', [{ scope: 'user', subject_id: viewer.id, window: 'rolling', overview: 'first', last_message_id: 10 }]);
- r = await dbOp('upsertChatAiSummary', [{ scope: 'user', subject_id: viewer.id, window: 'rolling', overview: 'second', last_message_id: 20 }]);
- assert.deepStrictEqual([r.mirror[0].row.overview, r.mirror[0].row.last_message_id], ['second', 20]);
- assert.strictEqual(count('chat_ai_summaries', "scope = 'user'"), 1);
- r = await dbOp('addChatTimelineEvents', ['global', 0, [{ ts: '2026-09-25 11:00:00', label: 'clip' }, { ts: '2026-09-25 11:00:00', label: 'clip' }, { label: 'no ts' }]]);
- assert.strictEqual(r.value, 2, 'Live counts the duplicate it tried');
- assert.strictEqual(r.mirror.length, 1);
- // Live's changes no longer apply to a table Chat writes.
- const refused = await op('stagedApply', [[{ table: 'emotes', op: 'delete', pk: { id: emoteId } }]]);
- assert.deepStrictEqual([refused.applied, refused.skipped[0].reason], [0, 'Chat writes this table (table_authority chat)']);
- assert.strictEqual(count('emotes', 'id = ?', [emoteId]), 1);
-});
-
-t('at chat: every change reaches Live through the mirror, newest state per row', async () => {
- assert.ok(stagedPending() > 0);
- await h.mirrorRelay.flush();
- assert.strictEqual(stagedPending(), 0);
- const got = (table) => h.live.mirror.filter((c) => c.table === table);
- assert.deepStrictEqual(got('emotes').find((c) => c.row && c.row.id === emoteId).row.code, 'hype2');
- assert.ok(got('emotes').some((c) => c.op === 'delete' && c.pk.id === 3));
- assert.ok(got('channel_moderators').some((c) => c.op === 'delete'));
- assert.strictEqual(got('channel_moderation_settings').find((c) => c.row.channel_id === channelId).row.goal_sound_url, '/sounds/goal.mp3');
- for (const s of ['user_tags', 'chat_ai_summaries', 'chat_timeline_events']) assert.ok(got(s).length >= 1, s);
-});
-
-t('at chat: Chat’s chat server reads the moderation tables in place and persists /slow itself', async () => {
- await dbOp('addChannelModerator', [channelId, mod.id, streamer.id]);
- assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true, 'Live’s policy lists no moderators; the local row counts');
- assert.strictEqual(h.ctx.getChannelModerationSettings(channelId).slow_mode_seconds, 7, 'the local row, not Live’s 99');
- assert.strictEqual(h.ctx.getChannelModerationSettings(424242).allow_anonymous, 1, 'defaults when there is no row');
- const effectsBefore = h.live.effects.length;
- const ws = await h.ws({ ip: '198.51.100.60', token: mod.token, stream: streamId });
- ws.sendJson({ type: 'join', streamId, token: mod.token });
- await ws.next((m) => m.type === 'auth');
- ws.sendJson({ type: 'chat', message: '/slow 12' });
- assert.strictEqual((await ws.next((m) => m.type === 'slowmode')).seconds, 12, 'a moderator by the local table');
- await h.sleep(100);
- assert.strictEqual(h.db.get('SELECT slow_mode_seconds FROM channel_moderation_settings WHERE channel_id = ?', [channelId]).slow_mode_seconds, 12);
- assert.ok(!h.live.effects.slice(effectsBefore).some((e) => e.name === 'channel-settings'), 'Live is not asked to write it');
- ws.close();
- // Alert sounds: files in the sounds directory only, written here.
- await assert.rejects(h.ctx.effects.setChannelAlertSound(channelId, 'donation', '/etc/hostname', 'audio/mpeg', streamer.id), /sounds directory/);
- const snd = path.join(h.tmp, 'sounds', 'alert.mp3');
- fs.writeFileSync(snd, 'ID3');
- await h.ctx.effects.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg', streamer.id);
- assert.strictEqual(h.ctx.getChannelAlertSoundsByUser(streamer.id).donation_sound_url, snd);
- assert.ok(!h.live.effects.slice(effectsBefore).some((e) => e.name === 'alert-sound'));
-});
-
-t('back to live: waits for the mirror, refused while Live refuses it, then Live writes again', async () => {
- await dbOp('addChannelModerator', [channelId, viewer.id, streamer.id]);
- assert.ok(h.db.mirrorPending('channel_moderators') > 0);
- h.live.mirrorStatus = 409;
- await assert.rejects(op('setTableAuthority', ['channel_moderators', 'live']), /not in Live yet/);
- assert.strictEqual(h.db.tableAuthority('channel_moderators'), 'chat', 'nothing moved');
- h.live.mirrorStatus = 200;
- assert.deepStrictEqual(await op('setTableAuthority', ['channel_moderators', 'live']), { table: 'channel_moderators', authority: 'live', mirror_pending: 0 });
- assert.ok(h.live.mirror.some((c) => c.table === 'channel_moderators' && c.row && c.row.user_id === viewer.id), 'Chat’s newest row reached Live first');
- await assert.rejects(dbOp('addChannelModerator', [channelId, streamer.id, streamer.id]), /written by Live/);
- assert.strictEqual((await op('stagedApply', [[{ table: 'channel_moderators', op: 'delete', pk: { id: 8 } }]])).applied, 1, 'Live’s changes apply again');
- assert.strictEqual(stagedPending(), 0, 'and are not mirrored back');
-});
-
-t('scripts/table-authority.js: status; set only with --force; never back to live with changes queued', async () => {
- const run = (...args) => spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'table-authority.js'), ...args], { encoding: 'utf8', env: { ...process.env } });
- let r = run();
- assert.strictEqual(r.status, 0, r.stderr);
- const st = JSON.parse(r.stdout).tables;
- assert.deepStrictEqual([st.channel_moderators.authority, st.emotes.authority], ['live', 'chat']);
- assert.strictEqual(run('set', 'emotes', 'live').status, 2, 'needs --force');
- await dbOp('createEmote', [{ user_id: viewer.id, code: 'late', url: '/e/late.png' }]);
- r = run('set', 'emotes', 'live', '--force');
- assert.strictEqual(r.status, 2);
- assert.match(r.stderr, /have not reached Live yet/);
- await h.mirrorRelay.flush();
- r = run('set', 'emotes', 'live', '--force');
- assert.strictEqual(r.status, 0, r.stderr);
- assert.deepStrictEqual(JSON.parse(r.stdout), { table: 'emotes', before: 'chat', after: 'live' });
- await h.sleep(1100); // the service's authority cache
- await assert.rejects(dbOp('createEmote', [{ user_id: viewer.id, code: 'later', url: '/e/later.png' }]), /written by Live/);
-});
-
-t.run(async () => { if (h) await h.close(); });
@@ -22,6 +22,7 @@ t('boot Chat against a stub Live', async () => {
@@ -22,6 +22,7 @@ t('boot Chat against a stub Live', async () => {
bob = h.addUser('bob');
bob = h.addUser('bob');
admin = h.addUser('boss', { role: 'admin' });
admin = h.addUser('boss', { role: 'admin' });
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
+ h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)
streamId = h.addStream(streamer.id, channelId, { managed: { id: 71, slug: 'main-slot', title: 'Main slot' } });
streamId = h.addStream(streamer.id, channelId, { managed: { id: 71, slug: 'main-slot', title: 'Main slot' } });
await h.ctx.sync();
await h.ctx.sync();
assert.ok(h.ctx.getStreamById(streamId), 'stream projected');
assert.ok(h.ctx.getStreamById(streamId), 'stream projected');
@@ -216,15 +217,15 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asy
@@ -216,15 +217,15 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asy
await h.sleep(1100);
await h.sleep(1100);
modWs.sendJson({ type: 'chat', message: '/clear' });
modWs.sendJson({ type: 'chat', message: '/clear' });
await aliceWs.next((m) => m.type === 'clear');
await aliceWs.next((m) => m.type === 'clear');
- // /slow: slowmode event + system line to the room, persisted through Live. It applies to
- // viewers; the room's moderators keep only the 1 s flood limit (the check runs before commands,
- // so a slowed moderator could not even turn it off).
+ // /slow: slowmode event + system line to the room, persisted in Chat's own settings row (C-04).
+ // It applies to viewers; the room's moderators keep only the 1 s flood limit (the check runs
+ // before commands, so a slowed moderator could not even turn it off).
await h.sleep(1100);
await h.sleep(1100);
modWs.sendJson({ type: 'chat', message: '/slow 5' });
modWs.sendJson({ type: 'chat', message: '/slow 5' });
assert.strictEqual((await aliceWs.next((m) => m.type === 'slowmode')).seconds, 5);
assert.strictEqual((await aliceWs.next((m) => m.type === 'slowmode')).seconds, 5);
await aliceWs.next((m) => m.type === 'system' && m.message === 'Slow mode enabled: 5s between messages');
await aliceWs.next((m) => m.type === 'system' && m.message === 'Slow mode enabled: 5s between messages');
await h.sleep(100);
await h.sleep(100);
- assert.ok(h.live.effects.some((e) => e.name === 'channel-settings' && e.body.fields.slow_mode_seconds === 5 && e.body.actor_user_id === mod.id));
+ assert.strictEqual(h.ctx.getChannelModerationSettings(channelId).slow_mode_seconds, 5, 'saved on Chat’s own row');
assert.strictEqual(h.chatServer.slowModeByStream.get(streamId), 5000);
assert.strictEqual(h.chatServer.slowModeByStream.get(streamId), 5000);
// Every action is logged (and announced as an internal moderation event).
// Every action is logged (and announced as an internal moderation event).
const actions = h.db.all('SELECT action_type FROM moderation_actions ORDER BY id').map((r) => r.action_type);
const actions = h.db.all('SELECT action_type FROM moderation_actions ORDER BY id').map((r) => r.action_type);