openvibers/openvibe.chat Commits
Retire the Live read mirror: the sender, its config and flush script are gone and migration 0006 drops the capture triggers (Live reads Chat's internal reads since Live#31) (#25)
Changes
@@@ -44,10 +44,6 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000#LIVE_TIMEOUT_MS=4000# /ready reads the Live sync degraded once its last clean pass is older than this (ms)#LIVE_SYNC_STALE_MS=60000--# Copy Chat's changes into Live's chat tables (read mirror for Live's remaining readers). ON from--# the cutover; OFF for rehearsals (Live refuses mirror writes unless it runs with CHAT_AUTHORITY=chat).--LIVE_MIRROR=0--#LIVE_MIRROR_INTERVAL_MS=1000# Chat preferences in the OpenVibe.Network user module chat.preferences (grants: chat network.modules.read# and network.modules.write on chat.preferences, audience openvibe.network). Cached per person (ms).@@@ -5,7 +5,7 @@**Status:** alpha — deployed. Since the cutover on 2026-09-23 at 02:03 UTC (`docs/cutover.md`),Chat is the authority for openvibe.live's chat in every mode: the service runs on `openvibe-ovh`(unit `openvibe-chat`, 127.0.0.1:4400), Live runs with `CHAT_AUTHORITY=chat`, mounts no local chat--routes and keeps a read mirror, and chat events go to OpenVibe.Events. `openvibe.chat` is Chat's own++routes, reads Chat's internal read API for the chat data it needs, and chat events go to OpenVibe.Events. `openvibe.chat` is Chat's ownsite (global chat, messages, rooms,settings; the service manifest records it live since 2026-09-24).**Domain:** `openvibe.chat` ([deploy/nginx/openvibe.chat.conf](deploy/nginx/openvibe.chat.conf)); Chat@@@ -61,13 +61,16 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th(`/internal/chat/*`, `docs/chat-ingress.md`); the bridge proxy at `POST /internal/live/calls` isretired (T3 J3a).- **Own database on PostgreSQL** (ADR-035, plan T3) with Live's tables and ids, the Network subject on new-- rows, a transactional events outbox and a read mirror back into Live. `server/db/database.js` serves++ rows, and a transactional events outbox. Chat's changes were copied into Live's tables by a read++ mirror until 2026-10-05: since Live #31 nothing in Live reads its mirror-filled chat tables in chat++ mode (every read goes to Chat's internal read API, `server/chat/internal-reads.js`), so the mirror —++ and the cutover rollback through it — is retired. `server/db/database.js` servesthrough `openvibe-sdk/db`: production needs `DATABASE_URL` (the runtime role, DML only, throughPgBouncer) and `DATABASE_DIRECT_URL` (the owner, which applies `migrations/` at boot); without`DATABASE_URL` outside production it runs on an embedded PGlite in `data/pglite` (`CHAT_PGLITE_DIR`).`migrations/0001_initial.sql` is the whole schema (timestamps stay SQLite-format text through-- `ov_now()`/`datetime()`); its triggers on the twelve Chat tables queue Live's read mirror-- (`live_mirror_outbox`), skipped where a transaction sets `ov.mirror_skip = '1'` (the importers).++ `ov_now()`/`datetime()`); `0006_stop_live_mirror_triggers.sql` dropped the twelve read-mirror capture++ triggers, and `live_mirror_outbox` stays, idle, until a contract migration drops it.`scripts/import-sqlite-to-pg.js` moves the SQLite data over once, with a per-table count and checksumreport (`docs/cutover.md`). `VALKEY_URL`/`VALKEY_PREFIX` put the per-actor rate-limit counters onValkey instead of this process. No part of the service reads SQLite; the one-time tools that read a@@@ -117,7 +120,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th| Concern | Where it lives | How Chat reaches it || --- | --- | --- |--| Messages, DMs, channel sounds, TTS voice overrides, relay users, first chats, IP-approval queue, moderation log | **Chat** (authority from the cutover) | its own database on PostgreSQL since the 2026-09-23 cutover (`DATABASE_URL` through `openvibe-sdk/db`, `migrations/`, the one-time `scripts/import-sqlite-to-pg.js`; `CHAT_DB_PATH` is read only by that importer); Live keeps a read mirror (`POST /internal/chat-effects/mirror`) |++| Messages, DMs, channel sounds, TTS voice overrides, relay users, first chats, IP-approval queue, moderation log | **Chat** (authority from the cutover) | its own database on PostgreSQL since the 2026-09-23 cutover (`DATABASE_URL` through `openvibe-sdk/db`, `migrations/`, the one-time `scripts/import-sqlite-to-pg.js`; `CHAT_DB_PATH` is read only by that importer); Live reads them through Chat's internal read API (the read mirror was retired on 2026-10-05 and the rollback through it is gone) || 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** | the Chat-owned ingress `/internal/chat/*` (`docs/chat-ingress.md`), presence `GET /internal/chat/presence` (`chat.presence.read`); the bridge `/internal/live/*` is retired (T3 J3a) |@@@ -378,7 +381,6 @@ npm run test:pg # the same on PostgreSQL + PgBouncer + Valkey (OV_# OV_TEST_VALKEY_URL: openvibe-sdk scripts/test-services.sh up)node scripts/import-from-live.js --live-db /tmp/live-snapshot.db # dry run; --apply writesnode scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"--node scripts/mirror-flush.js # drain queued mirror rows to Live without the servicenode 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)N1_LIVE_REF=<live sha> npm run n-1:record # after a deploy: the N-1 fixtures from the deployed commit@@@ -397,8 +399,8 @@ and commit the fixtures.`/ready` (openvibe-shared/ready shape: `status` ready/degraded/not_ready and `checks`) is 503 onlywhen the `db` check (a read of `chat_messages`) fails. `live_sync` is optional: it reads degraded whenthe last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a sync step has missed--its own interval by that much. It also reports the mirror queue, whether events are relayed, and the--Events consumer's counters.++its own interval by that much. It also reports whether events are relayed and the Events consumer's++counters.Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-chat.locations.conf`,`/etc/openvibe/chat.env`. The whole switch-over — rehearsal, import, parity checks, nginx, the flag,rollback — is `docs/cutover.md`.@@@ -443,7 +445,6 @@ server/app.js Live's guards for these routes: CORS, /api rate limitserver/live-context.js the only module that talks to Live (interface in its header)server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice; plus the T3 APIs: emotes-routes, channel-mod-routes, internal-moderation (Live's read API), alert-soundsserver/auth/ token resolution through Live; the chat subset of Live's permissions--server/bridge/ Chat → Live read mirror (live-mirror.js)server/events/outbox.js events.event-envelope@1 outbox and relayserver/events/consumer.js POST /internal/events: Chat's Events subscriptions (live.release.deployed, network.module.updated)server/events/subscriptions.js creates them at boot when missing; list/disable/enable for scripts/subscribe-events.js@@@ -453,7 +454,7 @@ server/calls/ moved from Live: the call server (/ws/call), its RESTserver/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service tokenserver/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/)--scripts/ import-from-live, parity-check, parity, mirror-flush, migrate-chat-preferences, subscribe-events, n-1-record++scripts/ import-from-live, parity-check, parity, migrate-chat-preferences, subscribe-events, n-1-recorddocs/ cutover.md, calls-cutover.md, parity.md, live-patch.diff, capabilities-proposal/```@@@ -479,7 +480,7 @@ queue are still Live's and reached through `live-context`. Calls are ported withIntroduced here and registered in `openvibe-contracts` v0.13.0:`chat.live_bridge.write`, `chat.presence.read` (owner chat) and `live.chat_context.read`,--`live.chat_effects.write`, `live.chat_mirror.write` (owner live). `chat.live_bridge.write` now only++`live.chat_effects.write` (owner live). `chat.live_bridge.write` now onlyguards Live's stream hooks for calls (`/internal/calls/stream-channel`) and `chat.presence.read` thepresence snapshot (`GET /internal/chat/presence`); the bridge routes they were made for are retired(T3 J3a). Enforced here as well: `chat.message.send` (`POST /internal/chat/messages`). It is owned@@@ -500,7 +501,7 @@ Called elsewhere, as the service principal `chat` ([server/net/service-auth.js](| Service | Grant | Why ||---|---|---|--| OpenVibe.Live | `live.chat_context.read`, `live.chat_effects.write`, `live.chat_mirror.write` | the chat context Live still owns (bans, channel settings), effects, the read mirror |++| OpenVibe.Live | `live.chat_context.read`, `live.chat_effects.write` | the chat context Live still owns (bans, channel settings) and effects || OpenVibe.Events | `events.event.publish`, `events.subscription.manage` | the outbox relay; the subscriptions above || OpenVibe.Network | `network.modules.read`, `network.modules.write` | chat preferences and the other chat user modules || OpenVibe.VIP | `vip.entitlement.check` | subscriber badges (fails closed) |@@@ -560,8 +561,7 @@ The unit is `openvibe-chat.service` on `127.0.0.1:4400`, the env file `/etc/openfor openvibe.chat and [deploy/nginx/openvibe.live-chat.locations.conf](deploy/nginx/openvibe.live-chat.locations.conf),included in openvibe.live's vhost, for Live's chat paths. After a deploy, record the N-1 fixtures(`npm run n-1:record`). Chat is required in every mode and Live's chat routes are retired: a--rollback is a Chat release rollback, not a switch back to Live (`docs/cutover.md`;--`scripts/mirror-flush.js` drains a stuck mirror).++rollback is a Chat release rollback, not a switch back to Live (`docs/cutover.md`).Rollback: ovhost puts the previous sha back by itself when `/ready` does not answer 2xx after therestart; afterwards `sudo ovhost rollback chat --to <sha>`. Nothing blocks a rollback: the schema@@@ -12,7 +12,6 @@"capabilitiesUsed": ["live.chat_context.read","live.chat_effects.write",-- "live.chat_mirror.write","events.event.publish","events.subscription.manage","vip.entitlement.check",@@@ -24,15 +23,15 @@"capabilitiesRegistered": true,"events": ["chat.message.created", "chat.message.deleted", "chat.dm.created", "chat.moderation.action", "chat.room.message.created", "chat.room.message.deleted"],"eventsConsumed": "live.release.deployed, network.module.updated, network.user.token_valid_after, vip.membership.changed, network.block.changed, network.subject.merged, network.account.export_requested and network.account.deleted at POST /internal/events (signature v2, openvibe-sdk inbox); subscriptions created at boot when missing (CHAT_EVENTS_SECRET and the Network grant chat events.subscription.manage openvibe.events)",-- "liveAuthorityFlag": "CHAT_AUTHORITY=chat is required in every mode since the cutover (2026-09-23 02:03 UTC): Live mounts no local chat routes and there is no rollback to Live; Live's chat tables are a read mirror Chat keeps current for Live's remaining readers",++ "liveAuthorityFlag": "CHAT_AUTHORITY=chat is required in every mode since the cutover (2026-09-23 02:03 UTC): Live mounts no local chat routes and there is no rollback to Live; Live reads Chat's chat tables through Chat's internal read API (the read mirror was retired on 2026-10-05, so the cutover rollback through it no longer exists)","vip": "Member badges from OpenVibe.VIP (server/vip/badges.js: entitlements/check with product chat, cached 60 s, never blocks sending, fails closed; vip.membership.changed drops the cache). Needs the Network grant chat vip.entitlement.check openvibe.vip","preferences": "Chat preferences in the Network user module chat.preferences, plus chat.tts_defaults, chat.dm_settings and chat.presence_prefs (server/prefs/: /api/chat/preferences and siblings with Chat's service token, cached 60 s, If-Match on every write; scripts/migrate-chat-preferences.js moves the choices in Live's user_preferences). Needs the grants chat network.modules.read/write","calls": "Live's call server, call routes and stream hooks ported to server/calls/ with a calls lifecycle table (WS-I task 1). Off unless CHAT_CALLS=1; the switch is CHAT_CALLS=1, Live CALLS_AUTHORITY=chat and the nginx include deploy/nginx/calls/openvibe.live-calls.locations.conf (docs/calls-cutover.md)","deployed": true,"deployment": "Deployed with `sudo ovhost deploy chat`: /opt/openvibe.chat, unit openvibe-chat, 127.0.0.1:4400 (loopback); public through the openvibe.chat vhost and openvibe.live's nginx include for the chat paths; EVENTS_URL set (chat.message.created reaches OpenVibe.Events); restore drill passed 2026-09-23",-- "updated": "2026-10-04",++ "updated": "2026-10-05","features": [-- "Live's global, stream and channel chat (authority since 2026-09-23, Live keeps a read mirror)",++ "Live's global, stream and channel chat (authority since 2026-09-23; Live reads Chat's internal read API)","DMs and group DMs, participants only","rooms: public and private, roles, invitations, slow mode, unread counts","moderation: bans, timeouts, purges, filters, slow, sub-only and member-only modes, audited log access",@@@ -1,7 +1,7 @@[Unit]Description=OpenVibe.Chat — rooms, messages, DMs, TTS and sounds, moderation (/ws/chat and the chat API of openvibe.live)# Live and Network should be up first; Chat serves from its caches if they are slow, and queues--# its writes to Live (mirror, effects) until Live answers.++# its side effects to Live until Live answers.After=network.targetWants=openvibe-live.service openvibe-network.service@@@ -16,7 +16,7 @@ Environment=PORT=4400Environment=HOST=127.0.0.1Environment=PATH=/usr/local/bin:/usr/bin:/binExecStart=/usr/bin/env node /opt/openvibe.chat/server/index.js--# Tells clients the chat restarts, flushes the Live mirror, closes sockets.++# Tells clients the chat restarts and closes sockets.KillSignal=SIGTERMTimeoutStopSec=15Restart=always@@@ -20,7 +20,7 @@ a stream that does not exist is 404: do not retry a 4xx.| `POST /internal/chat/invalidate` | `chat.cache.invalidate` | `{key,user?,user_data?,approvals?,bans?,channel?}`; a cache hint, returns `{ok:true}`. Include a typed `user_data` object when Live edited the account, so Chat updates sockets and stored message names. || `GET /internal/chat/presence` | `chat.presence.read` | Who is connected where: counts, per-stream viewers, slow modes, users/anons with their addresses (`server/chat/presence.js`). |--Live's reads of Chat's tables, in place of its mirrored copy (`server/chat/internal-reads.js`, plan++Live's reads of Chat's tables, in place of the read mirror retired on 2026-10-05 (`server/chat/internal-reads.js`, planT3 J4b N1–N5, contracts in `openvibe-contracts` 0.95.0). Unknown or malformed parameters are 400;deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive). Every answer is`{ok:true,...}` or `{ok:false,error}`.@@@ -38,7 +38,7 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive| `GET /internal/chat/sounds?channel_owner_id` (also `/sounds/count`) | `chat.sounds.read` | `{count}` of the channel's sounds. || `GET /internal/chat/sounds?pending_asset=1` | `chat.sounds.read` | `{sounds}` not yet on Media (`media_asset_id` null), by id; optional `channel_owner_id`, `after_id`, `limit` ≤ 500 (default 100). || `GET /internal/chat/sounds/by-command?channel_id&command` | `chat.sounds.read` | `{sound}`: the approved (`is_approved=1`) channel sound `command` plays, one at random when several match, or 404 — Live's `getChannelSoundByCommand`. `command` is trimmed, lowercased and has leading `!` stripped (≤ 120); `channel_id` is the channel owner's Live user id (`channel_sounds.channel_owner_id`); same projection as `/sounds`. |--| `POST /internal/chat/sounds/asset` | `chat.sounds.write` | `{id,media_url,media_asset_id}` records Live's upload (`migrations/0004_channel_sound_media.sql`); repeating it is a 200 no-op; an unknown sound is 404. Until Live writes here, the read mirror leaves Live's own asset columns alone for a sound Chat has no asset for. |++| `POST /internal/chat/sounds/asset` | `chat.sounds.write` | `{id,media_url,media_asset_id}` records Live's upload (`migrations/0004_channel_sound_media.sql`); repeating it is a 200 no-op; an unknown sound is 404. For a sound Chat has no Media asset for, Live's own asset columns stay as its asset sync left them (the read mirror that used to preserve them was retired on 2026-10-05). |`/internal/chat/events` is a narrow transient event ingress, not a generic ChatServer call.`live.release.deployed` continues through OpenVibe.Events; there is no deploy-notice endpoint.@@@ -5,8 +5,10 @@ socket and the chat REST prefixes to OpenVibe.Chat (127.0.0.1:4400), and Live ru`CHAT_AUTHORITY=chat`. Nothing in Live's browser code changes. Chat is required in every mode:Live's local chat routes and mounts are inert under the flag and are being removed, and the rollbackthat returned chat to Live is retired, so there is no unset-flag mode in which Live serves chat--again (see [Rollback](#rollback)). The Live-side read mirror stays only for Live's own readers,--until a later wave retires them.++again (see [Rollback](#rollback)). The Live-side read mirror was retired on 2026-10-05: since++Live #31 nothing in Live reads its mirror-filled chat tables in chat mode — every read goes to++Chat's internal read API — so Chat no longer copies its writes into Live, and the cutover rollback++through the mirror no longer exists (see [Rollback](#rollback)).The cutover itself ran on 2026-09-23; the rehearsal, flip and check steps below record that deploy.@@@ -78,22 +80,22 @@ writers are repointed; deploy the three new capability grants before that Live r| 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 (the bridge at the cutover; Chat's ingress since T3 J3a). |++| `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 read a mirror of them (Chat → `POST /internal/chat-effects/mirror`) until 2026-10-05, when it moved to Chat's internal read API (`server/chat/internal-reads.js`); Live's remaining writers (AI viewers, relays, donations, `/api/mod`, `/api/channels` deletes, emote renames) forward to Chat (the bridge at the cutover; Chat's ingress since T3 J3a). || `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 writers call Chat's APIs and ingress (the bridge op `db` they used is retired, T3 J3a). 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`,--`import_hold`, `import_runs`. The retired bridge's `bridge_applied` / `bridge_refs` are dropped++Live data maintained by `live-context` (never authority), `events_outbox`, `live_mirror_outbox` (the retired read mirror's queue: no code path writes or drains++it since 2026-10-05; the table stays until a later contract migration drops it), `import_hold`,++`import_runs`. The retired bridge's `bridge_applied` / `bridge_refs` are dropped(`migrations/0005_drop_bridge_tables.sql`).## Prerequisites1. **Contracts.** Register `docs/capabilities-proposal/*.json` in OpenVibe.Contracts (a new`openvibe-contracts` release): `live.chat_context.read`, `live.chat_effects.write`,-- `live.chat_mirror.write` (owner live), `chat.live_bridge.write`, `chat.presence.read` (owner-- chat). Add the live ones to `manifests/services/live.json`; set `manifests/services/chat.json` to++ `chat.live_bridge.write`, `chat.presence.read` (owner chat). Add the live ones to `manifests/services/live.json`; set `manifests/services/chat.json` toalpha with `capabilities: ["chat.live_bridge.write", "chat.presence.read"]`,`eventsProduced: ["chat.message.created", "chat.dm.created", "chat.moderation.action"]` and a`contractRanges` that includes the release. Until then `openvibe-contracts-check` reports these@@@ -107,7 +109,7 @@ Live data maintained by `live-context` (never authority), `events_outbox`, `liveafter the script name — Node consumes it as environment, which can point the script atChat's database instead of Network's (`ov_network`).`principal_grants` (client, capability, audience): `chat live.chat_context.read openvibe.live`,-- `chat live.chat_effects.write openvibe.live`, `chat live.chat_mirror.write openvibe.live`,++ `chat live.chat_effects.write openvibe.live`,`live chat.live_bridge.write openvibe.chat`, `live chat.message.send openvibe.chat`,`live chat.presence.read openvibe.chat` (`chat events.event.publish openvibe.events` is alreadya default). Without `chat.message.send`, Chat refused the bridge ops that sent a message (AI@@@ -123,8 +125,7 @@ Live data maintained by `live-context` (never authority), `events_outbox`, `live`/etc/openvibe/live.env`.4. **Chat.** `git clone … /opt/openvibe.chat && cd /opt/openvibe.chat && npm ci --omit=dev`;`/etc/openvibe/chat.env` from `.env.example` (0600): `OV_LIVE_INTERNAL_URL`, Network URLs,-- `BASE_URL=https://openvibe.live`, `LIVE_MIRROR=1`, and-- `EVENTS_URL` if OpenVibe.Events is deployed. The unit sets `CHAT_DB_PATH`, `CHAT_CACHE_DIR`,++ `BASE_URL=https://openvibe.live` and `EVENTS_URL` if OpenVibe.Events is deployed. The unit sets `CHAT_DB_PATH`, `CHAT_CACHE_DIR`,`SOUNDS_PATH=/opt/openvibe.live/data/sounds` and allows writes there. ffmpeg/ffprobe andespeak-ng are the host's (Live uses the same).```bash@@@ -155,9 +156,10 @@ Cross-check: `sqlite3 /tmp/live-rehearsal.db "select count(*) from chat_messagesreport. Run the import again: every `inserted` must be 0 (idempotent).```bash--# 3. a rehearsal Chat on 4401 against production Live, mirror OFF++# 3. a rehearsal Chat on 4401 against production Live (the mirror is retired: reads go to Chat's++# internal read API)sudo -u ubuntu env $(sudo cat /etc/openvibe/chat.env | xargs) PORT=4401 CHAT_DB_PATH=/tmp/chat-rehearsal.db \-- CHAT_CACHE_DIR=/tmp/chat-rehearsal-cache LIVE_MIRROR=0 EVENTS_URL= node server/index.js &++ CHAT_CACHE_DIR=/tmp/chat-rehearsal-cache EVENTS_URL= node server/index.js &curl -s http://127.0.0.1:4401/ready | jq # status: "ready" once a Live sync pass succeeded# 4. parity: the same reads answer the same (history pinned before the snapshot)@@@ -199,11 +201,10 @@ streamers; `deploy.sh --wait-idle` waits for none).sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-cutover-2.db"sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-cutover-2.db --chat-db /var/lib/openvibe-chat/chat.db --apply```-- Chat's own new rows are already in Live (mirror) and count as `identical`; `chat_kept` counts rows++ Chat's own new rows were already in Live through the mirror at the cutover and count as `identical`; `chat_kept` counts rowsChat edited since pass 1 (Chat wins); `held` must be 0.4. **Checks.**-- - `curl -s 127.0.0.1:4400/ready | jq` — `status` "ready" (`live.last_success_at` recent), `mirror.pending` near 0,-- `mirror.last_error` null.++ - `curl -s 127.0.0.1:4400/ready | jq` — `status` "ready" (`live.last_success_at` recent).- Live: `sqlite3 …/live.db "select count(*) from chat_bridge_outbox"` stays near 0 (its forwardedwrites are acknowledged); `journalctl -u openvibe-live | grep ChatRemote` shows no refusals.- Browser: join a stream chat, send a message (it appears for others and in history after a@@@ -212,8 +213,8 @@ streamers; `deploy.sh --wait-idle` waits for none).- Live features that read chat: home page chat stats, a recap, AI viewers answering in a teststream, `/api/mod` chat logs and deletes (the deleted line disappears in Chat).- `node scripts/parity-check.js --live http://127.0.0.1:3000 --chat http://127.0.0.1:4400 …` is no-- longer meaningful (Live mounts no chat routes); compare against the mirror instead with-- `sqlite3` counts: Live `chat_messages` ≈ Chat `chat_messages`.++ longer meaningful (Live mounts no chat routes); Live's readers now use Chat's internal read API++ (`server/chat/internal-reads.js`).## Rollback@@@ -226,11 +227,10 @@ unanswered, not served by Live. The nginx include stays in place through any rolare untouched, so chat keeps being served — a socket reconnects after the restart. Nothing blocksthis: the migrations are additive and the previous release reads the same database.2. If Chat cannot run at all, chat is down until it is restored: Live has no copy of the sockets,-- the DMs, the TTS and sound queue or the moderation log to serve from. Live's own readers stay-- correct through the read mirror. Restore `openvibe-chat.service`, then let the mirror drain-- (`curl -s 127.0.0.1:4400/ready | jq .mirror.pending` → 0); when the service still cannot start,-- `cd /opt/openvibe.chat && sudo -u ubuntu env $(sudo cat /etc/openvibe/chat.env | xargs) node-- scripts/mirror-flush.js` (`DATABASE_URL` names Chat's database; exit 0 = drained).++ the DMs, the TTS and sound queue or the moderation log to serve from. There is no rollback++ through the read mirror any more (retired 2026-10-05): Live's readers call Chat's internal read++ API, so while Chat is down those reads fail too. Restore `openvibe-chat.service` and confirm++ `/ready`; there is no mirror queue to drain and no `scripts/mirror-flush.js`.3. Keep the database and the sounds. `/var/lib/openvibe-chat/chat.db` (PostgreSQL after the switchbelow) is the only copy of chat's own state — rooms, DMs and their read state, the audio queue,calls — and the clips under `SOUNDS_PATH` are the only copy of the sound files; keep both for@@@ -281,8 +281,8 @@ Found while moving; deliberately not fixed in this wave (fix after the cutover,## PostgreSQL switch (plan T3, ADR-035)--Chat's own database moves from the SQLite file to PostgreSQL; the Live read mirror and the bridge (since--retired, T3 J3a) were unchanged in meaning. This is a runbook for that one deploy — the schema is `migrations/0001_initial.sql`++Chat's own database moves from the SQLite file to PostgreSQL; the Live read mirror (retired++2026-10-05) and the bridge (retired, T3 J3a) were unchanged in meaning. This is a runbook for that one deploy — the schema is `migrations/0001_initial.sql`(applied by `openvibe-sdk/db` at boot, owner role) and the data moves once.**Prerequisites.** `DATABASE_URL` (the runtime role, through PgBouncer — DML only, no session state) and@@@ -301,13 +301,14 @@ migration has, parents first, ids kept; identity sequences are `setval`'d past ttable is verified (row count + a checksum walked in primary-key order) and printed as one line(`[import] <table> <rows> rows checksum <16 hex> (<ms> ms)`), then the tail(`[import] <n> tables, <rows> rows, ok|FAILED`); the whole report is recorded in `import_runs`. It runs with--`ov.mirror_skip = '1'`, so the imported rows are never queued into Live's read mirror, and ends with++`ov.mirror_skip = '1'`, so the imported rows are never queued into the (retired) Live read mirror's++outbox, and ends with`ANALYZE` as the owner (the runtime role's ANALYZE is a no-op; without statistics the history page plans aSort). A problem (a source column with no target, a verification mismatch) makes it exit 1.**Switch.**--1. On the old release: `curl -s 127.0.0.1:4400/ready | jq .mirror.pending` → 0 (Live has every change), then-- stop the unit (drained: nothing writes the file any more).++1. On the old release: stop the unit (drained: nothing writes the file any more). Before 2026-10-05++ this also waited for `.mirror.pending` → 0; the mirror is retired, so `/ready` reports no mirror.2. Keep the file as it is (`cp -a /var/lib/openvibe-chat/chat.db /var/lib/openvibe-chat/chat.db.pre-pg`) andimport it as the owner (the script applies `migrations/` first, then copies):@@@ -319,14 +320,14 @@ Sort). A problem (a source column with no target, a verification mismatch) makes4. Set `DATABASE_URL` and `DATABASE_DIRECT_URL` (and `VALKEY_URL`, `VALKEY_PREFIX`) in`/etc/openvibe/chat.env`; deploy and start the new release (it applies `migrations/` as the owner at boot:nothing to do, the import's schema is current). `GET /ready`: `db` ok with `max_message_id` equal to the-- file's `select max(id) from chat_messages`, `valkey` ok (skipped without `VALKEY_URL`), `mirror.pending`-- back to 0 once the relay has flushed the first writes.++ file's `select max(id) from chat_messages`, `valkey` ok (skipped without `VALKEY_URL`). (`/ready` no++ longer reports a mirror queue: it was retired on 2026-10-05.)5. From the deployed commit, `npm run n-1:record` and commit `test/fixtures/n-1`: the N-1 test then replaysthis release's PostgreSQL statements again (it reports itself skipped until then).**Rollback** is the previous release plus the retained SQLite file: stop Chat, unset `DATABASE_URL` /`DATABASE_DIRECT_URL` (the old release reads `CHAT_DB_PATH`, which the unit still sets), and start the old--release on `chat.db.pre-pg`. The twelve chat tables Live mirrors lose nothing Live has already seen; rows++release on `chat.db.pre-pg`. The twelve chat tables Live once mirrored lose nothing Live has already seen; rowswritten on PostgreSQL after the switch to Chat's own tables (rooms, calls, the audio queue, DMs' readstate) are not in the file — export them from PostgreSQL first if the rollback comes after real traffic.Keep the SQLite file read-only for the rollback window; do not delete it until the PostgreSQL deploy hasnnew file mode 100644@@@ -0,0 +1,33 @@++-- phase: expand++--++-- The Live read mirror is retired (2026-10-05): Live #31 reads every chat table through Chat's internal reads++-- (docs/chat-ingress.md) and this release deletes the sender (server/bridge/live-mirror.js). The capture++-- triggers 0001_initial.sql put on twelve tables would otherwise keep queueing a row in live_mirror_outbox for++-- every chat write with nothing left to drain it. Dropping them is safe for the previous release too: its sender++-- only ever fed Live's mirror, which nothing reads any more. The (now idle) live_mirror_outbox table itself++-- is dropped by a later contract migration.++DROP TRIGGER IF EXISTS mirror_chat_messages ON chat_messages;++DROP TRIGGER IF EXISTS mirror_dm_conversations ON dm_conversations;++DROP TRIGGER IF EXISTS mirror_dm_participants ON dm_participants;++DROP TRIGGER IF EXISTS mirror_dm_messages ON dm_messages;++DROP TRIGGER IF EXISTS mirror_dm_blocks ON dm_blocks;++DROP TRIGGER IF EXISTS mirror_tts_voice_overrides ON tts_voice_overrides;++DROP TRIGGER IF EXISTS mirror_channel_sounds ON channel_sounds;++DROP TRIGGER IF EXISTS mirror_relay_users ON relay_users;++DROP TRIGGER IF EXISTS mirror_hidden_relay_users ON hidden_relay_users;++DROP TRIGGER IF EXISTS mirror_pending_ip_messages ON pending_ip_messages;++DROP TRIGGER IF EXISTS mirror_stream_first_chats ON stream_first_chats;++DROP TRIGGER IF EXISTS mirror_moderation_actions ON moderation_actions;++++DROP FUNCTION IF EXISTS mirror_chat_messages();++DROP FUNCTION IF EXISTS mirror_dm_conversations();++DROP FUNCTION IF EXISTS mirror_dm_participants();++DROP FUNCTION IF EXISTS mirror_dm_messages();++DROP FUNCTION IF EXISTS mirror_dm_blocks();++DROP FUNCTION IF EXISTS mirror_tts_voice_overrides();++DROP FUNCTION IF EXISTS mirror_channel_sounds();++DROP FUNCTION IF EXISTS mirror_relay_users();++DROP FUNCTION IF EXISTS mirror_hidden_relay_users();++DROP FUNCTION IF EXISTS mirror_pending_ip_messages();++DROP FUNCTION IF EXISTS mirror_stream_first_chats();++DROP FUNCTION IF EXISTS mirror_moderation_actions();ddeleted file mode 100644@@@ -1,25 +0,0 @@--#!/usr/bin/env node--/**-- * Send whatever is still queued in live_mirror_outbox to Live, once, without starting the service.-- * A recovery helper when the service cannot run: Live's readers see every change Chat made-- * (docs/cutover.md). There is no rollback to Live to drain for. Uses the same env as the service-- * (/etc/openvibe/chat.env: DATABASE_URL, DATABASE_DIRECT_URL); LIVE_MIRROR is forced on.-- *-- * node scripts/mirror-flush.js → { sent, pending, error }-- */--'use strict';----process.env.LIVE_MIRROR = '1';--const config = require('../server/config');--const db = require('../server/db/database');--const { createMirror } = require('../server/bridge/live-mirror');----(async () => {-- await db.initDb();-- const mirror = createMirror({ config });-- const before = await mirror.pending();-- const r = await mirror.flush();-- console.log(JSON.stringify({ pending_before: before, ...r }, null, 2));-- await db.close();-- process.exit(r.pending ? 1 : 0);--})().catch((err) => { console.error(err.message); process.exit(3); });@@@ -61,7 +61,7 @@ function getAllowedOrigins() {return allowed;}--function createApp({ chatServer, mirror, relay, events = null, callServer = null }) {++function createApp({ chatServer, relay, events = null, callServer = null }) {const allowedOrigins = getAllowedOrigins();const app = express();app.disable('x-powered-by');@@@ -162,8 +162,6 @@ function createApp({ chatServer, mirror, relay, events = null, callServer = nullconst failed = Object.keys(checks).filter((k) => checks[k].required && checks[k].status !== 'ok');const degraded = Object.keys(checks).filter((k) => !checks[k].required && checks[k].status !== 'ok');const ready = failed.length === 0;-- let pending = null;-- if (mirror && checks.db.status === 'ok') { try { pending = await mirror.pending(); } catch { /* reported by db */ } }res.set('Cache-Control', 'no-store');res.status(ready ? 200 : 503).json({ready,@@@ -175,7 +173,6 @@ function createApp({ chatServer, mirror, relay, events = null, callServer = nullchecks,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,events: {enabled: !!config.events.url,consumer: events ? { enabled: events.enabled, ...events.stats } : null,ddeleted file mode 100644@@@ -1,145 +0,0 @@--/**-- * OpenVibe.Chat → OpenVibe.Live read mirror.-- *-- * 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).-- * Chat is required in every mode and Live's local chat routes are retired, so this is no longer a-- * rollback path: it exists for Live's remaining readers until a later wave retires them. Every-- * change Chat makes to its tables (C-02) is copied into Live's tables of the same name, same ids:-- *-- * - PostgreSQL triggers on the twelve tables (migrations/0001_initial.sql) record each change in-- * live_mirror_outbox in the writing transaction — skipped where the transaction set ov.mirror_skip = '1'-- * (the importers: rows that came from Live are never recorded);-- * - this relay sends batches to Live's POST /internal/chat-effects/mirror (capability-- * live.chat_mirror.write) as { changes: [{ table, op: 'upsert', row } | { table, op: 'delete', pk }] }-- * with the row as it is NOW (so repeated changes to one row collapse into one upsert);-- * - Live applies them idempotently (INSERT … ON CONFLICT DO UPDATE on the columns it has; it-- * refuses unless it runs with CHAT_AUTHORITY=chat, so a rehearsal can never touch production).-- *-- * Rows leave the outbox only when Live acknowledged them; a Live outage just queues. `pending()`-- * must be 0 before Live's chat-table readers (and this mirror) are retired (docs/cutover.md).-- */--'use strict';----const db = require('../db/database');--const serviceAuth = require('../net/service-auth');----const BATCH = 200;--const MAX_BODY = 800 * 1024; // Live parses JSON bodies up to 1 MB--const SCOPE = 'live.chat_mirror.write';----// The row as it is now, per mirrored table (static statements, keyed as db.CHAT_TABLES).--const ROW_SQL = {-- chat_messages: 'SELECT * FROM chat_messages WHERE id = ?',-- dm_conversations: 'SELECT * FROM dm_conversations WHERE id = ?',-- dm_participants: 'SELECT * FROM dm_participants WHERE id = ?',-- dm_messages: 'SELECT * FROM dm_messages WHERE id = ?',-- dm_blocks: 'SELECT * FROM dm_blocks WHERE id = ?',-- tts_voice_overrides: 'SELECT * FROM tts_voice_overrides WHERE identity_key = ?',-- channel_sounds: 'SELECT * FROM channel_sounds WHERE id = ?',-- relay_users: 'SELECT * FROM relay_users WHERE id = ?',-- hidden_relay_users: 'SELECT * FROM hidden_relay_users WHERE id = ?',-- pending_ip_messages: 'SELECT * FROM pending_ip_messages WHERE id = ?',-- stream_first_chats: 'SELECT * FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',-- moderation_actions: 'SELECT * FROM moderation_actions WHERE id = ?',--};----// Live's asset sync still writes media_url/media_asset_id on its own copy until it moves to--// POST /internal/chat/sounds/asset: a sound Chat has no asset for must not blank Live's.--function soundRow(row) {-- const { media_url, media_asset_id, ...rest } = row;-- return media_asset_id == null ? rest : row;--}----function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), log = console } = {}) {-- let timer = null;-- let busy = false;-- let lastError = null;---- async function pending() { return (await db.get('SELECT COUNT(*) AS n FROM live_mirror_outbox'))?.n || 0; }---- async function buildChanges(rows) {-- // Newest state per row: several changes to one key collapse into the last op.-- const byKey = new Map();-- for (const r of rows) byKey.set(`${r.tbl}|${r.pk}`, r);-- const changes = [];-- for (const r of byKey.values()) {-- const cols = db.CHAT_TABLES[r.tbl];-- if (!cols) continue;-- let pk;-- try { pk = JSON.parse(r.pk); } catch { continue; }-- if (r.op === 'delete') { changes.push({ table: r.tbl, op: 'delete', pk }); continue; }-- const row = await db.get(ROW_SQL[r.tbl], cols.map((c) => pk[c]));-- changes.push(row ? { table: r.tbl, op: 'upsert', row: r.tbl === 'moderation_actions' ? db.moderationRow(row) : r.tbl === 'chat_messages' ? db.chatMessageRow(row) : r.tbl === 'channel_sounds' ? soundRow(row) : row } : { table: r.tbl, op: 'delete', pk });-- }-- return changes;-- }---- async function flush() {-- if (!config.live.mirror) return { sent: 0, disabled: true };-- if (busy) return { sent: 0, busy: true };-- busy = true;-- let sent = 0;-- try {-- for (;;) {-- const rows = await db.all('SELECT seq, tbl, op, pk FROM live_mirror_outbox ORDER BY seq LIMIT ?', [BATCH]);-- if (!rows.length) break;-- const changes = await buildChanges(rows);-- // Requests stay under Live's body limit; a batch is acknowledged when all its parts are.-- const parts = [[]];-- let size = 0;-- for (const c of changes) {-- const n = Buffer.byteLength(JSON.stringify(c));-- if (parts[parts.length - 1].length && size + n > MAX_BODY) { parts.push([]); size = 0; }-- parts[parts.length - 1].push(c);-- size += n;-- }-- let failed = false;-- for (const part of parts) {-- let res;-- try {-- res = await fetchImpl(`${config.live.internalUrl}/internal/chat-effects/mirror`, {-- method: 'POST',-- headers: { 'Content-Type': 'application/json', ...(await serviceAuth.headers(config.live.audience, SCOPE)) },-- body: JSON.stringify({ changes: part }),-- signal: AbortSignal.timeout(15000),-- });-- } catch (err) { lastError = err.message; failed = true; break; }-- if (res.status === 401) serviceAuth.invalidate(config.live.audience, SCOPE);-- if (!res.ok) {-- const text = await res.text().catch(() => '');-- lastError = `${res.status} ${text.slice(0, 200)}`;-- failed = true;-- break;-- }-- }-- if (failed) break;-- const maxSeq = rows[rows.length - 1].seq;-- await db.run('DELETE FROM live_mirror_outbox WHERE seq <= ?', [maxSeq]);-- sent += changes.length;-- lastError = null;-- }-- } finally { busy = false; }-- if (lastError) log.warn(`[Mirror] Live mirror waiting (${await pending()} pending): ${lastError}`);-- return { sent, pending: await pending(), error: lastError };-- }---- // The triggers record every change; with the mirror off (rehearsals, tests) nothing is kept, as when the capture-- // was per connection and off: the queue is emptied on the same cadence instead of sent.-- async function discard() { await db.run('DELETE FROM live_mirror_outbox'); }---- function start() {-- if (timer) return;-- if (!config.live.mirror) {-- timer = setInterval(() => { discard().catch((e) => log.warn('[Mirror]', e.message)); }, config.live.mirrorIntervalMs);-- if (timer.unref) timer.unref();-- return;-- }-- timer = setInterval(() => { flush().catch((e) => log.warn('[Mirror]', e.message)); }, config.live.mirrorIntervalMs);-- if (timer.unref) timer.unref();-- }-- function stop() { if (timer) clearInterval(timer); timer = null; }-- return { start, stop, flush, pending, lastError: () => lastError };--}----module.exports = { createMirror };@@@ -80,11 +80,6 @@ module.exports = {live: {internalUrl: strip(process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),audience: 'openvibe.live',-- // Chat's read-mirror of its tables into Live (for Live's own readers; there is no rollback-- // path to Live any more). Switch on at cutover; Live refuses mirror writes unless it runs-- // with CHAT_AUTHORITY=chat, so a rehearsal can never touch production.-- mirror: bool(process.env.LIVE_MIRROR, false),-- mirrorIntervalMs: int(process.env.LIVE_MIRROR_INTERVAL_MS, 1000),requestTimeoutMs: int(process.env.LIVE_TIMEOUT_MS, 4000),// /ready reports the Live sync degraded once its last clean pass is older than this.// The loop ticks every 2 s and the bans and stream projections are due every 10 s, so a@@@ -177,7 +172,7 @@ module.exports = {// itself and calls AI with its OWN service token (client `chat`, audience openvibe.ai, network// grants ai.run.create/ai.run.read on namespace chat.*). `enabled` is the job's switch: off in// development and the test harness, on in production through CHAT_AI_ENABLED=1 (its other jobs-- // are gated the same way — LIVE_MIRROR, EVENTS_URL).++ // are gated the same way — EVENTS_URL).ai: {enabled: bool(process.env.CHAT_AI_ENABLED, false),internalUrl: strip(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'),@@@ -24,7 +24,7 @@ let db = null;let _idTables = null; // tables with an `id` column: an INSERT into one returns it (lastInsertRowid)const _stmts = new Map();--// Tables Chat writes from the cutover on; Live keeps a read mirror of each (server/bridge/live-mirror.js).++// Tables Chat writes from the cutover on; Live reads them through Chat's internal read API.const CHAT_TABLES = {chat_messages: ['id'],dm_conversations: ['id'],@@@ -5,7 +5,7 @@** Boot: database → Live projections (first full sync; Chat serves with whatever it has if Live is* slow) → chat and call WebSocket servers → HTTP app (with the Events consumer, POST /internal/events) →-- * background loops (Live sync, events outbox relay, Live mirror relay) → once listening, Chat's++ * background loops (Live sync, events outbox relay) → once listening, Chat's* Events subscriptions are checked and created if missing (in the background, retried). SIGTERM* tells clients the chat is restarting, closes sockets and exits.*/@@@ -17,14 +17,13 @@ const db = require('./db/database');const ctx = require('./live-context');const chatServer = require('./chat/chat-server');const callServer = require('./calls/call-server');--const { createMirror } = require('./bridge/live-mirror');const { createRelay } = require('./events/outbox');const { createEventsConsumer } = require('./events/consumer');const subscriptions = require('./events/subscriptions');const chatAi = require('./ai/chat-ai');const { limits } = require('./net/actor-limits');const { createValkey } = require('openvibe-sdk/valkey');--const { gracefulStop, within } = require('openvibe-sdk/service');++const { gracefulStop } = require('openvibe-sdk/service');const { createApp } = require('./app');let rejectionsLogged = false;@@@ -37,9 +36,8 @@ async function start() {rejectionsLogged = true;process.on('unhandledRejection', (err) => console.error('[Chat] unhandled rejection:', (err && err.stack) || err));}-- console.log(`[Chat] OpenVibe.Chat starting (db ${config.db.url ? 'postgresql' : 'pglite'}, Live ${config.live.internalUrl}, mirror ${config.live.mirror ? 'on' : 'off'}, events ${config.events.url || 'off'}, consumer ${config.events.secrets.length ? 'on' : 'off'})`);++ console.log(`[Chat] OpenVibe.Chat starting (db ${config.db.url ? 'postgresql' : 'pglite'}, Live ${config.live.internalUrl}, events ${config.events.url || 'off'}, consumer ${config.events.secrets.length ? 'on' : 'off'})`);// PostgreSQL (DATABASE_URL; migrations first, as the owner) or, outside production, an embedded PGlite.-- // The Live mirror's capture is the migration's triggers; with the mirror off its queue is emptied.await db.initDb();// Shared per-actor rate-limit counters on Valkey (ADR-035); without VALKEY_URL they count in this@@@ -55,13 +53,11 @@ async function start() {const server = http.createServer();chatServer.init(server);callServer.init(server);-- const mirror = createMirror({ config });const relay = createRelay({ config });const events = createEventsConsumer({ chatServer, secrets: config.events.secrets });-- const { app, handleUpgrade } = createApp({ chatServer, mirror, relay, events, callServer });++ const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer });server.on('request', app);server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });-- 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; it@@@ -75,9 +71,8 @@ async function start() {// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the stop// steps in order (nothing new starts), drains the HTTP server, runs the close steps, then exits 0. drainMs// bounds the drain; deadlineMs 8000 is the manifest's lifecycle.shutdown.deadlineSeconds (openvibe-contracts-- // manifests/services/chat.json) and deadlineExitCode 0 keeps the hand-written hard timer's exit 0. The mirror-- // flush stays bounded at 3 s (within). The stop steps keep the old shutdown's exact order; a step that throws-- // is logged and the stop goes on.++ // manifests/services/chat.json) and deadlineExitCode 0 keeps the hand-written hard timer's exit 0. The stop++ // steps keep the old shutdown's exact order; a step that throws is logged and the stop goes on.const { stop: shutdown } = gracefulStop({name: 'Chat',server,@@@ -92,18 +87,16 @@ async function start() {}),() => ctx.stop(),() => relay.stop(),-- () => mirror.stop(),() => events.stop(),() => chatAi.stop(),() => subs.stop(),() => { if (valkey) return valkey.close(); },-- () => within(3000, mirror.flush()),() => chatServer.close(),() => callServer.close(),],close: [() => db.close()],});-- return { server, mirror, relay, events, callServer, subscriptions: subs, shutdown };++ return { server, relay, events, callServer, subscriptions: subs, shutdown };}if (require.main === module) {@@@ -11,7 +11,6 @@* openvibe.live —* GET/POST /internal/chat-context/* capability live.chat_context.read* POST /internal/chat-effects/* capability live.chat_effects.write-- * POST /internal/chat-effects/mirror capability live.chat_mirror.write (bridge/live-mirror.js)** Hot path rule: nothing a chat message touches makes a network call. Reads are answered from* - SQLite projections (ctx_users, ctx_streams, ctx_managed_streams, ctx_channels), kept@@@ -105,7 +105,7 @@ async function boot({ env = {} } = {}) {const f = new URLSearchParams(raw);tokenRequests.push({ audience: f.get('audience'), scope: f.get('scope'), client: f.get('client_id') });const cap = String(f.get('scope') || '').split(/\s+/).filter(Boolean);-- return res.end(JSON.stringify({ access_token: serviceToken(cap.length ? cap : ['live.chat_context.read', 'live.chat_effects.write', 'live.chat_mirror.write'], { aud: f.get('audience'), sub: 'svc:chat' }), expires_in: 300 }));++ return res.end(JSON.stringify({ access_token: serviceToken(cap.length ? cap : ['live.chat_context.read', 'live.chat_effects.write'], { aud: f.get('audience'), sub: 'svc:chat' }), expires_in: 300 }));}if (req.url === '/api/.well-known/jwks') return res.end(JSON.stringify({ public_key: keys.publicKey }));res.statusCode = 404; res.end('{}');@@@ -128,8 +128,6 @@ async function boot({ env = {} } = {}) {decor: new Map(), // userId → { cosmetic, tag }anon: new Map(), // ip → { num, first_seen }effects: [], // { name, body }-- mirror: [], // changes-- mirrorStatus: 200,down: false, // Live refuses: every connection is dropped at oncehang: false, // Live hangs: connections are accepted and never answerednextAnon: 1,@@@ -228,13 +226,8 @@ async function boot({ env = {} } = {}) {}if (p.startsWith('/internal/chat-effects/')) {const name = p.slice('/internal/chat-effects/'.length);-- const bad = verify(req, name === 'mirror' ? 'live.chat_mirror.write' : 'live.chat_effects.write');++ const bad = verify(req, 'live.chat_effects.write');if (bad) return send(401, { error: bad });-- if (name === 'mirror') {-- if (live.mirrorStatus !== 200) return send(live.mirrorStatus, { error: 'refused' });-- live.mirror.push(...(body.changes || []));-- return send(200, { ok: true, applied: (body.changes || []).length, skipped: [] });-- }live.effects.push({ name, body });switch (name) {case 'anon': {@@@ -298,7 +291,6 @@ async function boot({ env = {} } = {}) {OV_OAUTH_CLIENT_ID: 'chat',OV_OAUTH_CLIENT_SECRET: 'chat-secret',OV_LIVE_INTERNAL_URL: `http://127.0.0.1:${livePort}`,-- LIVE_MIRROR: '0',EVENTS_URL: '',...env,});@@@ -342,7 +334,6 @@ async function boot({ env = {} } = {}) {const index = require('../server/index');const started = await index.start();h.server = started.server;-- h.mirrorRelay = started.mirror;h.eventsRelay = started.relay;h.eventsConsumer = started.events;h.subscriptions = started.subscriptions;@@@ -404,7 +395,7 @@ async function boot({ env = {} } = {}) {*/h.detach = async () => {try { await h.chatServer.close(); } catch { /* */ }-- try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); } catch { /* */ }++ try { h.ctx.stop(); h.eventsRelay.stop(); } catch { /* */ }await new Promise((r) => h.server.close(() => r()));try { await h.db.close(); } catch { /* */ }h.children = [];@@@ -447,7 +438,7 @@ async function boot({ env = {} } = {}) {h.close = async () => {for (const c of h.children || []) { if (c.exitCode == null && c.signalCode == null) { c.kill('SIGKILL'); await c.exited; } }try { await h.chatServer.close(); } catch { /* */ }-- try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }++ try { h.ctx.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }await new Promise((r) => h.server.close(() => r()));network.close(); liveServer.close(); if (liveServer.closeAllConnections) liveServer.closeAllConnections();try { await h.db.close(); } catch { /* */ }nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++/**++ * The Live read mirror is retired (plan T3, 2026-10-05): Live #31 reads Chat's internal read API++ * (server/chat/internal-reads.js) instead of its mirror-filled copies of Chat's tables, so no Chat++ * code path enqueues into live_mirror_outbox or drains it. The table itself stays until a later++ * contract migration drops it, and migrations/0001_initial.sql still has the per-table triggers++ * that fill it (schema SQL, not an application code path); this guard is about the sender, its++ * config and its enqueue/relay call sites never coming back.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { suite } = require('./helpers');++++const t = suite('no-live-mirror');++const ROOT = path.join(__dirname, '..');++const RETIRED = /live_mirror_outbox|live-mirror|LIVE_MIRROR|createMirror|mirror-flush|mirrorIntervalMs|chat_mirror\.write/;++++function files(dir) {++ const out = [];++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const p = path.join(dir, e.name);++ if (e.isDirectory()) out.push(...files(p));++ else if (/\.(js|mjs|cjs)$/.test(e.name)) out.push(p);++ }++ return out;++}++++t('no server/ or scripts/ code path writes, reads or drains live_mirror_outbox', () => {++ const offenders = [...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'scripts'))]++ .filter((f) => RETIRED.test(fs.readFileSync(f, 'utf8')))++ .map((f) => path.relative(ROOT, f));++ assert.deepStrictEqual(offenders, []);++});++++t('the mirror sender module and the flush script are gone', () => {++ for (const p of ['server/bridge/live-mirror.js', 'scripts/mirror-flush.js']) {++ assert.ok(!fs.existsSync(path.join(ROOT, p)), `${p} is gone`);++ }++});++++t('.env.example no longer offers the retired mirror switches', () => {++ const env = fs.readFileSync(path.join(ROOT, '.env.example'), 'utf8');++ assert.ok(!/LIVE_MIRROR/.test(env), '.env.example still configures LIVE_MIRROR');++});++++t.run();ddeleted file mode 100644@@@ -1,151 +0,0 @@--'use strict';--/**-- * Events outbox (events.event-envelope@1, written in the same transaction as the change; relayed-- * to OpenVibe.Events only when EVENTS_URL is set, with a service token for audience-- * openvibe.events) and the Live read mirror (every change to Chat's tables copied to Live's-- * tables of the same name; held while Live refuses, drained when it accepts).-- */--const assert = require('assert');--const http = require('http');--const { validate } = require('openvibe-contracts');--const { boot, suite } = require('./helpers');----const t = suite('outbox-mirror');--let h, events, eventsPort, alice, bob, streamer, streamId;--const received = [];--let eventsMode = 'ok';----t('boot with the mirror on and a stub OpenVibe.Events', async () => {-- events = http.createServer((req, res) => {-- let raw = '';-- req.on('data', (c) => { raw += c; });-- req.on('end', () => {-- res.setHeader('Content-Type', 'application/json');-- if (eventsMode !== 'ok') { res.statusCode = 503; return res.end('{"error":"down"}'); }-- const { serviceAuth } = require('openvibe-contracts');-- const r = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: h.keys.publicKey, issuer: h.ISS, audience: 'openvibe.events' });-- if (!r.ok || !r.claims.cap.includes('events.event.publish')) { res.statusCode = 401; return res.end('{}'); }-- const body = JSON.parse(raw);-- received.push(...body.events);-- res.statusCode = 201;-- res.end(JSON.stringify({ accepted: body.events.length }));-- });-- });-- eventsPort = await new Promise((r) => events.listen(0, '127.0.0.1', () => r(events.address().port)));-- h = await boot({ env: { LIVE_MIRROR: '1', LIVE_MIRROR_INTERVAL_MS: '3600000', EVENTS_URL: `http://127.0.0.1:${eventsPort}`, EVENTS_RELAY_INTERVAL_MS: '3600000' } });-- streamer = h.addUser('streamer', { role: 'streamer' });-- alice = h.addUser('alice', { subject: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });-- bob = h.addUser('bob');-- streamId = h.addStream(streamer.id, h.addChannel(streamer.id));-- await h.ctx.sync();--});----t('a REST fallback message, a DM and a moderation action each leave one envelope', async () => {-- const sent = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'rest hello' } });-- assert.deepStrictEqual(sent.body, { ok: true });-- const conv = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });-- await h.http('POST', `/api/dm/conversations/${conv.body.conversation.id}/messages`, { token: alice.token, body: { message: 'dm text' } });-- await h.db.logModerationAction({ scope_type: 'site', actor_user_id: alice.id, target_user_id: bob.id, action_type: 'site_timeout', details: { duration: 60 } });-- const rows = await h.db.all('SELECT event_type, event, sent_at FROM events_outbox ORDER BY seq');-- assert.deepStrictEqual(rows.map((r) => r.event_type), ['chat.message.created', 'chat.dm.created', 'chat.moderation.action']);-- for (const r of rows) {-- const env = JSON.parse(r.event);-- const v = validate('events.event-envelope@1', env);-- assert.ok(v.valid, JSON.stringify(v.errors));-- assert.strictEqual(r.sent_at, null);-- }-- const [msg, dm, mod] = rows.map((r) => JSON.parse(r.event));-- assert.strictEqual(msg.visibility, 'public');-- assert.deepStrictEqual(msg.payload.room, { type: 'global' });-- assert.strictEqual(msg.payload.text, 'rest hello');-- assert.strictEqual(dm.visibility, 'subject');-- assert.ok(!JSON.stringify(dm).includes('dm text'));-- assert.deepStrictEqual(dm.payload.participants.map((p) => p.user_id).sort(), [alice.id, bob.id].sort());-- assert.strictEqual(mod.visibility, 'internal');-- assert.deepStrictEqual(mod.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });-- assert.deepStrictEqual(mod.actor.type, 'user');-- assert.deepStrictEqual(dm.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });--});----t('Events down: rows wait with the error; back up: published once, marked sent', async () => {-- eventsMode = 'down';-- await h.eventsRelay.flush();-- const waiting = await h.db.all('SELECT attempts, last_error, sent_at FROM events_outbox');-- assert.ok(waiting.every((r) => r.attempts === 1 && /503/.test(r.last_error) && r.sent_at === null));-- eventsMode = 'ok';-- const r = await h.eventsRelay.flush();-- assert.strictEqual(r.sent, 3);-- assert.strictEqual(received.length, 3);-- assert.ok((await h.db.all('SELECT sent_at FROM events_outbox')).every((x) => x.sent_at));-- assert.strictEqual((await h.eventsRelay.flush()).sent, 0, 'nothing twice');-- assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.events' && x.scope === 'events.event.publish'));--});----t('mirror: every change to Chat’s tables reaches Live, newest state per row', async () => {-- const id = Number((await h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'to be deleted', message_type: 'chat' })).lastInsertRowid);-- await h.db.deleteChatMessage(id, streamer.id);-- await h.db.recordFirstChat(`user:${bob.id}`, streamer.id);-- await h.mirrorRelay.flush();-- const byTable = (tbl) => h.live.mirror.filter((c) => c.table === tbl);-- const msgChanges = byTable('chat_messages').filter((c) => c.row && c.row.id === id);-- assert.strictEqual(msgChanges.length, 1, 'insert + update collapsed into one upsert');-- assert.strictEqual(msgChanges[0].row.is_deleted, 1);-- assert.strictEqual(msgChanges[0].row.channel_user_id, streamer.id);-- assert.ok(byTable('chat_messages').some((c) => c.row && c.row.message === 'rest hello'));-- assert.ok(byTable('dm_messages').length === 1 && byTable('dm_participants').length === 2 && byTable('dm_conversations').length >= 1);-- assert.ok(byTable('moderation_actions').length === 1);-- assert.deepStrictEqual(byTable('stream_first_chats')[0].row.chatter_key, `user:${bob.id}`);-- assert.strictEqual(await h.mirrorRelay.pending(), 0);-- // A delete travels as a delete.-- const dmId = (await h.db.get('SELECT id FROM dm_messages')).id;-- const conv = (await h.db.get('SELECT conversation_id FROM dm_messages')).conversation_id;-- assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${conv}/messages/${dmId}`, { token: alice.token })).status, 200);-- await h.mirrorRelay.flush();-- assert.deepStrictEqual(h.live.mirror.at(-1), { table: 'dm_messages', op: 'delete', pk: { id: dmId } });--});----t('mirror: a channel sound without a Media asset leaves Live’s asset columns alone', async () => {-- const soundId = Number((await h.db.run(`INSERT INTO channel_sounds (channel_owner_id, command, url) VALUES (?, 'mirrorhonk', '/sounds/m.mp3')`, [streamer.id])).lastInsertRowid);-- await h.mirrorRelay.flush();-- const sent = h.live.mirror.filter((c) => c.table === 'channel_sounds' && c.row && c.row.id === soundId);-- assert.strictEqual(sent.length, 1);-- assert.ok(!('media_url' in sent[0].row) && !('media_asset_id' in sent[0].row), JSON.stringify(sent[0].row));-- await h.db.run('UPDATE channel_sounds SET media_url = ?, media_asset_id = ? WHERE id = ?', ['https://media.test/s/9', 9, soundId]);-- await h.mirrorRelay.flush();-- const row = h.live.mirror.filter((c) => c.table === 'channel_sounds' && c.row && c.row.id === soundId).at(-1).row;-- assert.strictEqual(row.media_url, 'https://media.test/s/9');-- assert.strictEqual(Number(row.media_asset_id), 9);--});----t('mirror: Live refusing (not yet CHAT_AUTHORITY=chat) keeps the queue; accepted later', async () => {-- h.live.mirrorStatus = 409;-- await h.db.recordRelayUser('twitch', 'Zed');-- const r = await h.mirrorRelay.flush();-- assert.ok(r.pending >= 1 && /409/.test(r.error));-- h.live.mirrorStatus = 200;-- const r2 = await h.mirrorRelay.flush();-- assert.strictEqual(r2.pending, 0);-- assert.deepStrictEqual(h.live.mirror.at(-1).row.username, 'zed');-- assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.live' && x.scope === 'live.chat_mirror.write'));--});----t('scripts/mirror-flush.js drains the queue without the service', async () => {-- const { spawn } = require('child_process');-- const path = require('path');-- h.live.mirrorStatus = 409;-- await h.db.recordRelayUser('twitch', 'Late');-- await h.mirrorRelay.flush();-- assert.ok(await h.mirrorRelay.pending() >= 1);-- h.live.mirrorStatus = 200;-- const env = { ...process.env, ...(await h.childDbEnv()) };-- const r = await new Promise((resolve) => {-- const c = spawn(process.execPath, [path.join(__dirname, '..', 'scripts', 'mirror-flush.js')], { env });-- let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { out += d; }); c.on('close', (code) => resolve({ code, out }));-- });-- assert.strictEqual(r.code, 0, r.out);-- assert.strictEqual(JSON.parse(r.out.slice(r.out.indexOf('{'))).pending, 0);-- assert.strictEqual(await h.mirrorRelay.pending(), 0);-- assert.strictEqual(h.live.mirror.at(-1).row.username, 'late');--});----t.run(async () => { if (h) await h.close(); events.close(); });nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * Events outbox (events.event-envelope@1, written in the same transaction as the change; relayed++ * to OpenVibe.Events only when EVENTS_URL is set, with a service token for audience++ * openvibe.events).++ */++const assert = require('assert');++const http = require('http');++const { validate } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('outbox');++let h, events, eventsPort, alice, bob;++const received = [];++let eventsMode = 'ok';++++t('boot with a stub OpenVibe.Events', async () => {++ events = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ res.setHeader('Content-Type', 'application/json');++ if (eventsMode !== 'ok') { res.statusCode = 503; return res.end('{"error":"down"}'); }++ const { serviceAuth } = require('openvibe-contracts');++ const r = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: h.keys.publicKey, issuer: h.ISS, audience: 'openvibe.events' });++ if (!r.ok || !r.claims.cap.includes('events.event.publish')) { res.statusCode = 401; return res.end('{}'); }++ const body = JSON.parse(raw);++ received.push(...body.events);++ res.statusCode = 201;++ res.end(JSON.stringify({ accepted: body.events.length }));++ });++ });++ eventsPort = await new Promise((r) => events.listen(0, '127.0.0.1', () => r(events.address().port)));++ h = await boot({ env: { EVENTS_URL: `http://127.0.0.1:${eventsPort}`, EVENTS_RELAY_INTERVAL_MS: '3600000' } });++ alice = h.addUser('alice', { subject: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });++ bob = h.addUser('bob');++ await h.ctx.sync();++});++++t('a REST fallback message, a DM and a moderation action each leave one envelope', async () => {++ const sent = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'rest hello' } });++ assert.deepStrictEqual(sent.body, { ok: true });++ const conv = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });++ await h.http('POST', `/api/dm/conversations/${conv.body.conversation.id}/messages`, { token: alice.token, body: { message: 'dm text' } });++ await h.db.logModerationAction({ scope_type: 'site', actor_user_id: alice.id, target_user_id: bob.id, action_type: 'site_timeout', details: { duration: 60 } });++ const rows = await h.db.all('SELECT event_type, event, sent_at FROM events_outbox ORDER BY seq');++ assert.deepStrictEqual(rows.map((r) => r.event_type), ['chat.message.created', 'chat.dm.created', 'chat.moderation.action']);++ for (const r of rows) {++ const env = JSON.parse(r.event);++ const v = validate('events.event-envelope@1', env);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.strictEqual(r.sent_at, null);++ }++ const [msg, dm, mod] = rows.map((r) => JSON.parse(r.event));++ assert.strictEqual(msg.visibility, 'public');++ assert.deepStrictEqual(msg.payload.room, { type: 'global' });++ assert.strictEqual(msg.payload.text, 'rest hello');++ assert.strictEqual(dm.visibility, 'subject');++ assert.ok(!JSON.stringify(dm).includes('dm text'));++ assert.deepStrictEqual(dm.payload.participants.map((p) => p.user_id).sort(), [alice.id, bob.id].sort());++ assert.strictEqual(mod.visibility, 'internal');++ assert.deepStrictEqual(mod.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });++ assert.deepStrictEqual(mod.actor.type, 'user');++ assert.deepStrictEqual(dm.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });++});++++t('Events down: rows wait with the error; back up: published once, marked sent', async () => {++ eventsMode = 'down';++ await h.eventsRelay.flush();++ const waiting = await h.db.all('SELECT attempts, last_error, sent_at FROM events_outbox');++ assert.ok(waiting.every((r) => r.attempts === 1 && /503/.test(r.last_error) && r.sent_at === null));++ eventsMode = 'ok';++ const r = await h.eventsRelay.flush();++ assert.strictEqual(r.sent, 3);++ assert.strictEqual(received.length, 3);++ assert.ok((await h.db.all('SELECT sent_at FROM events_outbox')).every((x) => x.sent_at));++ assert.strictEqual((await h.eventsRelay.flush()).sent, 0, 'nothing twice');++ assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.events' && x.scope === 'events.event.publish'));++});++++t.run(async () => { if (h) await h.close(); events.close(); });@@@ -2,9 +2,8 @@/*** The PostgreSQL schema (plan T3, decision 1). migrations/0001_initial.sql opens a real PostgreSQL* (PGlite in-process, or the containers with OV_TEST_STORE=pg) and is checked here: every Chat table-- * exists, the keys/indexes the queries rely on are present, and the Live read-mirror triggers on the-- * twelve mirrored tables queue a change for every Chat write but never for one that sets-- * ov.mirror_skip = '1' (the bridge applying Live's writes, and the importer).++ * exists, the keys/indexes the queries rely on are present, and the retired Live read mirror captures++ * nothing any more (0006 dropped its twelve triggers; live_mirror_outbox stays until a contract migration).*/const assert = require('assert');const path = require('path');@@@ -83,24 +82,12 @@ t('relay_users has a real identity id', async () => {assert.strictEqual(r.is_identity, 'YES', 'id is an identity column');});--t('a Chat write is captured by the mirror', async () => {++t('the retired Live mirror captures nothing (0006 dropped its triggers)', async () => {++ const triggers = await all("SELECT tgname FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE 'mirror\\_%'");++ assert.deepStrictEqual(triggers, [], 'no mirror_* capture trigger remains');const before = (await get('SELECT COUNT(*) AS n FROM live_mirror_outbox')).n;await db.prepare('INSERT INTO chat_messages (message) VALUES (?)').run('hello');-- const rows = await all('SELECT tbl, op, pk FROM live_mirror_outbox ORDER BY seq');-- assert.strictEqual(rows.length, before + 1, 'one queued change');-- const last = rows[rows.length - 1];-- assert.strictEqual(last.tbl, 'chat_messages');-- assert.strictEqual(last.op, 'upsert');-- assert.ok(JSON.parse(last.pk).id >= 1, 'the pk carries the id');--});----t("a Live write (ov.mirror_skip) never comes back", async () => {-- const before = (await get('SELECT COUNT(*) AS n FROM live_mirror_outbox')).n;-- await db.tx(async (tx) => {-- await tx.query(`SET LOCAL ov.mirror_skip = '1'`);-- await tx.query(`INSERT INTO chat_messages (message) VALUES ('from Live')`);-- });-- assert.strictEqual((await get('SELECT COUNT(*) AS n FROM live_mirror_outbox')).n, before, 'nothing queued');++ assert.strictEqual((await get('SELECT COUNT(*) AS n FROM live_mirror_outbox')).n, before, 'a Chat write queues nothing');});t('cleanup', async () => { await close(); });@@@ -19,11 +19,10 @@ const t = suite('service-kit');const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');--t('server/index.js imports gracefulStop and within from openvibe-sdk/service', () => {++t('server/index.js imports gracefulStop from openvibe-sdk/service', () => {const m = src.match(/const\s*\{([^}]*)\}\s*=\s*require\(['"]openvibe-sdk\/service['"]\)/);assert.ok(m, "require('openvibe-sdk/service')'s exports are not destructured");assert.ok(/\bgracefulStop\b/.test(m[1]), 'gracefulStop is not imported');-- assert.ok(/\bwithin\b/.test(m[1]), 'within is not imported');});t('server/index.js leaves the signal handlers to the kit', () => {@@@ -59,11 +58,9 @@ t('the returned shutdown runs the stop steps in order and closes the database',wrap(chatServer, 'broadcastAll', 'broadcastAll');wrap(h.ctx, 'stop', 'ctx.stop');wrap(h.eventsRelay, 'stop', 'relay.stop');-- wrap(h.mirrorRelay, 'stop', 'mirror.stop');wrap(h.eventsConsumer, 'stop', 'events.stop');wrap(chatAi, 'stop', 'chatAi.stop');wrap(h.subscriptions, 'stop', 'subs.stop');-- wrap(h.mirrorRelay, 'flush', 'mirror.flush');wrap(chatServer, 'close', 'chatServer.close');wrap(callServer, 'close', 'callServer.close');wrap(db, 'close', 'db.close');@@@ -84,11 +81,9 @@ t('the returned shutdown runs the stop steps in order and closes the database','broadcastAll','ctx.stop','relay.stop',-- 'mirror.stop','events.stop','chatAi.stop','subs.stop',-- 'mirror.flush','chatServer.close','callServer.close','db.close',@@@ -97,7 +92,7 @@ t('the returned shutdown runs the stop steps in order and closes the database',// The kit starts the stop once: a second call is the same promise, no step runs again.const again = await h.shutdown();assert.strictEqual(again, 0, 'a second stop resolves with the same code');-- assert.strictEqual(order.length, 11, 'a second stop runs no step again');++ assert.strictEqual(order.length, 9, 'a second stop runs no step again');});// The shutdown step above already stopped the process; detach is a no-op then (and a fallback if a
@@ -44,10 +44,6 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
@@ -44,10 +44,6 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
#LIVE_TIMEOUT_MS=4000
#LIVE_TIMEOUT_MS=4000
# /ready reads the Live sync degraded once its last clean pass is older than this (ms)
# /ready reads the Live sync degraded once its last clean pass is older than this (ms)
#LIVE_SYNC_STALE_MS=60000
#LIVE_SYNC_STALE_MS=60000
-# Copy Chat's changes into Live's chat tables (read mirror for Live's remaining readers). ON from
-# the cutover; OFF for rehearsals (Live refuses mirror writes unless it runs with CHAT_AUTHORITY=chat).
-LIVE_MIRROR=0
-#LIVE_MIRROR_INTERVAL_MS=1000
# Chat preferences in the OpenVibe.Network user module chat.preferences (grants: chat network.modules.read
# Chat preferences in the OpenVibe.Network user module chat.preferences (grants: chat network.modules.read
# and network.modules.write on chat.preferences, audience openvibe.network). Cached per person (ms).
# and network.modules.write on chat.preferences, audience openvibe.network). Cached per person (ms).
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Status:** alpha — deployed. Since the cutover on 2026-09-23 at 02:03 UTC (`docs/cutover.md`),
**Status:** alpha — deployed. Since the cutover on 2026-09-23 at 02:03 UTC (`docs/cutover.md`),
Chat is the authority for openvibe.live's chat in every mode: the service runs on `openvibe-ovh`
Chat is the authority for openvibe.live's chat in every mode: the service runs on `openvibe-ovh`
(unit `openvibe-chat`, 127.0.0.1:4400), Live runs with `CHAT_AUTHORITY=chat`, mounts no local chat
(unit `openvibe-chat`, 127.0.0.1:4400), Live runs with `CHAT_AUTHORITY=chat`, mounts no local chat
-routes and keeps a read mirror, and chat events go to OpenVibe.Events. `openvibe.chat` is Chat's own
+routes, reads Chat's internal read API for the chat data it needs, and chat events go to OpenVibe.Events. `openvibe.chat` is Chat's own
site (global chat, messages, rooms,
site (global chat, messages, rooms,
settings; the service manifest records it live since 2026-09-24).
settings; the service manifest records it live since 2026-09-24).
**Domain:** `openvibe.chat` ([deploy/nginx/openvibe.chat.conf](deploy/nginx/openvibe.chat.conf)); Chat
**Domain:** `openvibe.chat` ([deploy/nginx/openvibe.chat.conf](deploy/nginx/openvibe.chat.conf)); Chat
@@ -61,13 +61,16 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -61,13 +61,16 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
(`/internal/chat/*`, `docs/chat-ingress.md`); the bridge proxy at `POST /internal/live/calls` is
(`/internal/chat/*`, `docs/chat-ingress.md`); the bridge proxy at `POST /internal/live/calls` is
retired (T3 J3a).
retired (T3 J3a).
- **Own database on PostgreSQL** (ADR-035, plan T3) with Live's tables and ids, the Network subject on new
- **Own database on PostgreSQL** (ADR-035, plan T3) with Live's tables and ids, the Network subject on new
- rows, a transactional events outbox and a read mirror back into Live. `server/db/database.js` serves
+ rows, and a transactional events outbox. Chat's changes were copied into Live's tables by a read
+ mirror until 2026-10-05: since Live #31 nothing in Live reads its mirror-filled chat tables in chat
+ mode (every read goes to Chat's internal read API, `server/chat/internal-reads.js`), so the mirror —
+ and the cutover rollback through it — is retired. `server/db/database.js` serves
through `openvibe-sdk/db`: production needs `DATABASE_URL` (the runtime role, DML only, through
through `openvibe-sdk/db`: production needs `DATABASE_URL` (the runtime role, DML only, through
PgBouncer) and `DATABASE_DIRECT_URL` (the owner, which applies `migrations/` at boot); without
PgBouncer) and `DATABASE_DIRECT_URL` (the owner, which applies `migrations/` at boot); without
`DATABASE_URL` outside production it runs on an embedded PGlite in `data/pglite` (`CHAT_PGLITE_DIR`).
`DATABASE_URL` outside production it runs on an embedded PGlite in `data/pglite` (`CHAT_PGLITE_DIR`).
`migrations/0001_initial.sql` is the whole schema (timestamps stay SQLite-format text through
`migrations/0001_initial.sql` is the whole schema (timestamps stay SQLite-format text through
- `ov_now()`/`datetime()`); its triggers on the twelve Chat tables queue Live's read mirror
- (`live_mirror_outbox`), skipped where a transaction sets `ov.mirror_skip = '1'` (the importers).
+ `ov_now()`/`datetime()`); `0006_stop_live_mirror_triggers.sql` dropped the twelve read-mirror capture
+ triggers, and `live_mirror_outbox` stays, idle, until a contract migration drops it.
`scripts/import-sqlite-to-pg.js` moves the SQLite data over once, with a per-table count and checksum
`scripts/import-sqlite-to-pg.js` moves the SQLite data over once, with a per-table count and checksum
report (`docs/cutover.md`). `VALKEY_URL`/`VALKEY_PREFIX` put the per-actor rate-limit counters on
report (`docs/cutover.md`). `VALKEY_URL`/`VALKEY_PREFIX` put the per-actor rate-limit counters on
Valkey instead of this process. No part of the service reads SQLite; the one-time tools that read a
Valkey instead of this process. No part of the service reads SQLite; the one-time tools that read a
@@ -117,7 +120,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -117,7 +120,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
| Concern | Where it lives | How Chat reaches it |
| Concern | Where it lives | How Chat reaches it |
| --- | --- | --- |
| --- | --- | --- |
-| Messages, DMs, channel sounds, TTS voice overrides, relay users, first chats, IP-approval queue, moderation log | **Chat** (authority from the cutover) | its own database on PostgreSQL since the 2026-09-23 cutover (`DATABASE_URL` through `openvibe-sdk/db`, `migrations/`, the one-time `scripts/import-sqlite-to-pg.js`; `CHAT_DB_PATH` is read only by that importer); Live keeps a read mirror (`POST /internal/chat-effects/mirror`) |
+| Messages, DMs, channel sounds, TTS voice overrides, relay users, first chats, IP-approval queue, moderation log | **Chat** (authority from the cutover) | its own database on PostgreSQL since the 2026-09-23 cutover (`DATABASE_URL` through `openvibe-sdk/db`, `migrations/`, the one-time `scripts/import-sqlite-to-pg.js`; `CHAT_DB_PATH` is read only by that importer); Live reads them through Chat's internal read API (the read mirror was retired on 2026-10-05 and the rollback through it is gone) |
| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) |
| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) |
| Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) |
| Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) |
| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | the Chat-owned ingress `/internal/chat/*` (`docs/chat-ingress.md`), presence `GET /internal/chat/presence` (`chat.presence.read`); the bridge `/internal/live/*` is retired (T3 J3a) |
| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | the Chat-owned ingress `/internal/chat/*` (`docs/chat-ingress.md`), presence `GET /internal/chat/presence` (`chat.presence.read`); the bridge `/internal/live/*` is retired (T3 J3a) |
@@ -378,7 +381,6 @@ npm run test:pg # the same on PostgreSQL + PgBouncer + Valkey (OV_
@@ -378,7 +381,6 @@ npm run test:pg # the same on PostgreSQL + PgBouncer + Valkey (OV_
# OV_TEST_VALKEY_URL: openvibe-sdk scripts/test-services.sh up)
# OV_TEST_VALKEY_URL: openvibe-sdk scripts/test-services.sh up)
node scripts/import-from-live.js --live-db /tmp/live-snapshot.db # dry run; --apply writes
node scripts/import-from-live.js --live-db /tmp/live-snapshot.db # dry run; --apply writes
node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"
node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"
-node scripts/mirror-flush.js # drain queued mirror rows to Live without the service
node scripts/subscribe-events.js --dry-run # Chat's Events subscriptions (boot creates missing ones)
node scripts/subscribe-events.js --dry-run # Chat's Events subscriptions (boot creates missing ones)
node scripts/parity.js # chat parity scenarios: a dry run; --apply only on test accounts (docs/parity.md)
node scripts/parity.js # chat parity scenarios: a dry run; --apply only on test accounts (docs/parity.md)
N1_LIVE_REF=<live sha> npm run n-1:record # after a deploy: the N-1 fixtures from the deployed commit
N1_LIVE_REF=<live sha> npm run n-1:record # after a deploy: the N-1 fixtures from the deployed commit
@@ -397,8 +399,8 @@ and commit the fixtures.
@@ -397,8 +399,8 @@ and commit the fixtures.
`/ready` (openvibe-shared/ready shape: `status` ready/degraded/not_ready and `checks`) is 503 only
`/ready` (openvibe-shared/ready shape: `status` ready/degraded/not_ready and `checks`) is 503 only
when the `db` check (a read of `chat_messages`) fails. `live_sync` is optional: it reads degraded when
when the `db` check (a read of `chat_messages`) fails. `live_sync` is optional: it reads degraded when
the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a sync step has missed
the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a sync step has missed
-its own interval by that much. It also reports the mirror queue, whether events are relayed, and the
-Events consumer's counters.
+its own interval by that much. It also reports whether events are relayed and the Events consumer's
+counters.
Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-chat.locations.conf`,
Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-chat.locations.conf`,
`/etc/openvibe/chat.env`. The whole switch-over — rehearsal, import, parity checks, nginx, the flag,
`/etc/openvibe/chat.env`. The whole switch-over — rehearsal, import, parity checks, nginx, the flag,
rollback — is `docs/cutover.md`.
rollback — is `docs/cutover.md`.
@@ -443,7 +445,6 @@ server/app.js Live's guards for these routes: CORS, /api rate limit
@@ -443,7 +445,6 @@ server/app.js Live's guards for these routes: CORS, /api rate limit
server/live-context.js the only module that talks to Live (interface in its header)
server/live-context.js the only module that talks to Live (interface in its header)
server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice; plus the T3 APIs: emotes-routes, channel-mod-routes, internal-moderation (Live's read API), alert-sounds
server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice; plus the T3 APIs: emotes-routes, channel-mod-routes, internal-moderation (Live's read API), alert-sounds
server/auth/ token resolution through Live; the chat subset of Live's permissions
server/auth/ token resolution through Live; the chat subset of Live's permissions
-server/bridge/ Chat → Live read mirror (live-mirror.js)
server/events/outbox.js events.event-envelope@1 outbox and relay
server/events/outbox.js events.event-envelope@1 outbox and relay
server/events/consumer.js POST /internal/events: Chat's Events subscriptions (live.release.deployed, network.module.updated)
server/events/consumer.js POST /internal/events: Chat's Events subscriptions (live.release.deployed, network.module.updated)
server/events/subscriptions.js creates them at boot when missing; list/disable/enable for scripts/subscribe-events.js
server/events/subscriptions.js creates them at boot when missing; list/disable/enable for scripts/subscribe-events.js
@@ -453,7 +454,7 @@ server/calls/ moved from Live: the call server (/ws/call), its REST
@@ -453,7 +454,7 @@ server/calls/ moved from Live: the call server (/ws/call), its REST
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/)
server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/)
-scripts/ import-from-live, parity-check, parity, mirror-flush, migrate-chat-preferences, subscribe-events, n-1-record
+scripts/ import-from-live, parity-check, parity, migrate-chat-preferences, subscribe-events, n-1-record
docs/ cutover.md, calls-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
docs/ cutover.md, calls-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
```
```
@@ -479,7 +480,7 @@ queue are still Live's and reached through `live-context`. Calls are ported with
@@ -479,7 +480,7 @@ queue are still Live's and reached through `live-context`. Calls are ported with
Introduced here and registered in `openvibe-contracts` v0.13.0:
Introduced here and registered in `openvibe-contracts` v0.13.0:
`chat.live_bridge.write`, `chat.presence.read` (owner chat) and `live.chat_context.read`,
`chat.live_bridge.write`, `chat.presence.read` (owner chat) and `live.chat_context.read`,
-`live.chat_effects.write`, `live.chat_mirror.write` (owner live). `chat.live_bridge.write` now only
+`live.chat_effects.write` (owner live). `chat.live_bridge.write` now only
guards Live's stream hooks for calls (`/internal/calls/stream-channel`) and `chat.presence.read` the
guards Live's stream hooks for calls (`/internal/calls/stream-channel`) and `chat.presence.read` the
presence snapshot (`GET /internal/chat/presence`); the bridge routes they were made for are retired
presence snapshot (`GET /internal/chat/presence`); the bridge routes they were made for are retired
(T3 J3a). Enforced here as well: `chat.message.send` (`POST /internal/chat/messages`). It is owned
(T3 J3a). Enforced here as well: `chat.message.send` (`POST /internal/chat/messages`). It is owned
@@ -500,7 +501,7 @@ Called elsewhere, as the service principal `chat` ([server/net/service-auth.js](
@@ -500,7 +501,7 @@ Called elsewhere, as the service principal `chat` ([server/net/service-auth.js](
| Service | Grant | Why |
| Service | Grant | Why |
|---|---|---|
|---|---|---|
-| OpenVibe.Live | `live.chat_context.read`, `live.chat_effects.write`, `live.chat_mirror.write` | the chat context Live still owns (bans, channel settings), effects, the read mirror |
+| OpenVibe.Live | `live.chat_context.read`, `live.chat_effects.write` | the chat context Live still owns (bans, channel settings) and effects |
| OpenVibe.Events | `events.event.publish`, `events.subscription.manage` | the outbox relay; the subscriptions above |
| OpenVibe.Events | `events.event.publish`, `events.subscription.manage` | the outbox relay; the subscriptions above |
| OpenVibe.Network | `network.modules.read`, `network.modules.write` | chat preferences and the other chat user modules |
| OpenVibe.Network | `network.modules.read`, `network.modules.write` | chat preferences and the other chat user modules |
| OpenVibe.VIP | `vip.entitlement.check` | subscriber badges (fails closed) |
| OpenVibe.VIP | `vip.entitlement.check` | subscriber badges (fails closed) |
@@ -560,8 +561,7 @@ The unit is `openvibe-chat.service` on `127.0.0.1:4400`, the env file `/etc/open
@@ -560,8 +561,7 @@ The unit is `openvibe-chat.service` on `127.0.0.1:4400`, the env file `/etc/open
for openvibe.chat and [deploy/nginx/openvibe.live-chat.locations.conf](deploy/nginx/openvibe.live-chat.locations.conf),
for openvibe.chat and [deploy/nginx/openvibe.live-chat.locations.conf](deploy/nginx/openvibe.live-chat.locations.conf),
included in openvibe.live's vhost, for Live's chat paths. After a deploy, record the N-1 fixtures
included in openvibe.live's vhost, for Live's chat paths. After a deploy, record the N-1 fixtures
(`npm run n-1:record`). Chat is required in every mode and Live's chat routes are retired: a
(`npm run n-1:record`). Chat is required in every mode and Live's chat routes are retired: a
-rollback is a Chat release rollback, not a switch back to Live (`docs/cutover.md`;
-`scripts/mirror-flush.js` drains a stuck mirror).
+rollback is a Chat release rollback, not a switch back to Live (`docs/cutover.md`).
Rollback: ovhost puts the previous sha back by itself when `/ready` does not answer 2xx after the
Rollback: ovhost puts the previous sha back by itself when `/ready` does not answer 2xx after the
restart; afterwards `sudo ovhost rollback chat --to <sha>`. Nothing blocks a rollback: the schema
restart; afterwards `sudo ovhost rollback chat --to <sha>`. Nothing blocks a rollback: the schema
@@ -12,7 +12,6 @@
@@ -12,7 +12,6 @@
"capabilitiesUsed": [
"capabilitiesUsed": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
- "live.chat_mirror.write",
"events.event.publish",
"events.event.publish",
"events.subscription.manage",
"events.subscription.manage",
"vip.entitlement.check",
"vip.entitlement.check",
@@ -24,15 +23,15 @@
@@ -24,15 +23,15 @@
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"events": ["chat.message.created", "chat.message.deleted", "chat.dm.created", "chat.moderation.action", "chat.room.message.created", "chat.room.message.deleted"],
"events": ["chat.message.created", "chat.message.deleted", "chat.dm.created", "chat.moderation.action", "chat.room.message.created", "chat.room.message.deleted"],
"eventsConsumed": "live.release.deployed, network.module.updated, network.user.token_valid_after, vip.membership.changed, network.block.changed, network.subject.merged, network.account.export_requested and network.account.deleted at POST /internal/events (signature v2, openvibe-sdk inbox); subscriptions created at boot when missing (CHAT_EVENTS_SECRET and the Network grant chat events.subscription.manage openvibe.events)",
"eventsConsumed": "live.release.deployed, network.module.updated, network.user.token_valid_after, vip.membership.changed, network.block.changed, network.subject.merged, network.account.export_requested and network.account.deleted at POST /internal/events (signature v2, openvibe-sdk inbox); subscriptions created at boot when missing (CHAT_EVENTS_SECRET and the Network grant chat events.subscription.manage openvibe.events)",
- "liveAuthorityFlag": "CHAT_AUTHORITY=chat is required in every mode since the cutover (2026-09-23 02:03 UTC): Live mounts no local chat routes and there is no rollback to Live; Live's chat tables are a read mirror Chat keeps current for Live's remaining readers",
+ "liveAuthorityFlag": "CHAT_AUTHORITY=chat is required in every mode since the cutover (2026-09-23 02:03 UTC): Live mounts no local chat routes and there is no rollback to Live; Live reads Chat's chat tables through Chat's internal read API (the read mirror was retired on 2026-10-05, so the cutover rollback through it no longer exists)",
"vip": "Member badges from OpenVibe.VIP (server/vip/badges.js: entitlements/check with product chat, cached 60 s, never blocks sending, fails closed; vip.membership.changed drops the cache). Needs the Network grant chat vip.entitlement.check openvibe.vip",
"vip": "Member badges from OpenVibe.VIP (server/vip/badges.js: entitlements/check with product chat, cached 60 s, never blocks sending, fails closed; vip.membership.changed drops the cache). Needs the Network grant chat vip.entitlement.check openvibe.vip",
"preferences": "Chat preferences in the Network user module chat.preferences, plus chat.tts_defaults, chat.dm_settings and chat.presence_prefs (server/prefs/: /api/chat/preferences and siblings with Chat's service token, cached 60 s, If-Match on every write; scripts/migrate-chat-preferences.js moves the choices in Live's user_preferences). Needs the grants chat network.modules.read/write",
"preferences": "Chat preferences in the Network user module chat.preferences, plus chat.tts_defaults, chat.dm_settings and chat.presence_prefs (server/prefs/: /api/chat/preferences and siblings with Chat's service token, cached 60 s, If-Match on every write; scripts/migrate-chat-preferences.js moves the choices in Live's user_preferences). Needs the grants chat network.modules.read/write",
"calls": "Live's call server, call routes and stream hooks ported to server/calls/ with a calls lifecycle table (WS-I task 1). Off unless CHAT_CALLS=1; the switch is CHAT_CALLS=1, Live CALLS_AUTHORITY=chat and the nginx include deploy/nginx/calls/openvibe.live-calls.locations.conf (docs/calls-cutover.md)",
"calls": "Live's call server, call routes and stream hooks ported to server/calls/ with a calls lifecycle table (WS-I task 1). Off unless CHAT_CALLS=1; the switch is CHAT_CALLS=1, Live CALLS_AUTHORITY=chat and the nginx include deploy/nginx/calls/openvibe.live-calls.locations.conf (docs/calls-cutover.md)",
"deployed": true,
"deployed": true,
"deployment": "Deployed with `sudo ovhost deploy chat`: /opt/openvibe.chat, unit openvibe-chat, 127.0.0.1:4400 (loopback); public through the openvibe.chat vhost and openvibe.live's nginx include for the chat paths; EVENTS_URL set (chat.message.created reaches OpenVibe.Events); restore drill passed 2026-09-23",
"deployment": "Deployed with `sudo ovhost deploy chat`: /opt/openvibe.chat, unit openvibe-chat, 127.0.0.1:4400 (loopback); public through the openvibe.chat vhost and openvibe.live's nginx include for the chat paths; EVENTS_URL set (chat.message.created reaches OpenVibe.Events); restore drill passed 2026-09-23",
- "updated": "2026-10-04",
+ "updated": "2026-10-05",
"features": [
"features": [
- "Live's global, stream and channel chat (authority since 2026-09-23, Live keeps a read mirror)",
+ "Live's global, stream and channel chat (authority since 2026-09-23; Live reads Chat's internal read API)",
"DMs and group DMs, participants only",
"DMs and group DMs, participants only",
"rooms: public and private, roles, invitations, slow mode, unread counts",
"rooms: public and private, roles, invitations, slow mode, unread counts",
"moderation: bans, timeouts, purges, filters, slow, sub-only and member-only modes, audited log access",
"moderation: bans, timeouts, purges, filters, slow, sub-only and member-only modes, audited log access",
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
[Unit]
[Unit]
Description=OpenVibe.Chat — rooms, messages, DMs, TTS and sounds, moderation (/ws/chat and the chat API of openvibe.live)
Description=OpenVibe.Chat — rooms, messages, DMs, TTS and sounds, moderation (/ws/chat and the chat API of openvibe.live)
# Live and Network should be up first; Chat serves from its caches if they are slow, and queues
# Live and Network should be up first; Chat serves from its caches if they are slow, and queues
-# its writes to Live (mirror, effects) until Live answers.
+# its side effects to Live until Live answers.
After=network.target
After=network.target
Wants=openvibe-live.service openvibe-network.service
Wants=openvibe-live.service openvibe-network.service
@@ -16,7 +16,7 @@ Environment=PORT=4400
@@ -16,7 +16,7 @@ Environment=PORT=4400
Environment=HOST=127.0.0.1
Environment=HOST=127.0.0.1
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/env node /opt/openvibe.chat/server/index.js
ExecStart=/usr/bin/env node /opt/openvibe.chat/server/index.js
-# Tells clients the chat restarts, flushes the Live mirror, closes sockets.
+# Tells clients the chat restarts and closes sockets.
KillSignal=SIGTERM
KillSignal=SIGTERM
TimeoutStopSec=15
TimeoutStopSec=15
Restart=always
Restart=always
@@ -20,7 +20,7 @@ a stream that does not exist is 404: do not retry a 4xx.
@@ -20,7 +20,7 @@ a stream that does not exist is 404: do not retry a 4xx.
| `POST /internal/chat/invalidate` | `chat.cache.invalidate` | `{key,user?,user_data?,approvals?,bans?,channel?}`; a cache hint, returns `{ok:true}`. Include a typed `user_data` object when Live edited the account, so Chat updates sockets and stored message names. |
| `POST /internal/chat/invalidate` | `chat.cache.invalidate` | `{key,user?,user_data?,approvals?,bans?,channel?}`; a cache hint, returns `{ok:true}`. Include a typed `user_data` object when Live edited the account, so Chat updates sockets and stored message names. |
| `GET /internal/chat/presence` | `chat.presence.read` | Who is connected where: counts, per-stream viewers, slow modes, users/anons with their addresses (`server/chat/presence.js`). |
| `GET /internal/chat/presence` | `chat.presence.read` | Who is connected where: counts, per-stream viewers, slow modes, users/anons with their addresses (`server/chat/presence.js`). |
-Live's reads of Chat's tables, in place of its mirrored copy (`server/chat/internal-reads.js`, plan
+Live's reads of Chat's tables, in place of the read mirror retired on 2026-10-05 (`server/chat/internal-reads.js`, plan
T3 J4b N1–N5, contracts in `openvibe-contracts` 0.95.0). Unknown or malformed parameters are 400;
T3 J4b N1–N5, contracts in `openvibe-contracts` 0.95.0). Unknown or malformed parameters are 400;
deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive). Every answer is
deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive). Every answer is
`{ok:true,...}` or `{ok:false,error}`.
`{ok:true,...}` or `{ok:false,error}`.
@@ -38,7 +38,7 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive
@@ -38,7 +38,7 @@ deleted messages are never read; `since`/`until` are epoch ms (`until` exclusive
| `GET /internal/chat/sounds?channel_owner_id` (also `/sounds/count`) | `chat.sounds.read` | `{count}` of the channel's sounds. |
| `GET /internal/chat/sounds?channel_owner_id` (also `/sounds/count`) | `chat.sounds.read` | `{count}` of the channel's sounds. |
| `GET /internal/chat/sounds?pending_asset=1` | `chat.sounds.read` | `{sounds}` not yet on Media (`media_asset_id` null), by id; optional `channel_owner_id`, `after_id`, `limit` ≤ 500 (default 100). |
| `GET /internal/chat/sounds?pending_asset=1` | `chat.sounds.read` | `{sounds}` not yet on Media (`media_asset_id` null), by id; optional `channel_owner_id`, `after_id`, `limit` ≤ 500 (default 100). |
| `GET /internal/chat/sounds/by-command?channel_id&command` | `chat.sounds.read` | `{sound}`: the approved (`is_approved=1`) channel sound `command` plays, one at random when several match, or 404 — Live's `getChannelSoundByCommand`. `command` is trimmed, lowercased and has leading `!` stripped (≤ 120); `channel_id` is the channel owner's Live user id (`channel_sounds.channel_owner_id`); same projection as `/sounds`. |
| `GET /internal/chat/sounds/by-command?channel_id&command` | `chat.sounds.read` | `{sound}`: the approved (`is_approved=1`) channel sound `command` plays, one at random when several match, or 404 — Live's `getChannelSoundByCommand`. `command` is trimmed, lowercased and has leading `!` stripped (≤ 120); `channel_id` is the channel owner's Live user id (`channel_sounds.channel_owner_id`); same projection as `/sounds`. |
-| `POST /internal/chat/sounds/asset` | `chat.sounds.write` | `{id,media_url,media_asset_id}` records Live's upload (`migrations/0004_channel_sound_media.sql`); repeating it is a 200 no-op; an unknown sound is 404. Until Live writes here, the read mirror leaves Live's own asset columns alone for a sound Chat has no asset for. |
+| `POST /internal/chat/sounds/asset` | `chat.sounds.write` | `{id,media_url,media_asset_id}` records Live's upload (`migrations/0004_channel_sound_media.sql`); repeating it is a 200 no-op; an unknown sound is 404. For a sound Chat has no Media asset for, Live's own asset columns stay as its asset sync left them (the read mirror that used to preserve them was retired on 2026-10-05). |
`/internal/chat/events` is a narrow transient event ingress, not a generic ChatServer call.
`/internal/chat/events` is a narrow transient event ingress, not a generic ChatServer call.
`live.release.deployed` continues through OpenVibe.Events; there is no deploy-notice endpoint.
`live.release.deployed` continues through OpenVibe.Events; there is no deploy-notice endpoint.
@@ -5,8 +5,10 @@ socket and the chat REST prefixes to OpenVibe.Chat (127.0.0.1:4400), and Live ru
@@ -5,8 +5,10 @@ socket and the chat REST prefixes to OpenVibe.Chat (127.0.0.1:4400), and Live ru
`CHAT_AUTHORITY=chat`. Nothing in Live's browser code changes. Chat is required in every mode:
`CHAT_AUTHORITY=chat`. Nothing in Live's browser code changes. Chat is required in every mode:
Live's local chat routes and mounts are inert under the flag and are being removed, and the rollback
Live's local chat routes and mounts are inert under the flag and are being removed, and the rollback
that returned chat to Live is retired, so there is no unset-flag mode in which Live serves chat
that returned chat to Live is retired, so there is no unset-flag mode in which Live serves chat
-again (see [Rollback](#rollback)). The Live-side read mirror stays only for Live's own readers,
-until a later wave retires them.
+again (see [Rollback](#rollback)). The Live-side read mirror was retired on 2026-10-05: since
+Live #31 nothing in Live reads its mirror-filled chat tables in chat mode — every read goes to
+Chat's internal read API — so Chat no longer copies its writes into Live, and the cutover rollback
+through the mirror no longer exists (see [Rollback](#rollback)).
The cutover itself ran on 2026-09-23; the rehearsal, flip and check steps below record that deploy.
The cutover itself ran on 2026-09-23; the rehearsal, flip and check steps below record that deploy.
@@ -78,22 +80,22 @@ writers are repointed; deploy the three new capability grants before that Live r
@@ -78,22 +80,22 @@ writers are repointed; deploy the three new capability grants before that Live r
| Table (Live baseline, target OpenVibe.Chat) | W6 authority | Notes |
| Table (Live baseline, target OpenVibe.Chat) | W6 authority | Notes |
| --- | --- | --- |
| --- | --- | --- |
-| `chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions` | **Chat** | Imported with their ids. Live keeps a read mirror (Chat → `POST /internal/chat-effects/mirror`); Live's remaining writers (AI viewers, relays, donations, `/api/mod`, `/api/channels` deletes, emote renames) forward to Chat (the bridge at the cutover; Chat's ingress since T3 J3a). |
+| `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 read a mirror of them (Chat → `POST /internal/chat-effects/mirror`) until 2026-10-05, when it moved to Chat's internal read API (`server/chat/internal-reads.js`); Live's remaining writers (AI viewers, relays, donations, `/api/mod`, `/api/channels` deletes, emote renames) forward to Chat (the bridge at the cutover; Chat's ingress since T3 J3a). |
| `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 writers call Chat's APIs and ingress (the bridge op `db` they used is retired, T3 J3a). There is no authority switch any more. |
| `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 writers call Chat's APIs and ingress (the bridge op `db` they used is retired, T3 J3a). There is no authority switch any more. |
| `media_requests`, `media_request_settings` | **Live** (not moved) | Decision with evidence: every writer is `server/media/media-queue.js` / `server/media/routes.js` (`/api/media`: gold payment, yt-dlp download, playback state, the streamer's overlay and dashboard). Chat only has the `!sr/!queue/!np/!skip` entry points, which call Live (`POST /internal/chat-effects/media-queue`). Not chat-owned in practice; it moves with the queue lifecycle the charter describes, not before. The importer reports them as `not_moved`. |
| `media_requests`, `media_request_settings` | **Live** (not moved) | Decision with evidence: every writer is `server/media/media-queue.js` / `server/media/routes.js` (`/api/media`: gold payment, yt-dlp download, playback state, the streamer's overlay and dashboard). Chat only has the `!sr/!queue/!np/!skip` entry points, which call Live (`POST /internal/chat-effects/media-queue`). Not chat-owned in practice; it moves with the queue lifecycle the charter describes, not before. The importer reports them as `not_moved`. |
| `chat_messages_new`, `emotes_new`, `channel_sounds_new` | — | Transient tables of Live's table rebuilds; empty in a consistent snapshot. Rows found there go to `import_hold`. |
| `chat_messages_new`, `emotes_new`, `channel_sounds_new` | — | Transient tables of Live's table rebuilds; empty in a consistent snapshot. Rows found there go to `import_hold`. |
Chat also keeps `ctx_users`, `ctx_streams`, `ctx_managed_streams`, `ctx_channels` — projections of
Chat also keeps `ctx_users`, `ctx_streams`, `ctx_managed_streams`, `ctx_channels` — projections of
-Live data maintained by `live-context` (never authority), `events_outbox`, `live_mirror_outbox`,
-`import_hold`, `import_runs`. The retired bridge's `bridge_applied` / `bridge_refs` are dropped
+Live data maintained by `live-context` (never authority), `events_outbox`, `live_mirror_outbox` (the retired read mirror's queue: no code path writes or drains
+it since 2026-10-05; the table stays until a later contract migration drops it), `import_hold`,
+`import_runs`. The retired bridge's `bridge_applied` / `bridge_refs` are dropped
(`migrations/0005_drop_bridge_tables.sql`).
(`migrations/0005_drop_bridge_tables.sql`).
## Prerequisites
## Prerequisites
1. **Contracts.** Register `docs/capabilities-proposal/*.json` in OpenVibe.Contracts (a new
1. **Contracts.** Register `docs/capabilities-proposal/*.json` in OpenVibe.Contracts (a new
`openvibe-contracts` release): `live.chat_context.read`, `live.chat_effects.write`,
`openvibe-contracts` release): `live.chat_context.read`, `live.chat_effects.write`,
- `live.chat_mirror.write` (owner live), `chat.live_bridge.write`, `chat.presence.read` (owner
- chat). Add the live ones to `manifests/services/live.json`; set `manifests/services/chat.json` to
+ `chat.live_bridge.write`, `chat.presence.read` (owner chat). Add the live ones to `manifests/services/live.json`; set `manifests/services/chat.json` to
alpha with `capabilities: ["chat.live_bridge.write", "chat.presence.read"]`,
alpha with `capabilities: ["chat.live_bridge.write", "chat.presence.read"]`,
`eventsProduced: ["chat.message.created", "chat.dm.created", "chat.moderation.action"]` and a
`eventsProduced: ["chat.message.created", "chat.dm.created", "chat.moderation.action"]` and a
`contractRanges` that includes the release. Until then `openvibe-contracts-check` reports these
`contractRanges` that includes the release. Until then `openvibe-contracts-check` reports these
@@ -107,7 +109,7 @@ Live data maintained by `live-context` (never authority), `events_outbox`, `live
@@ -107,7 +109,7 @@ Live data maintained by `live-context` (never authority), `events_outbox`, `live
after the script name — Node consumes it as environment, which can point the script at
after the script name — Node consumes it as environment, which can point the script at
Chat's database instead of Network's (`ov_network`).
Chat's database instead of Network's (`ov_network`).
`principal_grants` (client, capability, audience): `chat live.chat_context.read openvibe.live`,
`principal_grants` (client, capability, audience): `chat live.chat_context.read openvibe.live`,
- `chat live.chat_effects.write openvibe.live`, `chat live.chat_mirror.write openvibe.live`,
+ `chat live.chat_effects.write openvibe.live`,
`live chat.live_bridge.write openvibe.chat`, `live chat.message.send openvibe.chat`,
`live chat.live_bridge.write openvibe.chat`, `live chat.message.send openvibe.chat`,
`live chat.presence.read openvibe.chat` (`chat events.event.publish openvibe.events` is already
`live chat.presence.read openvibe.chat` (`chat events.event.publish openvibe.events` is already
a default). Without `chat.message.send`, Chat refused the bridge ops that sent a message (AI
a default). Without `chat.message.send`, Chat refused the bridge ops that sent a message (AI
@@ -123,8 +125,7 @@ Live data maintained by `live-context` (never authority), `events_outbox`, `live
@@ -123,8 +125,7 @@ Live data maintained by `live-context` (never authority), `events_outbox`, `live
`/etc/openvibe/live.env`.
`/etc/openvibe/live.env`.
4. **Chat.** `git clone … /opt/openvibe.chat && cd /opt/openvibe.chat && npm ci --omit=dev`;
4. **Chat.** `git clone … /opt/openvibe.chat && cd /opt/openvibe.chat && npm ci --omit=dev`;
`/etc/openvibe/chat.env` from `.env.example` (0600): `OV_LIVE_INTERNAL_URL`, Network URLs,
`/etc/openvibe/chat.env` from `.env.example` (0600): `OV_LIVE_INTERNAL_URL`, Network URLs,
- `BASE_URL=https://openvibe.live`, `LIVE_MIRROR=1`, and
- `EVENTS_URL` if OpenVibe.Events is deployed. The unit sets `CHAT_DB_PATH`, `CHAT_CACHE_DIR`,
+ `BASE_URL=https://openvibe.live` and `EVENTS_URL` if OpenVibe.Events is deployed. The unit sets `CHAT_DB_PATH`, `CHAT_CACHE_DIR`,
`SOUNDS_PATH=/opt/openvibe.live/data/sounds` and allows writes there. ffmpeg/ffprobe and
`SOUNDS_PATH=/opt/openvibe.live/data/sounds` and allows writes there. ffmpeg/ffprobe and
espeak-ng are the host's (Live uses the same).
espeak-ng are the host's (Live uses the same).
```bash
```bash
@@ -155,9 +156,10 @@ Cross-check: `sqlite3 /tmp/live-rehearsal.db "select count(*) from chat_messages
@@ -155,9 +156,10 @@ Cross-check: `sqlite3 /tmp/live-rehearsal.db "select count(*) from chat_messages
report. Run the import again: every `inserted` must be 0 (idempotent).
report. Run the import again: every `inserted` must be 0 (idempotent).
```bash
```bash
-# 3. a rehearsal Chat on 4401 against production Live, mirror OFF
+# 3. a rehearsal Chat on 4401 against production Live (the mirror is retired: reads go to Chat's
+# internal read API)
sudo -u ubuntu env $(sudo cat /etc/openvibe/chat.env | xargs) PORT=4401 CHAT_DB_PATH=/tmp/chat-rehearsal.db \
sudo -u ubuntu env $(sudo cat /etc/openvibe/chat.env | xargs) PORT=4401 CHAT_DB_PATH=/tmp/chat-rehearsal.db \
- CHAT_CACHE_DIR=/tmp/chat-rehearsal-cache LIVE_MIRROR=0 EVENTS_URL= node server/index.js &
+ CHAT_CACHE_DIR=/tmp/chat-rehearsal-cache EVENTS_URL= node server/index.js &
curl -s http://127.0.0.1:4401/ready | jq # status: "ready" once a Live sync pass succeeded
curl -s http://127.0.0.1:4401/ready | jq # status: "ready" once a Live sync pass succeeded
# 4. parity: the same reads answer the same (history pinned before the snapshot)
# 4. parity: the same reads answer the same (history pinned before the snapshot)
@@ -199,11 +201,10 @@ streamers; `deploy.sh --wait-idle` waits for none).
@@ -199,11 +201,10 @@ streamers; `deploy.sh --wait-idle` waits for none).
sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-cutover-2.db"
sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-cutover-2.db"
sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-cutover-2.db --chat-db /var/lib/openvibe-chat/chat.db --apply
sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-cutover-2.db --chat-db /var/lib/openvibe-chat/chat.db --apply
```
```
- Chat's own new rows are already in Live (mirror) and count as `identical`; `chat_kept` counts rows
+ Chat's own new rows were already in Live through the mirror at the cutover and count as `identical`; `chat_kept` counts rows
Chat edited since pass 1 (Chat wins); `held` must be 0.
Chat edited since pass 1 (Chat wins); `held` must be 0.
4. **Checks.**
4. **Checks.**
- - `curl -s 127.0.0.1:4400/ready | jq` — `status` "ready" (`live.last_success_at` recent), `mirror.pending` near 0,
- `mirror.last_error` null.
+ - `curl -s 127.0.0.1:4400/ready | jq` — `status` "ready" (`live.last_success_at` recent).
- Live: `sqlite3 …/live.db "select count(*) from chat_bridge_outbox"` stays near 0 (its forwarded
- Live: `sqlite3 …/live.db "select count(*) from chat_bridge_outbox"` stays near 0 (its forwarded
writes are acknowledged); `journalctl -u openvibe-live | grep ChatRemote` shows no refusals.
writes are acknowledged); `journalctl -u openvibe-live | grep ChatRemote` shows no refusals.
- Browser: join a stream chat, send a message (it appears for others and in history after a
- Browser: join a stream chat, send a message (it appears for others and in history after a
@@ -212,8 +213,8 @@ streamers; `deploy.sh --wait-idle` waits for none).
@@ -212,8 +213,8 @@ streamers; `deploy.sh --wait-idle` waits for none).
- Live features that read chat: home page chat stats, a recap, AI viewers answering in a test
- Live features that read chat: home page chat stats, a recap, AI viewers answering in a test
stream, `/api/mod` chat logs and deletes (the deleted line disappears in Chat).
stream, `/api/mod` chat logs and deletes (the deleted line disappears in Chat).
- `node scripts/parity-check.js --live http://127.0.0.1:3000 --chat http://127.0.0.1:4400 …` is no
- `node scripts/parity-check.js --live http://127.0.0.1:3000 --chat http://127.0.0.1:4400 …` is no
- longer meaningful (Live mounts no chat routes); compare against the mirror instead with
- `sqlite3` counts: Live `chat_messages` ≈ Chat `chat_messages`.
+ longer meaningful (Live mounts no chat routes); Live's readers now use Chat's internal read API
+ (`server/chat/internal-reads.js`).
## Rollback
## Rollback
@@ -226,11 +227,10 @@ unanswered, not served by Live. The nginx include stays in place through any rol
@@ -226,11 +227,10 @@ unanswered, not served by Live. The nginx include stays in place through any rol
are untouched, so chat keeps being served — a socket reconnects after the restart. Nothing blocks
are untouched, so chat keeps being served — a socket reconnects after the restart. Nothing blocks
this: the migrations are additive and the previous release reads the same database.
this: the migrations are additive and the previous release reads the same database.
2. If Chat cannot run at all, chat is down until it is restored: Live has no copy of the sockets,
2. If Chat cannot run at all, chat is down until it is restored: Live has no copy of the sockets,
- the DMs, the TTS and sound queue or the moderation log to serve from. Live's own readers stay
- correct through the read mirror. Restore `openvibe-chat.service`, then let the mirror drain
- (`curl -s 127.0.0.1:4400/ready | jq .mirror.pending` → 0); when the service still cannot start,
- `cd /opt/openvibe.chat && sudo -u ubuntu env $(sudo cat /etc/openvibe/chat.env | xargs) node
- scripts/mirror-flush.js` (`DATABASE_URL` names Chat's database; exit 0 = drained).
+ the DMs, the TTS and sound queue or the moderation log to serve from. There is no rollback
+ through the read mirror any more (retired 2026-10-05): Live's readers call Chat's internal read
+ API, so while Chat is down those reads fail too. Restore `openvibe-chat.service` and confirm
+ `/ready`; there is no mirror queue to drain and no `scripts/mirror-flush.js`.
3. Keep the database and the sounds. `/var/lib/openvibe-chat/chat.db` (PostgreSQL after the switch
3. Keep the database and the sounds. `/var/lib/openvibe-chat/chat.db` (PostgreSQL after the switch
below) is the only copy of chat's own state — rooms, DMs and their read state, the audio queue,
below) is the only copy of chat's own state — rooms, DMs and their read state, the audio queue,
calls — and the clips under `SOUNDS_PATH` are the only copy of the sound files; keep both for
calls — and the clips under `SOUNDS_PATH` are the only copy of the sound files; keep both for
@@ -281,8 +281,8 @@ Found while moving; deliberately not fixed in this wave (fix after the cutover,
@@ -281,8 +281,8 @@ Found while moving; deliberately not fixed in this wave (fix after the cutover,
## PostgreSQL switch (plan T3, ADR-035)
## PostgreSQL switch (plan T3, ADR-035)
-Chat's own database moves from the SQLite file to PostgreSQL; the Live read mirror and the bridge (since
-retired, T3 J3a) were unchanged in meaning. This is a runbook for that one deploy — the schema is `migrations/0001_initial.sql`
+Chat's own database moves from the SQLite file to PostgreSQL; the Live read mirror (retired
+2026-10-05) and the bridge (retired, T3 J3a) were unchanged in meaning. This is a runbook for that one deploy — the schema is `migrations/0001_initial.sql`
(applied by `openvibe-sdk/db` at boot, owner role) and the data moves once.
(applied by `openvibe-sdk/db` at boot, owner role) and the data moves once.
**Prerequisites.** `DATABASE_URL` (the runtime role, through PgBouncer — DML only, no session state) and
**Prerequisites.** `DATABASE_URL` (the runtime role, through PgBouncer — DML only, no session state) and
@@ -301,13 +301,14 @@ migration has, parents first, ids kept; identity sequences are `setval`'d past t
@@ -301,13 +301,14 @@ migration has, parents first, ids kept; identity sequences are `setval`'d past t
table is verified (row count + a checksum walked in primary-key order) and printed as one line
table is verified (row count + a checksum walked in primary-key order) and printed as one line
(`[import] <table> <rows> rows checksum <16 hex> (<ms> ms)`), then the tail
(`[import] <table> <rows> rows checksum <16 hex> (<ms> ms)`), then the tail
(`[import] <n> tables, <rows> rows, ok|FAILED`); the whole report is recorded in `import_runs`. It runs with
(`[import] <n> tables, <rows> rows, ok|FAILED`); the whole report is recorded in `import_runs`. It runs with
-`ov.mirror_skip = '1'`, so the imported rows are never queued into Live's read mirror, and ends with
+`ov.mirror_skip = '1'`, so the imported rows are never queued into the (retired) Live read mirror's
+outbox, and ends with
`ANALYZE` as the owner (the runtime role's ANALYZE is a no-op; without statistics the history page plans a
`ANALYZE` as the owner (the runtime role's ANALYZE is a no-op; without statistics the history page plans a
Sort). A problem (a source column with no target, a verification mismatch) makes it exit 1.
Sort). A problem (a source column with no target, a verification mismatch) makes it exit 1.
**Switch.**
**Switch.**
-1. On the old release: `curl -s 127.0.0.1:4400/ready | jq .mirror.pending` → 0 (Live has every change), then
- stop the unit (drained: nothing writes the file any more).
+1. On the old release: stop the unit (drained: nothing writes the file any more). Before 2026-10-05
+ this also waited for `.mirror.pending` → 0; the mirror is retired, so `/ready` reports no mirror.
2. Keep the file as it is (`cp -a /var/lib/openvibe-chat/chat.db /var/lib/openvibe-chat/chat.db.pre-pg`) and
2. Keep the file as it is (`cp -a /var/lib/openvibe-chat/chat.db /var/lib/openvibe-chat/chat.db.pre-pg`) and
import it as the owner (the script applies `migrations/` first, then copies):
import it as the owner (the script applies `migrations/` first, then copies):
@@ -319,14 +320,14 @@ Sort). A problem (a source column with no target, a verification mismatch) makes
@@ -319,14 +320,14 @@ Sort). A problem (a source column with no target, a verification mismatch) makes
4. Set `DATABASE_URL` and `DATABASE_DIRECT_URL` (and `VALKEY_URL`, `VALKEY_PREFIX`) in
4. Set `DATABASE_URL` and `DATABASE_DIRECT_URL` (and `VALKEY_URL`, `VALKEY_PREFIX`) in
`/etc/openvibe/chat.env`; deploy and start the new release (it applies `migrations/` as the owner at boot:
`/etc/openvibe/chat.env`; deploy and start the new release (it applies `migrations/` as the owner at boot:
nothing to do, the import's schema is current). `GET /ready`: `db` ok with `max_message_id` equal to the
nothing to do, the import's schema is current). `GET /ready`: `db` ok with `max_message_id` equal to the
- file's `select max(id) from chat_messages`, `valkey` ok (skipped without `VALKEY_URL`), `mirror.pending`
- back to 0 once the relay has flushed the first writes.
+ file's `select max(id) from chat_messages`, `valkey` ok (skipped without `VALKEY_URL`). (`/ready` no
+ longer reports a mirror queue: it was retired on 2026-10-05.)
5. From the deployed commit, `npm run n-1:record` and commit `test/fixtures/n-1`: the N-1 test then replays
5. From the deployed commit, `npm run n-1:record` and commit `test/fixtures/n-1`: the N-1 test then replays
this release's PostgreSQL statements again (it reports itself skipped until then).
this release's PostgreSQL statements again (it reports itself skipped until then).
**Rollback** is the previous release plus the retained SQLite file: stop Chat, unset `DATABASE_URL` /
**Rollback** is the previous release plus the retained SQLite file: stop Chat, unset `DATABASE_URL` /
`DATABASE_DIRECT_URL` (the old release reads `CHAT_DB_PATH`, which the unit still sets), and start the old
`DATABASE_DIRECT_URL` (the old release reads `CHAT_DB_PATH`, which the unit still sets), and start the old
-release on `chat.db.pre-pg`. The twelve chat tables Live mirrors lose nothing Live has already seen; rows
+release on `chat.db.pre-pg`. The twelve chat tables Live once mirrored lose nothing Live has already seen; rows
written on PostgreSQL after the switch to Chat's own tables (rooms, calls, the audio queue, DMs' read
written on PostgreSQL after the switch to Chat's own tables (rooms, calls, the audio queue, DMs' read
state) are not in the file — export them from PostgreSQL first if the rollback comes after real traffic.
state) are not in the file — export them from PostgreSQL first if the rollback comes after real traffic.
Keep the SQLite file read-only for the rollback window; do not delete it until the PostgreSQL deploy has
Keep the SQLite file read-only for the rollback window; do not delete it until the PostgreSQL deploy has
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+-- phase: expand
+--
+-- The Live read mirror is retired (2026-10-05): Live #31 reads every chat table through Chat's internal reads
+-- (docs/chat-ingress.md) and this release deletes the sender (server/bridge/live-mirror.js). The capture
+-- triggers 0001_initial.sql put on twelve tables would otherwise keep queueing a row in live_mirror_outbox for
+-- every chat write with nothing left to drain it. Dropping them is safe for the previous release too: its sender
+-- only ever fed Live's mirror, which nothing reads any more. The (now idle) live_mirror_outbox table itself
+-- is dropped by a later contract migration.
+DROP TRIGGER IF EXISTS mirror_chat_messages ON chat_messages;
+DROP TRIGGER IF EXISTS mirror_dm_conversations ON dm_conversations;
+DROP TRIGGER IF EXISTS mirror_dm_participants ON dm_participants;
+DROP TRIGGER IF EXISTS mirror_dm_messages ON dm_messages;
+DROP TRIGGER IF EXISTS mirror_dm_blocks ON dm_blocks;
+DROP TRIGGER IF EXISTS mirror_tts_voice_overrides ON tts_voice_overrides;
+DROP TRIGGER IF EXISTS mirror_channel_sounds ON channel_sounds;
+DROP TRIGGER IF EXISTS mirror_relay_users ON relay_users;
+DROP TRIGGER IF EXISTS mirror_hidden_relay_users ON hidden_relay_users;
+DROP TRIGGER IF EXISTS mirror_pending_ip_messages ON pending_ip_messages;
+DROP TRIGGER IF EXISTS mirror_stream_first_chats ON stream_first_chats;
+DROP TRIGGER IF EXISTS mirror_moderation_actions ON moderation_actions;
+
+DROP FUNCTION IF EXISTS mirror_chat_messages();
+DROP FUNCTION IF EXISTS mirror_dm_conversations();
+DROP FUNCTION IF EXISTS mirror_dm_participants();
+DROP FUNCTION IF EXISTS mirror_dm_messages();
+DROP FUNCTION IF EXISTS mirror_dm_blocks();
+DROP FUNCTION IF EXISTS mirror_tts_voice_overrides();
+DROP FUNCTION IF EXISTS mirror_channel_sounds();
+DROP FUNCTION IF EXISTS mirror_relay_users();
+DROP FUNCTION IF EXISTS mirror_hidden_relay_users();
+DROP FUNCTION IF EXISTS mirror_pending_ip_messages();
+DROP FUNCTION IF EXISTS mirror_stream_first_chats();
+DROP FUNCTION IF EXISTS mirror_moderation_actions();
deleted file mode 100644
deleted file mode 100644
@@ -1,25 +0,0 @@
@@ -1,25 +0,0 @@
-#!/usr/bin/env node
-/**
- * Send whatever is still queued in live_mirror_outbox to Live, once, without starting the service.
- * A recovery helper when the service cannot run: Live's readers see every change Chat made
- * (docs/cutover.md). There is no rollback to Live to drain for. Uses the same env as the service
- * (/etc/openvibe/chat.env: DATABASE_URL, DATABASE_DIRECT_URL); LIVE_MIRROR is forced on.
- *
- * node scripts/mirror-flush.js → { sent, pending, error }
- */
-'use strict';
-
-process.env.LIVE_MIRROR = '1';
-const config = require('../server/config');
-const db = require('../server/db/database');
-const { createMirror } = require('../server/bridge/live-mirror');
-
-(async () => {
- await db.initDb();
- const mirror = createMirror({ config });
- const before = await mirror.pending();
- const r = await mirror.flush();
- console.log(JSON.stringify({ pending_before: before, ...r }, null, 2));
- await db.close();
- process.exit(r.pending ? 1 : 0);
-})().catch((err) => { console.error(err.message); process.exit(3); });
@@ -61,7 +61,7 @@ function getAllowedOrigins() {
@@ -61,7 +61,7 @@ function getAllowedOrigins() {
return allowed;
return allowed;
}
}
-function createApp({ chatServer, mirror, relay, events = null, callServer = null }) {
+function createApp({ chatServer, relay, events = null, callServer = null }) {
const allowedOrigins = getAllowedOrigins();
const allowedOrigins = getAllowedOrigins();
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -162,8 +162,6 @@ function createApp({ chatServer, mirror, relay, events = null, callServer = null
@@ -162,8 +162,6 @@ function createApp({ chatServer, mirror, relay, events = null, callServer = null
const failed = Object.keys(checks).filter((k) => checks[k].required && checks[k].status !== 'ok');
const failed = Object.keys(checks).filter((k) => checks[k].required && checks[k].status !== 'ok');
const degraded = Object.keys(checks).filter((k) => !checks[k].required && checks[k].status !== 'ok');
const degraded = Object.keys(checks).filter((k) => !checks[k].required && checks[k].status !== 'ok');
const ready = failed.length === 0;
const ready = failed.length === 0;
- let pending = null;
- if (mirror && checks.db.status === 'ok') { try { pending = await mirror.pending(); } catch { /* reported by db */ } }
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
res.status(ready ? 200 : 503).json({
res.status(ready ? 200 : 503).json({
ready,
ready,
@@ -175,7 +173,6 @@ function createApp({ chatServer, mirror, relay, events = null, callServer = null
@@ -175,7 +173,6 @@ function createApp({ chatServer, mirror, relay, events = null, callServer = null
checks,
checks,
connections: chatServer.getTotalConnections(),
connections: chatServer.getTotalConnections(),
live: { last_sync_at: ctx.stats.lastSyncAt, last_success_at: ctx.stats.lastSuccessAt, failures: ctx.stats.failures, last_error: ctx.stats.lastError },
live: { last_sync_at: ctx.stats.lastSyncAt, last_success_at: ctx.stats.lastSuccessAt, failures: ctx.stats.failures, last_error: ctx.stats.lastError },
- mirror: mirror ? { enabled: config.live.mirror, pending, last_error: mirror.lastError() } : null,
events: {
events: {
enabled: !!config.events.url,
enabled: !!config.events.url,
consumer: events ? { enabled: events.enabled, ...events.stats } : null,
consumer: events ? { enabled: events.enabled, ...events.stats } : null,
deleted file mode 100644
deleted file mode 100644
@@ -1,145 +0,0 @@
@@ -1,145 +0,0 @@
-/**
- * OpenVibe.Chat → OpenVibe.Live read mirror.
- *
- * 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).
- * Chat is required in every mode and Live's local chat routes are retired, so this is no longer a
- * rollback path: it exists for Live's remaining readers until a later wave retires them. Every
- * change Chat makes to its tables (C-02) is copied into Live's tables of the same name, same ids:
- *
- * - PostgreSQL triggers on the twelve tables (migrations/0001_initial.sql) record each change in
- * live_mirror_outbox in the writing transaction — skipped where the transaction set ov.mirror_skip = '1'
- * (the importers: rows that came from Live are never recorded);
- * - this relay sends batches to Live's POST /internal/chat-effects/mirror (capability
- * live.chat_mirror.write) as { changes: [{ table, op: 'upsert', row } | { table, op: 'delete', pk }] }
- * with the row as it is NOW (so repeated changes to one row collapse into one upsert);
- * - Live applies them idempotently (INSERT … ON CONFLICT DO UPDATE on the columns it has; it
- * refuses unless it runs with CHAT_AUTHORITY=chat, so a rehearsal can never touch production).
- *
- * Rows leave the outbox only when Live acknowledged them; a Live outage just queues. `pending()`
- * must be 0 before Live's chat-table readers (and this mirror) are retired (docs/cutover.md).
- */
-'use strict';
-
-const db = require('../db/database');
-const serviceAuth = require('../net/service-auth');
-
-const BATCH = 200;
-const MAX_BODY = 800 * 1024; // Live parses JSON bodies up to 1 MB
-const SCOPE = 'live.chat_mirror.write';
-
-// The row as it is now, per mirrored table (static statements, keyed as db.CHAT_TABLES).
-const ROW_SQL = {
- chat_messages: 'SELECT * FROM chat_messages WHERE id = ?',
- dm_conversations: 'SELECT * FROM dm_conversations WHERE id = ?',
- dm_participants: 'SELECT * FROM dm_participants WHERE id = ?',
- dm_messages: 'SELECT * FROM dm_messages WHERE id = ?',
- dm_blocks: 'SELECT * FROM dm_blocks WHERE id = ?',
- tts_voice_overrides: 'SELECT * FROM tts_voice_overrides WHERE identity_key = ?',
- channel_sounds: 'SELECT * FROM channel_sounds WHERE id = ?',
- relay_users: 'SELECT * FROM relay_users WHERE id = ?',
- hidden_relay_users: 'SELECT * FROM hidden_relay_users WHERE id = ?',
- pending_ip_messages: 'SELECT * FROM pending_ip_messages WHERE id = ?',
- stream_first_chats: 'SELECT * FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',
- moderation_actions: 'SELECT * FROM moderation_actions WHERE id = ?',
-};
-
-// Live's asset sync still writes media_url/media_asset_id on its own copy until it moves to
-// POST /internal/chat/sounds/asset: a sound Chat has no asset for must not blank Live's.
-function soundRow(row) {
- const { media_url, media_asset_id, ...rest } = row;
- return media_asset_id == null ? rest : row;
-}
-
-function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), log = console } = {}) {
- let timer = null;
- let busy = false;
- let lastError = null;
-
- async function pending() { return (await db.get('SELECT COUNT(*) AS n FROM live_mirror_outbox'))?.n || 0; }
-
- async function buildChanges(rows) {
- // Newest state per row: several changes to one key collapse into the last op.
- const byKey = new Map();
- for (const r of rows) byKey.set(`${r.tbl}|${r.pk}`, r);
- const changes = [];
- for (const r of byKey.values()) {
- const cols = db.CHAT_TABLES[r.tbl];
- if (!cols) continue;
- let pk;
- try { pk = JSON.parse(r.pk); } catch { continue; }
- if (r.op === 'delete') { changes.push({ table: r.tbl, op: 'delete', pk }); continue; }
- const row = await db.get(ROW_SQL[r.tbl], cols.map((c) => pk[c]));
- changes.push(row ? { table: r.tbl, op: 'upsert', row: r.tbl === 'moderation_actions' ? db.moderationRow(row) : r.tbl === 'chat_messages' ? db.chatMessageRow(row) : r.tbl === 'channel_sounds' ? soundRow(row) : row } : { table: r.tbl, op: 'delete', pk });
- }
- return changes;
- }
-
- async function flush() {
- if (!config.live.mirror) return { sent: 0, disabled: true };
- if (busy) return { sent: 0, busy: true };
- busy = true;
- let sent = 0;
- try {
- for (;;) {
- const rows = await db.all('SELECT seq, tbl, op, pk FROM live_mirror_outbox ORDER BY seq LIMIT ?', [BATCH]);
- if (!rows.length) break;
- const changes = await buildChanges(rows);
- // Requests stay under Live's body limit; a batch is acknowledged when all its parts are.
- const parts = [[]];
- let size = 0;
- for (const c of changes) {
- const n = Buffer.byteLength(JSON.stringify(c));
- if (parts[parts.length - 1].length && size + n > MAX_BODY) { parts.push([]); size = 0; }
- parts[parts.length - 1].push(c);
- size += n;
- }
- let failed = false;
- for (const part of parts) {
- let res;
- try {
- res = await fetchImpl(`${config.live.internalUrl}/internal/chat-effects/mirror`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', ...(await serviceAuth.headers(config.live.audience, SCOPE)) },
- body: JSON.stringify({ changes: part }),
- signal: AbortSignal.timeout(15000),
- });
- } catch (err) { lastError = err.message; failed = true; break; }
- if (res.status === 401) serviceAuth.invalidate(config.live.audience, SCOPE);
- if (!res.ok) {
- const text = await res.text().catch(() => '');
- lastError = `${res.status} ${text.slice(0, 200)}`;
- failed = true;
- break;
- }
- }
- if (failed) break;
- const maxSeq = rows[rows.length - 1].seq;
- await db.run('DELETE FROM live_mirror_outbox WHERE seq <= ?', [maxSeq]);
- sent += changes.length;
- lastError = null;
- }
- } finally { busy = false; }
- if (lastError) log.warn(`[Mirror] Live mirror waiting (${await pending()} pending): ${lastError}`);
- return { sent, pending: await pending(), error: lastError };
- }
-
- // The triggers record every change; with the mirror off (rehearsals, tests) nothing is kept, as when the capture
- // was per connection and off: the queue is emptied on the same cadence instead of sent.
- async function discard() { await db.run('DELETE FROM live_mirror_outbox'); }
-
- function start() {
- if (timer) return;
- if (!config.live.mirror) {
- timer = setInterval(() => { discard().catch((e) => log.warn('[Mirror]', e.message)); }, config.live.mirrorIntervalMs);
- if (timer.unref) timer.unref();
- return;
- }
- timer = setInterval(() => { flush().catch((e) => log.warn('[Mirror]', e.message)); }, config.live.mirrorIntervalMs);
- if (timer.unref) timer.unref();
- }
- function stop() { if (timer) clearInterval(timer); timer = null; }
- return { start, stop, flush, pending, lastError: () => lastError };
-}
-
-module.exports = { createMirror };
@@ -80,11 +80,6 @@ module.exports = {
@@ -80,11 +80,6 @@ module.exports = {
live: {
live: {
internalUrl: strip(process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
internalUrl: strip(process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
audience: 'openvibe.live',
audience: 'openvibe.live',
- // Chat's read-mirror of its tables into Live (for Live's own readers; there is no rollback
- // path to Live any more). Switch on at cutover; Live refuses mirror writes unless it runs
- // with CHAT_AUTHORITY=chat, so a rehearsal can never touch production.
- mirror: bool(process.env.LIVE_MIRROR, false),
- mirrorIntervalMs: int(process.env.LIVE_MIRROR_INTERVAL_MS, 1000),
requestTimeoutMs: int(process.env.LIVE_TIMEOUT_MS, 4000),
requestTimeoutMs: int(process.env.LIVE_TIMEOUT_MS, 4000),
// /ready reports the Live sync degraded once its last clean pass is older than this.
// /ready reports the Live sync degraded once its last clean pass is older than this.
// The loop ticks every 2 s and the bans and stream projections are due every 10 s, so a
// The loop ticks every 2 s and the bans and stream projections are due every 10 s, so a
@@ -177,7 +172,7 @@ module.exports = {
@@ -177,7 +172,7 @@ module.exports = {
// itself and calls AI with its OWN service token (client `chat`, audience openvibe.ai, network
// itself and calls AI with its OWN service token (client `chat`, audience openvibe.ai, network
// grants ai.run.create/ai.run.read on namespace chat.*). `enabled` is the job's switch: off in
// grants ai.run.create/ai.run.read on namespace chat.*). `enabled` is the job's switch: off in
// development and the test harness, on in production through CHAT_AI_ENABLED=1 (its other jobs
// development and the test harness, on in production through CHAT_AI_ENABLED=1 (its other jobs
- // are gated the same way — LIVE_MIRROR, EVENTS_URL).
+ // are gated the same way — EVENTS_URL).
ai: {
ai: {
enabled: bool(process.env.CHAT_AI_ENABLED, false),
enabled: bool(process.env.CHAT_AI_ENABLED, false),
internalUrl: strip(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'),
internalUrl: strip(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'),
@@ -24,7 +24,7 @@ let db = null;
@@ -24,7 +24,7 @@ let db = null;
let _idTables = null; // tables with an `id` column: an INSERT into one returns it (lastInsertRowid)
let _idTables = null; // tables with an `id` column: an INSERT into one returns it (lastInsertRowid)
const _stmts = new Map();
const _stmts = new Map();
-// Tables Chat writes from the cutover on; Live keeps a read mirror of each (server/bridge/live-mirror.js).
+// Tables Chat writes from the cutover on; Live reads them through Chat's internal read API.
const CHAT_TABLES = {
const CHAT_TABLES = {
chat_messages: ['id'],
chat_messages: ['id'],
dm_conversations: ['id'],
dm_conversations: ['id'],
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
*
*
* Boot: database → Live projections (first full sync; Chat serves with whatever it has if Live is
* Boot: database → Live projections (first full sync; Chat serves with whatever it has if Live is
* slow) → chat and call WebSocket servers → HTTP app (with the Events consumer, POST /internal/events) →
* slow) → chat and call WebSocket servers → HTTP app (with the Events consumer, POST /internal/events) →
- * background loops (Live sync, events outbox relay, Live mirror relay) → once listening, Chat's
+ * background loops (Live sync, events outbox relay) → once listening, Chat's
* Events subscriptions are checked and created if missing (in the background, retried). SIGTERM
* Events subscriptions are checked and created if missing (in the background, retried). SIGTERM
* tells clients the chat is restarting, closes sockets and exits.
* tells clients the chat is restarting, closes sockets and exits.
*/
*/
@@ -17,14 +17,13 @@ const db = require('./db/database');
@@ -17,14 +17,13 @@ const db = require('./db/database');
const ctx = require('./live-context');
const ctx = require('./live-context');
const chatServer = require('./chat/chat-server');
const chatServer = require('./chat/chat-server');
const callServer = require('./calls/call-server');
const callServer = require('./calls/call-server');
-const { createMirror } = require('./bridge/live-mirror');
const { createRelay } = require('./events/outbox');
const { createRelay } = require('./events/outbox');
const { createEventsConsumer } = require('./events/consumer');
const { createEventsConsumer } = require('./events/consumer');
const subscriptions = require('./events/subscriptions');
const subscriptions = require('./events/subscriptions');
const chatAi = require('./ai/chat-ai');
const chatAi = require('./ai/chat-ai');
const { limits } = require('./net/actor-limits');
const { limits } = require('./net/actor-limits');
const { createValkey } = require('openvibe-sdk/valkey');
const { createValkey } = require('openvibe-sdk/valkey');
-const { gracefulStop, within } = require('openvibe-sdk/service');
+const { gracefulStop } = require('openvibe-sdk/service');
const { createApp } = require('./app');
const { createApp } = require('./app');
let rejectionsLogged = false;
let rejectionsLogged = false;
@@ -37,9 +36,8 @@ async function start() {
@@ -37,9 +36,8 @@ async function start() {
rejectionsLogged = true;
rejectionsLogged = true;
process.on('unhandledRejection', (err) => console.error('[Chat] unhandled rejection:', (err && err.stack) || err));
process.on('unhandledRejection', (err) => console.error('[Chat] unhandled rejection:', (err && err.stack) || err));
}
}
- console.log(`[Chat] OpenVibe.Chat starting (db ${config.db.url ? 'postgresql' : 'pglite'}, Live ${config.live.internalUrl}, mirror ${config.live.mirror ? 'on' : 'off'}, events ${config.events.url || 'off'}, consumer ${config.events.secrets.length ? 'on' : 'off'})`);
+ console.log(`[Chat] OpenVibe.Chat starting (db ${config.db.url ? 'postgresql' : 'pglite'}, Live ${config.live.internalUrl}, events ${config.events.url || 'off'}, consumer ${config.events.secrets.length ? 'on' : 'off'})`);
// PostgreSQL (DATABASE_URL; migrations first, as the owner) or, outside production, an embedded PGlite.
// PostgreSQL (DATABASE_URL; migrations first, as the owner) or, outside production, an embedded PGlite.
- // The Live mirror's capture is the migration's triggers; with the mirror off its queue is emptied.
await db.initDb();
await db.initDb();
// Shared per-actor rate-limit counters on Valkey (ADR-035); without VALKEY_URL they count in this
// Shared per-actor rate-limit counters on Valkey (ADR-035); without VALKEY_URL they count in this
@@ -55,13 +53,11 @@ async function start() {
@@ -55,13 +53,11 @@ async function start() {
const server = http.createServer();
const server = http.createServer();
chatServer.init(server);
chatServer.init(server);
callServer.init(server);
callServer.init(server);
- const mirror = createMirror({ config });
const relay = createRelay({ config });
const relay = createRelay({ config });
const events = createEventsConsumer({ chatServer, secrets: config.events.secrets });
const events = createEventsConsumer({ chatServer, secrets: config.events.secrets });
- const { app, handleUpgrade } = createApp({ chatServer, mirror, relay, events, callServer });
+ const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer });
server.on('request', app);
server.on('request', app);
server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });
server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });
- mirror.start();
relay.start();
relay.start();
events.start();
events.start();
// The chat-AI job (Live's server/ai/chat-ai.js, now Chat's): off unless CHAT_AI_ENABLED=1; it
// The chat-AI job (Live's server/ai/chat-ai.js, now Chat's): off unless CHAT_AI_ENABLED=1; it
@@ -75,9 +71,8 @@ async function start() {
@@ -75,9 +71,8 @@ async function start() {
// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the stop
// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the stop
// steps in order (nothing new starts), drains the HTTP server, runs the close steps, then exits 0. drainMs
// steps in order (nothing new starts), drains the HTTP server, runs the close steps, then exits 0. drainMs
// bounds the drain; deadlineMs 8000 is the manifest's lifecycle.shutdown.deadlineSeconds (openvibe-contracts
// bounds the drain; deadlineMs 8000 is the manifest's lifecycle.shutdown.deadlineSeconds (openvibe-contracts
- // manifests/services/chat.json) and deadlineExitCode 0 keeps the hand-written hard timer's exit 0. The mirror
- // flush stays bounded at 3 s (within). The stop steps keep the old shutdown's exact order; a step that throws
- // is logged and the stop goes on.
+ // manifests/services/chat.json) and deadlineExitCode 0 keeps the hand-written hard timer's exit 0. The stop
+ // steps keep the old shutdown's exact order; a step that throws is logged and the stop goes on.
const { stop: shutdown } = gracefulStop({
const { stop: shutdown } = gracefulStop({
name: 'Chat',
name: 'Chat',
server,
server,
@@ -92,18 +87,16 @@ async function start() {
@@ -92,18 +87,16 @@ async function start() {
}),
}),
() => ctx.stop(),
() => ctx.stop(),
() => relay.stop(),
() => relay.stop(),
- () => mirror.stop(),
() => events.stop(),
() => events.stop(),
() => chatAi.stop(),
() => chatAi.stop(),
() => subs.stop(),
() => subs.stop(),
() => { if (valkey) return valkey.close(); },
() => { if (valkey) return valkey.close(); },
- () => within(3000, mirror.flush()),
() => chatServer.close(),
() => chatServer.close(),
() => callServer.close(),
() => callServer.close(),
],
],
close: [() => db.close()],
close: [() => db.close()],
});
});
- return { server, mirror, relay, events, callServer, subscriptions: subs, shutdown };
+ return { server, relay, events, callServer, subscriptions: subs, shutdown };
}
}
if (require.main === module) {
if (require.main === module) {
@@ -11,7 +11,6 @@
@@ -11,7 +11,6 @@
* openvibe.live —
* openvibe.live —
* GET/POST /internal/chat-context/* capability live.chat_context.read
* GET/POST /internal/chat-context/* capability live.chat_context.read
* POST /internal/chat-effects/* capability live.chat_effects.write
* POST /internal/chat-effects/* capability live.chat_effects.write
- * POST /internal/chat-effects/mirror capability live.chat_mirror.write (bridge/live-mirror.js)
*
*
* Hot path rule: nothing a chat message touches makes a network call. Reads are answered from
* Hot path rule: nothing a chat message touches makes a network call. Reads are answered from
* - SQLite projections (ctx_users, ctx_streams, ctx_managed_streams, ctx_channels), kept
* - SQLite projections (ctx_users, ctx_streams, ctx_managed_streams, ctx_channels), kept
@@ -105,7 +105,7 @@ async function boot({ env = {} } = {}) {
@@ -105,7 +105,7 @@ async function boot({ env = {} } = {}) {
const f = new URLSearchParams(raw);
const f = new URLSearchParams(raw);
tokenRequests.push({ audience: f.get('audience'), scope: f.get('scope'), client: f.get('client_id') });
tokenRequests.push({ audience: f.get('audience'), scope: f.get('scope'), client: f.get('client_id') });
const cap = String(f.get('scope') || '').split(/\s+/).filter(Boolean);
const cap = String(f.get('scope') || '').split(/\s+/).filter(Boolean);
- return res.end(JSON.stringify({ access_token: serviceToken(cap.length ? cap : ['live.chat_context.read', 'live.chat_effects.write', 'live.chat_mirror.write'], { aud: f.get('audience'), sub: 'svc:chat' }), expires_in: 300 }));
+ return res.end(JSON.stringify({ access_token: serviceToken(cap.length ? cap : ['live.chat_context.read', 'live.chat_effects.write'], { aud: f.get('audience'), sub: 'svc:chat' }), expires_in: 300 }));
}
}
if (req.url === '/api/.well-known/jwks') return res.end(JSON.stringify({ public_key: keys.publicKey }));
if (req.url === '/api/.well-known/jwks') return res.end(JSON.stringify({ public_key: keys.publicKey }));
res.statusCode = 404; res.end('{}');
res.statusCode = 404; res.end('{}');
@@ -128,8 +128,6 @@ async function boot({ env = {} } = {}) {
@@ -128,8 +128,6 @@ async function boot({ env = {} } = {}) {
decor: new Map(), // userId → { cosmetic, tag }
decor: new Map(), // userId → { cosmetic, tag }
anon: new Map(), // ip → { num, first_seen }
anon: new Map(), // ip → { num, first_seen }
effects: [], // { name, body }
effects: [], // { name, body }
- mirror: [], // changes
- mirrorStatus: 200,
down: false, // Live refuses: every connection is dropped at once
down: false, // Live refuses: every connection is dropped at once
hang: false, // Live hangs: connections are accepted and never answered
hang: false, // Live hangs: connections are accepted and never answered
nextAnon: 1,
nextAnon: 1,
@@ -228,13 +226,8 @@ async function boot({ env = {} } = {}) {
@@ -228,13 +226,8 @@ async function boot({ env = {} } = {}) {
}
}
if (p.startsWith('/internal/chat-effects/')) {
if (p.startsWith('/internal/chat-effects/')) {
const name = p.slice('/internal/chat-effects/'.length);
const name = p.slice('/internal/chat-effects/'.length);
- const bad = verify(req, name === 'mirror' ? 'live.chat_mirror.write' : 'live.chat_effects.write');
+ const bad = verify(req, 'live.chat_effects.write');
if (bad) return send(401, { error: bad });
if (bad) return send(401, { error: bad });
- if (name === 'mirror') {
- if (live.mirrorStatus !== 200) return send(live.mirrorStatus, { error: 'refused' });
- live.mirror.push(...(body.changes || []));
- return send(200, { ok: true, applied: (body.changes || []).length, skipped: [] });
- }
live.effects.push({ name, body });
live.effects.push({ name, body });
switch (name) {
switch (name) {
case 'anon': {
case 'anon': {
@@ -298,7 +291,6 @@ async function boot({ env = {} } = {}) {
@@ -298,7 +291,6 @@ async function boot({ env = {} } = {}) {
OV_OAUTH_CLIENT_ID: 'chat',
OV_OAUTH_CLIENT_ID: 'chat',
OV_OAUTH_CLIENT_SECRET: 'chat-secret',
OV_OAUTH_CLIENT_SECRET: 'chat-secret',
OV_LIVE_INTERNAL_URL: `http://127.0.0.1:${livePort}`,
OV_LIVE_INTERNAL_URL: `http://127.0.0.1:${livePort}`,
- LIVE_MIRROR: '0',
EVENTS_URL: '',
EVENTS_URL: '',
...env,
...env,
});
});
@@ -342,7 +334,6 @@ async function boot({ env = {} } = {}) {
@@ -342,7 +334,6 @@ async function boot({ env = {} } = {}) {
const index = require('../server/index');
const index = require('../server/index');
const started = await index.start();
const started = await index.start();
h.server = started.server;
h.server = started.server;
- h.mirrorRelay = started.mirror;
h.eventsRelay = started.relay;
h.eventsRelay = started.relay;
h.eventsConsumer = started.events;
h.eventsConsumer = started.events;
h.subscriptions = started.subscriptions;
h.subscriptions = started.subscriptions;
@@ -404,7 +395,7 @@ async function boot({ env = {} } = {}) {
@@ -404,7 +395,7 @@ async function boot({ env = {} } = {}) {
*/
*/
h.detach = async () => {
h.detach = async () => {
try { await h.chatServer.close(); } catch { /* */ }
try { await h.chatServer.close(); } catch { /* */ }
- try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); } catch { /* */ }
+ try { h.ctx.stop(); h.eventsRelay.stop(); } catch { /* */ }
await new Promise((r) => h.server.close(() => r()));
await new Promise((r) => h.server.close(() => r()));
try { await h.db.close(); } catch { /* */ }
try { await h.db.close(); } catch { /* */ }
h.children = [];
h.children = [];
@@ -447,7 +438,7 @@ async function boot({ env = {} } = {}) {
@@ -447,7 +438,7 @@ async function boot({ env = {} } = {}) {
h.close = async () => {
h.close = async () => {
for (const c of h.children || []) { if (c.exitCode == null && c.signalCode == null) { c.kill('SIGKILL'); await c.exited; } }
for (const c of h.children || []) { if (c.exitCode == null && c.signalCode == null) { c.kill('SIGKILL'); await c.exited; } }
try { await h.chatServer.close(); } catch { /* */ }
try { await h.chatServer.close(); } catch { /* */ }
- try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }
+ try { h.ctx.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }
await new Promise((r) => h.server.close(() => r()));
await new Promise((r) => h.server.close(() => r()));
network.close(); liveServer.close(); if (liveServer.closeAllConnections) liveServer.closeAllConnections();
network.close(); liveServer.close(); if (liveServer.closeAllConnections) liveServer.closeAllConnections();
try { await h.db.close(); } catch { /* */ }
try { await h.db.close(); } catch { /* */ }
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+/**
+ * The Live read mirror is retired (plan T3, 2026-10-05): Live #31 reads Chat's internal read API
+ * (server/chat/internal-reads.js) instead of its mirror-filled copies of Chat's tables, so no Chat
+ * code path enqueues into live_mirror_outbox or drains it. The table itself stays until a later
+ * contract migration drops it, and migrations/0001_initial.sql still has the per-table triggers
+ * that fill it (schema SQL, not an application code path); this guard is about the sender, its
+ * config and its enqueue/relay call sites never coming back.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { suite } = require('./helpers');
+
+const t = suite('no-live-mirror');
+const ROOT = path.join(__dirname, '..');
+const RETIRED = /live_mirror_outbox|live-mirror|LIVE_MIRROR|createMirror|mirror-flush|mirrorIntervalMs|chat_mirror\.write/;
+
+function files(dir) {
+ const out = [];
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) out.push(...files(p));
+ else if (/\.(js|mjs|cjs)$/.test(e.name)) out.push(p);
+ }
+ return out;
+}
+
+t('no server/ or scripts/ code path writes, reads or drains live_mirror_outbox', () => {
+ const offenders = [...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'scripts'))]
+ .filter((f) => RETIRED.test(fs.readFileSync(f, 'utf8')))
+ .map((f) => path.relative(ROOT, f));
+ assert.deepStrictEqual(offenders, []);
+});
+
+t('the mirror sender module and the flush script are gone', () => {
+ for (const p of ['server/bridge/live-mirror.js', 'scripts/mirror-flush.js']) {
+ assert.ok(!fs.existsSync(path.join(ROOT, p)), `${p} is gone`);
+ }
+});
+
+t('.env.example no longer offers the retired mirror switches', () => {
+ const env = fs.readFileSync(path.join(ROOT, '.env.example'), 'utf8');
+ assert.ok(!/LIVE_MIRROR/.test(env), '.env.example still configures LIVE_MIRROR');
+});
+
+t.run();
deleted file mode 100644
deleted file mode 100644
@@ -1,151 +0,0 @@
@@ -1,151 +0,0 @@
-'use strict';
-/**
- * Events outbox (events.event-envelope@1, written in the same transaction as the change; relayed
- * to OpenVibe.Events only when EVENTS_URL is set, with a service token for audience
- * openvibe.events) and the Live read mirror (every change to Chat's tables copied to Live's
- * tables of the same name; held while Live refuses, drained when it accepts).
- */
-const assert = require('assert');
-const http = require('http');
-const { validate } = require('openvibe-contracts');
-const { boot, suite } = require('./helpers');
-
-const t = suite('outbox-mirror');
-let h, events, eventsPort, alice, bob, streamer, streamId;
-const received = [];
-let eventsMode = 'ok';
-
-t('boot with the mirror on and a stub OpenVibe.Events', async () => {
- events = http.createServer((req, res) => {
- let raw = '';
- req.on('data', (c) => { raw += c; });
- req.on('end', () => {
- res.setHeader('Content-Type', 'application/json');
- if (eventsMode !== 'ok') { res.statusCode = 503; return res.end('{"error":"down"}'); }
- const { serviceAuth } = require('openvibe-contracts');
- const r = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: h.keys.publicKey, issuer: h.ISS, audience: 'openvibe.events' });
- if (!r.ok || !r.claims.cap.includes('events.event.publish')) { res.statusCode = 401; return res.end('{}'); }
- const body = JSON.parse(raw);
- received.push(...body.events);
- res.statusCode = 201;
- res.end(JSON.stringify({ accepted: body.events.length }));
- });
- });
- eventsPort = await new Promise((r) => events.listen(0, '127.0.0.1', () => r(events.address().port)));
- h = await boot({ env: { LIVE_MIRROR: '1', LIVE_MIRROR_INTERVAL_MS: '3600000', EVENTS_URL: `http://127.0.0.1:${eventsPort}`, EVENTS_RELAY_INTERVAL_MS: '3600000' } });
- streamer = h.addUser('streamer', { role: 'streamer' });
- alice = h.addUser('alice', { subject: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
- bob = h.addUser('bob');
- streamId = h.addStream(streamer.id, h.addChannel(streamer.id));
- await h.ctx.sync();
-});
-
-t('a REST fallback message, a DM and a moderation action each leave one envelope', async () => {
- const sent = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'rest hello' } });
- assert.deepStrictEqual(sent.body, { ok: true });
- const conv = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
- await h.http('POST', `/api/dm/conversations/${conv.body.conversation.id}/messages`, { token: alice.token, body: { message: 'dm text' } });
- await h.db.logModerationAction({ scope_type: 'site', actor_user_id: alice.id, target_user_id: bob.id, action_type: 'site_timeout', details: { duration: 60 } });
- const rows = await h.db.all('SELECT event_type, event, sent_at FROM events_outbox ORDER BY seq');
- assert.deepStrictEqual(rows.map((r) => r.event_type), ['chat.message.created', 'chat.dm.created', 'chat.moderation.action']);
- for (const r of rows) {
- const env = JSON.parse(r.event);
- const v = validate('events.event-envelope@1', env);
- assert.ok(v.valid, JSON.stringify(v.errors));
- assert.strictEqual(r.sent_at, null);
- }
- const [msg, dm, mod] = rows.map((r) => JSON.parse(r.event));
- assert.strictEqual(msg.visibility, 'public');
- assert.deepStrictEqual(msg.payload.room, { type: 'global' });
- assert.strictEqual(msg.payload.text, 'rest hello');
- assert.strictEqual(dm.visibility, 'subject');
- assert.ok(!JSON.stringify(dm).includes('dm text'));
- assert.deepStrictEqual(dm.payload.participants.map((p) => p.user_id).sort(), [alice.id, bob.id].sort());
- assert.strictEqual(mod.visibility, 'internal');
- assert.deepStrictEqual(mod.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
- assert.deepStrictEqual(mod.actor.type, 'user');
- assert.deepStrictEqual(dm.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
-});
-
-t('Events down: rows wait with the error; back up: published once, marked sent', async () => {
- eventsMode = 'down';
- await h.eventsRelay.flush();
- const waiting = await h.db.all('SELECT attempts, last_error, sent_at FROM events_outbox');
- assert.ok(waiting.every((r) => r.attempts === 1 && /503/.test(r.last_error) && r.sent_at === null));
- eventsMode = 'ok';
- const r = await h.eventsRelay.flush();
- assert.strictEqual(r.sent, 3);
- assert.strictEqual(received.length, 3);
- assert.ok((await h.db.all('SELECT sent_at FROM events_outbox')).every((x) => x.sent_at));
- assert.strictEqual((await h.eventsRelay.flush()).sent, 0, 'nothing twice');
- assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.events' && x.scope === 'events.event.publish'));
-});
-
-t('mirror: every change to Chat’s tables reaches Live, newest state per row', async () => {
- const id = Number((await h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'to be deleted', message_type: 'chat' })).lastInsertRowid);
- await h.db.deleteChatMessage(id, streamer.id);
- await h.db.recordFirstChat(`user:${bob.id}`, streamer.id);
- await h.mirrorRelay.flush();
- const byTable = (tbl) => h.live.mirror.filter((c) => c.table === tbl);
- const msgChanges = byTable('chat_messages').filter((c) => c.row && c.row.id === id);
- assert.strictEqual(msgChanges.length, 1, 'insert + update collapsed into one upsert');
- assert.strictEqual(msgChanges[0].row.is_deleted, 1);
- assert.strictEqual(msgChanges[0].row.channel_user_id, streamer.id);
- assert.ok(byTable('chat_messages').some((c) => c.row && c.row.message === 'rest hello'));
- assert.ok(byTable('dm_messages').length === 1 && byTable('dm_participants').length === 2 && byTable('dm_conversations').length >= 1);
- assert.ok(byTable('moderation_actions').length === 1);
- assert.deepStrictEqual(byTable('stream_first_chats')[0].row.chatter_key, `user:${bob.id}`);
- assert.strictEqual(await h.mirrorRelay.pending(), 0);
- // A delete travels as a delete.
- const dmId = (await h.db.get('SELECT id FROM dm_messages')).id;
- const conv = (await h.db.get('SELECT conversation_id FROM dm_messages')).conversation_id;
- assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${conv}/messages/${dmId}`, { token: alice.token })).status, 200);
- await h.mirrorRelay.flush();
- assert.deepStrictEqual(h.live.mirror.at(-1), { table: 'dm_messages', op: 'delete', pk: { id: dmId } });
-});
-
-t('mirror: a channel sound without a Media asset leaves Live’s asset columns alone', async () => {
- const soundId = Number((await h.db.run(`INSERT INTO channel_sounds (channel_owner_id, command, url) VALUES (?, 'mirrorhonk', '/sounds/m.mp3')`, [streamer.id])).lastInsertRowid);
- await h.mirrorRelay.flush();
- const sent = h.live.mirror.filter((c) => c.table === 'channel_sounds' && c.row && c.row.id === soundId);
- assert.strictEqual(sent.length, 1);
- assert.ok(!('media_url' in sent[0].row) && !('media_asset_id' in sent[0].row), JSON.stringify(sent[0].row));
- await h.db.run('UPDATE channel_sounds SET media_url = ?, media_asset_id = ? WHERE id = ?', ['https://media.test/s/9', 9, soundId]);
- await h.mirrorRelay.flush();
- const row = h.live.mirror.filter((c) => c.table === 'channel_sounds' && c.row && c.row.id === soundId).at(-1).row;
- assert.strictEqual(row.media_url, 'https://media.test/s/9');
- assert.strictEqual(Number(row.media_asset_id), 9);
-});
-
-t('mirror: Live refusing (not yet CHAT_AUTHORITY=chat) keeps the queue; accepted later', async () => {
- h.live.mirrorStatus = 409;
- await h.db.recordRelayUser('twitch', 'Zed');
- const r = await h.mirrorRelay.flush();
- assert.ok(r.pending >= 1 && /409/.test(r.error));
- h.live.mirrorStatus = 200;
- const r2 = await h.mirrorRelay.flush();
- assert.strictEqual(r2.pending, 0);
- assert.deepStrictEqual(h.live.mirror.at(-1).row.username, 'zed');
- assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.live' && x.scope === 'live.chat_mirror.write'));
-});
-
-t('scripts/mirror-flush.js drains the queue without the service', async () => {
- const { spawn } = require('child_process');
- const path = require('path');
- h.live.mirrorStatus = 409;
- await h.db.recordRelayUser('twitch', 'Late');
- await h.mirrorRelay.flush();
- assert.ok(await h.mirrorRelay.pending() >= 1);
- h.live.mirrorStatus = 200;
- const env = { ...process.env, ...(await h.childDbEnv()) };
- const r = await new Promise((resolve) => {
- const c = spawn(process.execPath, [path.join(__dirname, '..', 'scripts', 'mirror-flush.js')], { env });
- let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { out += d; }); c.on('close', (code) => resolve({ code, out }));
- });
- assert.strictEqual(r.code, 0, r.out);
- assert.strictEqual(JSON.parse(r.out.slice(r.out.indexOf('{'))).pending, 0);
- assert.strictEqual(await h.mirrorRelay.pending(), 0);
- assert.strictEqual(h.live.mirror.at(-1).row.username, 'late');
-});
-
-t.run(async () => { if (h) await h.close(); events.close(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * Events outbox (events.event-envelope@1, written in the same transaction as the change; relayed
+ * to OpenVibe.Events only when EVENTS_URL is set, with a service token for audience
+ * openvibe.events).
+ */
+const assert = require('assert');
+const http = require('http');
+const { validate } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('outbox');
+let h, events, eventsPort, alice, bob;
+const received = [];
+let eventsMode = 'ok';
+
+t('boot with a stub OpenVibe.Events', async () => {
+ events = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json');
+ if (eventsMode !== 'ok') { res.statusCode = 503; return res.end('{"error":"down"}'); }
+ const { serviceAuth } = require('openvibe-contracts');
+ const r = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: h.keys.publicKey, issuer: h.ISS, audience: 'openvibe.events' });
+ if (!r.ok || !r.claims.cap.includes('events.event.publish')) { res.statusCode = 401; return res.end('{}'); }
+ const body = JSON.parse(raw);
+ received.push(...body.events);
+ res.statusCode = 201;
+ res.end(JSON.stringify({ accepted: body.events.length }));
+ });
+ });
+ eventsPort = await new Promise((r) => events.listen(0, '127.0.0.1', () => r(events.address().port)));
+ h = await boot({ env: { EVENTS_URL: `http://127.0.0.1:${eventsPort}`, EVENTS_RELAY_INTERVAL_MS: '3600000' } });
+ alice = h.addUser('alice', { subject: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
+ bob = h.addUser('bob');
+ await h.ctx.sync();
+});
+
+t('a REST fallback message, a DM and a moderation action each leave one envelope', async () => {
+ const sent = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'rest hello' } });
+ assert.deepStrictEqual(sent.body, { ok: true });
+ const conv = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
+ await h.http('POST', `/api/dm/conversations/${conv.body.conversation.id}/messages`, { token: alice.token, body: { message: 'dm text' } });
+ await h.db.logModerationAction({ scope_type: 'site', actor_user_id: alice.id, target_user_id: bob.id, action_type: 'site_timeout', details: { duration: 60 } });
+ const rows = await h.db.all('SELECT event_type, event, sent_at FROM events_outbox ORDER BY seq');
+ assert.deepStrictEqual(rows.map((r) => r.event_type), ['chat.message.created', 'chat.dm.created', 'chat.moderation.action']);
+ for (const r of rows) {
+ const env = JSON.parse(r.event);
+ const v = validate('events.event-envelope@1', env);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.strictEqual(r.sent_at, null);
+ }
+ const [msg, dm, mod] = rows.map((r) => JSON.parse(r.event));
+ assert.strictEqual(msg.visibility, 'public');
+ assert.deepStrictEqual(msg.payload.room, { type: 'global' });
+ assert.strictEqual(msg.payload.text, 'rest hello');
+ assert.strictEqual(dm.visibility, 'subject');
+ assert.ok(!JSON.stringify(dm).includes('dm text'));
+ assert.deepStrictEqual(dm.payload.participants.map((p) => p.user_id).sort(), [alice.id, bob.id].sort());
+ assert.strictEqual(mod.visibility, 'internal');
+ assert.deepStrictEqual(mod.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
+ assert.deepStrictEqual(mod.actor.type, 'user');
+ assert.deepStrictEqual(dm.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
+});
+
+t('Events down: rows wait with the error; back up: published once, marked sent', async () => {
+ eventsMode = 'down';
+ await h.eventsRelay.flush();
+ const waiting = await h.db.all('SELECT attempts, last_error, sent_at FROM events_outbox');
+ assert.ok(waiting.every((r) => r.attempts === 1 && /503/.test(r.last_error) && r.sent_at === null));
+ eventsMode = 'ok';
+ const r = await h.eventsRelay.flush();
+ assert.strictEqual(r.sent, 3);
+ assert.strictEqual(received.length, 3);
+ assert.ok((await h.db.all('SELECT sent_at FROM events_outbox')).every((x) => x.sent_at));
+ assert.strictEqual((await h.eventsRelay.flush()).sent, 0, 'nothing twice');
+ assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.events' && x.scope === 'events.event.publish'));
+});
+
+t.run(async () => { if (h) await h.close(); events.close(); });
@@ -2,9 +2,8 @@
@@ -2,9 +2,8 @@
/**
/**
* The PostgreSQL schema (plan T3, decision 1). migrations/0001_initial.sql opens a real PostgreSQL
* The PostgreSQL schema (plan T3, decision 1). migrations/0001_initial.sql opens a real PostgreSQL
* (PGlite in-process, or the containers with OV_TEST_STORE=pg) and is checked here: every Chat table
* (PGlite in-process, or the containers with OV_TEST_STORE=pg) and is checked here: every Chat table
- * exists, the keys/indexes the queries rely on are present, and the Live read-mirror triggers on the
- * twelve mirrored tables queue a change for every Chat write but never for one that sets
- * ov.mirror_skip = '1' (the bridge applying Live's writes, and the importer).
+ * exists, the keys/indexes the queries rely on are present, and the retired Live read mirror captures
+ * nothing any more (0006 dropped its twelve triggers; live_mirror_outbox stays until a contract migration).
*/
*/
const assert = require('assert');
const assert = require('assert');
const path = require('path');
const path = require('path');
@@ -83,24 +82,12 @@ t('relay_users has a real identity id', async () => {
@@ -83,24 +82,12 @@ t('relay_users has a real identity id', async () => {
assert.strictEqual(r.is_identity, 'YES', 'id is an identity column');
assert.strictEqual(r.is_identity, 'YES', 'id is an identity column');
});
});
-t('a Chat write is captured by the mirror', async () => {
+t('the retired Live mirror captures nothing (0006 dropped its triggers)', async () => {
+ const triggers = await all("SELECT tgname FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE 'mirror\\_%'");
+ assert.deepStrictEqual(triggers, [], 'no mirror_* capture trigger remains');
const before = (await get('SELECT COUNT(*) AS n FROM live_mirror_outbox')).n;
const before = (await get('SELECT COUNT(*) AS n FROM live_mirror_outbox')).n;
await db.prepare('INSERT INTO chat_messages (message) VALUES (?)').run('hello');
await db.prepare('INSERT INTO chat_messages (message) VALUES (?)').run('hello');
- const rows = await all('SELECT tbl, op, pk FROM live_mirror_outbox ORDER BY seq');
- assert.strictEqual(rows.length, before + 1, 'one queued change');
- const last = rows[rows.length - 1];
- assert.strictEqual(last.tbl, 'chat_messages');
- assert.strictEqual(last.op, 'upsert');
- assert.ok(JSON.parse(last.pk).id >= 1, 'the pk carries the id');
-});
-
-t("a Live write (ov.mirror_skip) never comes back", async () => {
- const before = (await get('SELECT COUNT(*) AS n FROM live_mirror_outbox')).n;
- await db.tx(async (tx) => {
- await tx.query(`SET LOCAL ov.mirror_skip = '1'`);
- await tx.query(`INSERT INTO chat_messages (message) VALUES ('from Live')`);
- });
- assert.strictEqual((await get('SELECT COUNT(*) AS n FROM live_mirror_outbox')).n, before, 'nothing queued');
+ assert.strictEqual((await get('SELECT COUNT(*) AS n FROM live_mirror_outbox')).n, before, 'a Chat write queues nothing');
});
});
t('cleanup', async () => { await close(); });
t('cleanup', async () => { await close(); });
@@ -19,11 +19,10 @@ const t = suite('service-kit');
@@ -19,11 +19,10 @@ const t = suite('service-kit');
const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');
-t('server/index.js imports gracefulStop and within from openvibe-sdk/service', () => {
+t('server/index.js imports gracefulStop from openvibe-sdk/service', () => {
const m = src.match(/const\s*\{([^}]*)\}\s*=\s*require\(['"]openvibe-sdk\/service['"]\)/);
const m = src.match(/const\s*\{([^}]*)\}\s*=\s*require\(['"]openvibe-sdk\/service['"]\)/);
assert.ok(m, "require('openvibe-sdk/service')'s exports are not destructured");
assert.ok(m, "require('openvibe-sdk/service')'s exports are not destructured");
assert.ok(/\bgracefulStop\b/.test(m[1]), 'gracefulStop is not imported');
assert.ok(/\bgracefulStop\b/.test(m[1]), 'gracefulStop is not imported');
- assert.ok(/\bwithin\b/.test(m[1]), 'within is not imported');
});
});
t('server/index.js leaves the signal handlers to the kit', () => {
t('server/index.js leaves the signal handlers to the kit', () => {
@@ -59,11 +58,9 @@ t('the returned shutdown runs the stop steps in order and closes the database',
@@ -59,11 +58,9 @@ t('the returned shutdown runs the stop steps in order and closes the database',
wrap(chatServer, 'broadcastAll', 'broadcastAll');
wrap(chatServer, 'broadcastAll', 'broadcastAll');
wrap(h.ctx, 'stop', 'ctx.stop');
wrap(h.ctx, 'stop', 'ctx.stop');
wrap(h.eventsRelay, 'stop', 'relay.stop');
wrap(h.eventsRelay, 'stop', 'relay.stop');
- wrap(h.mirrorRelay, 'stop', 'mirror.stop');
wrap(h.eventsConsumer, 'stop', 'events.stop');
wrap(h.eventsConsumer, 'stop', 'events.stop');
wrap(chatAi, 'stop', 'chatAi.stop');
wrap(chatAi, 'stop', 'chatAi.stop');
wrap(h.subscriptions, 'stop', 'subs.stop');
wrap(h.subscriptions, 'stop', 'subs.stop');
- wrap(h.mirrorRelay, 'flush', 'mirror.flush');
wrap(chatServer, 'close', 'chatServer.close');
wrap(chatServer, 'close', 'chatServer.close');
wrap(callServer, 'close', 'callServer.close');
wrap(callServer, 'close', 'callServer.close');
wrap(db, 'close', 'db.close');
wrap(db, 'close', 'db.close');
@@ -84,11 +81,9 @@ t('the returned shutdown runs the stop steps in order and closes the database',
@@ -84,11 +81,9 @@ t('the returned shutdown runs the stop steps in order and closes the database',
'broadcastAll',
'broadcastAll',
'ctx.stop',
'ctx.stop',
'relay.stop',
'relay.stop',
- 'mirror.stop',
'events.stop',
'events.stop',
'chatAi.stop',
'chatAi.stop',
'subs.stop',
'subs.stop',
- 'mirror.flush',
'chatServer.close',
'chatServer.close',
'callServer.close',
'callServer.close',
'db.close',
'db.close',
@@ -97,7 +92,7 @@ t('the returned shutdown runs the stop steps in order and closes the database',
@@ -97,7 +92,7 @@ t('the returned shutdown runs the stop steps in order and closes the database',
// The kit starts the stop once: a second call is the same promise, no step runs again.
// The kit starts the stop once: a second call is the same promise, no step runs again.
const again = await h.shutdown();
const again = await h.shutdown();
assert.strictEqual(again, 0, 'a second stop resolves with the same code');
assert.strictEqual(again, 0, 'a second stop resolves with the same code');
- assert.strictEqual(order.length, 11, 'a second stop runs no step again');
+ assert.strictEqual(order.length, 9, 'a second stop runs no step again');
});
});
// The shutdown step above already stopped the process; detach is a no-op then (and a fallback if a
// The shutdown step above already stopped the process; detach is a no-op then (and a fallback if a