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.

OP OpenVibers committed 29 Sept 2026 · f7bf6a6c3b · 33 files, +174 −1207


Changes

ddiff --git a/README.md b/README.md
iindex 6cfbe0d..497060b 100644
---- a/README.md
++++ b/README.md
@@@ -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 can
stop 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 moved
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
-- `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`,
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.
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,s
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/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/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 REST
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/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
ddiff --git a/docs/cutover.md b/docs/cutover.md
iindex 5b2fb3e..5d78bfa 100644
---- a/docs/cutover.md
++++ b/docs/cutover.md
@@@ -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 of
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
ddiff --git a/docs/parity.md b/docs/parity.md
iindex 880363e..13f09f6 100644
---- a/docs/parity.md
++++ b/docs/parity.md
@@@ -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 VIP
does not count and anonymous viewers cannot chat. Chat asks Live `GET
ddiff --git a/docs/staged-tables-cutover.md b/docs/staged-tables-cutover.md
ddeleted file mode 100644
iindex 6e82b34..0000000
---- a/docs/staged-tables-cutover.md
++++ /dev/null
@@@ -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.
ddiff --git a/scripts/import-from-live.js b/scripts/import-from-live.js
iindex 0d7ae73..b488b4a 100644
---- a/scripts/import-from-live.js
++++ b/scripts/import-from-live.js
@@@ -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++;
}
};
ddiff --git a/scripts/parity-check.js b/scripts/parity-check.js
iindex 3123220..d0de131 100644
---- a/scripts/parity-check.js
++++ b/scripts/parity-check.js
@@@ -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 };
ddiff --git a/scripts/table-authority.js b/scripts/table-authority.js
ddeleted file mode 100644
iindex 9094241..0000000
---- a/scripts/table-authority.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/ai/chat-ai.js b/server/ai/chat-ai.js
iindex 47a86b1..469f957 100644
---- a/server/ai/chat-ai.js
++++ b/server/ai/chat-ai.js
@@@ -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 { /* */ }
}
ddiff --git a/server/app.js b/server/app.js
iindex 538ffae..502cd5f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -147,8 +147,6 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
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 },
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,
ddiff --git a/server/bridge/live-bridge.js b/server/bridge/live-bridge.js
iindex f7574ca..a07aadd 100644
---- a/server/bridge/live-bridge.js
++++ b/server/bridge/live-bridge.js
@@@ -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 memory
const REF_KEEP_MS = 24 * 3600 * 1000; // in bridge_refs
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') }) {
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 };
ddiff --git a/server/bridge/live-mirror.js b/server/bridge/live-mirror.js
iindex 4d5b0c4..60fac8a 100644
---- a/server/bridge/live-mirror.js
++++ b/server/bridge/live-mirror.js
@@@ -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), lo
for (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; }
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 063ff71..5163559 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -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']);
ddiff --git a/server/chat/channel-mod-routes.js b/server/chat/channel-mod-routes.js
iindex 56d2b3d..f65311f 100644
---- a/server/chat/channel-mod-routes.js
++++ b/server/chat/channel-mod-routes.js
@@@ -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' });
}
});
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 8e7d2b7..c285fc2 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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.
*/
ddiff --git a/server/chat/emotes-routes.js b/server/chat/emotes-routes.js
iindex c5c9718..dc59b9d 100644
---- a/server/chat/emotes-routes.js
++++ b/server/chat/emotes-routes.js
@@@ -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' });
}
});
ddiff --git a/server/chat/sounds-routes.js b/server/chat/sounds-routes.js
iindex 39eb03f..324d8e6 100644
---- a/server/chat/sounds-routes.js
++++ b/server/chat/sounds-routes.js
@@@ -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) {
ddiff --git a/server/db/database.js b/server/db/database.js
iindex f6469b9..15b7c2a 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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, 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 ─────────────────────────────────────────────────────
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 AI
addChannelModerator,
removeChannelModerator,
upsertChannelModerationSettings,
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex 7c35d17..0d7c9d4 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -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. Staged copies of chat-target tables that Live still writes in W6 (channel moderators
---- and settings, emotes, user tags, chat-AI summaries and timeline). The importer fills
---- them; Chat reads the live values through server/live-context.js until the routes that
---- write them move. See table_authority.
++-- 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(created
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);
---- ── 2. Staged: chat targets Live still writes in W6 ─────────────
++-- 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);
---- Who writes each baseline chat table in this wave (docs/cutover.md).
--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.
ddiff --git a/server/index.js b/server/index.js
iindex bbd3695..367d426 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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));
ddiff --git a/server/live-context.js b/server/live-context.js
iindex f58e5c1..c62a823 100644
---- a/server/live-context.js
++++ b/server/live-context.js
@@@ -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(chan
function 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) {
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 0f835e9..d627077 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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);
});
ddiff --git a/test/audio-queue.test.js b/test/audio-queue.test.js
iindex 502a8a6..1a34c43 100644
---- a/test/audio-queue.test.js
++++ b/test/audio-queue.test.js
@@@ -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');
ddiff --git a/test/bridge.test.js b/test/bridge.test.js
iindex fcaf4ab..c237310 100644
---- a/test/bridge.test.js
++++ b/test/bridge.test.js
@@@ -190,13 +190,11 @@ t('deploy notices: stored as one rolling global row, shown to everyone, folded o
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);
-- 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);
});
ddiff --git a/test/chat-ai.test.js b/test/chat-ai.test.js
iindex e78c25a..fd5eda9 100644
---- a/test/chat-ai.test.js
++++ b/test/chat-ai.test.js
@@@ -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 write
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 () => {
aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed' } }) } });
db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-40 * 60 * 1000)]);
ddiff --git a/test/chat-apis.test.js b/test/chat-apis.test.js
iindex 36ab5ec..69c468a 100644
---- a/test/chat-apis.test.js
++++ b/test/chat-apis.test.js
@@@ -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/png
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 () => {
-- 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 m
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);
-- 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');
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 8ca7b33..fba253c 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -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 });
ddiff --git a/test/import.test.js b/test/import.test.js
iindex bdd8e87..03bc91c 100644
---- a/test/import.test.js
++++ b/test/import.test.js
@@@ -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 Chat
c.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 h
assert.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 h
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', () => {
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();
ddiff --git a/test/live-context.test.js b/test/live-context.test.js
iindex 436de08..a73ecb7 100644
---- a/test/live-context.test.js
++++ b/test/live-context.test.js
@@@ -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 poli
assert.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();
ddiff --git a/test/parity.test.js b/test/parity.test.js
iindex d66d3de..9a00f3c 100644
---- a/test/parity.test.js
++++ b/test/parity.test.js
@@@ -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' });
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 not
await 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 enforc
assert.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();
ddiff --git a/test/security.test.js b/test/security.test.js
iindex 7df2255..0d304dc 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -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 IP
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)
-- 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 IP
await 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();
});
ddiff --git a/test/sounds-tts.test.js b/test/sounds-tts.test.js
iindex 478c9de..3fad9f6 100644
---- a/test/sounds-tts.test.js
++++ b/test/sounds-tts.test.js
@@@ -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 co
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());
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 () => {
ddiff --git a/test/staged-tables.test.js b/test/staged-tables.test.js
ddeleted file mode 100644
iindex 6c6002a..0000000
---- a/test/staged-tables.test.js
++++ /dev/null
@@@ -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(); });
ddiff --git a/test/ws-protocol.test.js b/test/ws-protocol.test.js
iindex 277f51b..111f88b 100644
---- a/test/ws-protocol.test.js
++++ b/test/ws-protocol.test.js
@@@ -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', asy
await 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);