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)

OP OpenVibers committed 5 Oct 2026 · 417585d791 · 20 files, +235 −442

* 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)

**Production changes (file:line)**
- `server/bridge/live-mirror.js` — deleted (sender: timer/relay loop, `buildChanges`/`flush`/`discard`, `ROW_SQL`, `live.chat_mirror.write`).
- `scripts/mirror-flush.js` — deleted (only served the mirror).
- `server/index.js:8,20,26,53-58,64,90-97,99` — dropped `createMirror` import/start/stop, `within(3000, mirror.flush())` and the `within` import, the `mirror` arg to `createApp`, the `mirror on/off` log and the returned `mirror`.
- `server/app.js:64,162-176` — `createApp` no longer takes `mirror`; `/ready` no longer reports the `mirror` pending/last_error block.
- `server/config.js:81-87,175` — removed `live.mirror` / `live.mirrorIntervalMs` (`LIVE_MIRROR`, `LIVE_MIRROR_INTERVAL_MS`) and the `LIVE_MIRROR` mention in the AI gating comment.
- `server/live-context.js:14` — removed the `/internal/chat-effects/mirror` transport line.
- `server/db/database.js:27` — comment now: Live reads the tables through Chat's internal read API.
- `.env.example:46-49` — removed the `LIVE_MIRROR` / `LIVE_MIRROR_INTERVAL_MS` block.
- `README.md:8,65-73,124,381,403,446,456,564` — retirement note (retired 2026-10-05, Live #31 reads internal reads, rollback through mirror gone), `/ready`, capability lists, layout, scripts and rollback text.
- `docs/cutover.md:8-11,83,89,95,110,128,160,205,215,231-234,285,305,309-312,325,331` — mirror retired 2026-10-05; rollback-through-mirror removed; dropped `LIVE_MIRROR`, `mirror.pending` gates and `mirror-flush` steps.
- `docs/chat-ingress.md:23,41` — internal reads replace the retired read mirror.
- `STATUS.json:15,26,32,34` — dropped `live.chat_mirror.write` from `capabilitiesUsed`, updated `liveAuthorityFlag`, feature text and `updated` to 2026-10-05.
- Note: migrations are untouched, so `0001_initial.sql`'s triggers still fill the kept `live_mirror_outbox` until the later contract migration drops table + triggers.

**Tests deleted/rewritten/added**
- Deleted `test/outbox-mirror.test.js` — its five mirror-only steps ('mirror: every change…', 'mirror: a channel sound…', 'mirror: Live refusing…', 'scripts/mirror-flush.js drains…'); the file's events-outbox tests moved to new `test/outbox.test.js`.
- Rewrote `test/service-kit.test.js:22-26,58-95` — shutdown order without `mirror.stop`/`mirror.flush` (11→9 steps), dropped the `within` import assertion.
- Added `test/no-live-mirror.test.js` (grep-style guard) — asserts no `server/`/`scripts/` file references `live_mirror_outbox`/`live-mirror`/`LIVE_MIRROR`/`createMirror`/`mirror-flush`/`mirrorIntervalMs`/`chat_mirror.write`, sender + script are gone, and `.env.example` has no `LIVE_MIRROR`.
- `test/helpers.js:108,131-132,228-235,291,337,398,441` — removed the Live mirror stub, `mirrorRelay`, `LIVE_MIRROR` default and stop calls.
- Kept unchanged and green: `test/pg-schema.test.js` (trigger capture stays), `test/n-1.test.js`, `test/import*.test.js` (still guarded by `ov.mirror_skip`).

**Test results** (`ov test <files>`, 1 shard): 6 passed in 36 s — `test/no-live-mirror.test.js` 2.8 s, `test/outbox.test.js` 3.8 s, `test/service-kit.test.js` 3.5 s, `test/pg-schema.test.js` 4.8 s, `test/n-1.test.js` 14.3 s (skipped: engine-switch re-record pending), `test/chat-ingress.test.js` 6.0 s.

Nothing committed or pushed.

* README, unit and cutover docs no longer describe the retired mirror (review)

Changes

ddiff --git a/.env.example b/.env.example
iindex eb0cc7a..c797423 100644
---- a/.env.example
++++ b/.env.example
@@@ -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).
ddiff --git a/README.md b/README.md
iindex 3090b24..eb33bb9 100644
---- a/README.md
++++ b/README.md
@@@ -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 own
site (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` is
retired (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` serves
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
`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 checksum
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
@@@ -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 writes
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/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 only
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
--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 limit
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/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/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 REST
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
server/db/ 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/
```
@@@ -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:
`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
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
@@@ -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/open
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
(`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
restart; afterwards `sudo ovhost rollback chat --to <sha>`. Nothing blocks a rollback: the schema
ddiff --git a/STATUS.json b/STATUS.json
iindex 65f95bc..3dc8112 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/deploy/systemd/openvibe-chat.service b/deploy/systemd/openvibe-chat.service
iindex 6d06f28..866563a 100644
---- a/deploy/systemd/openvibe-chat.service
++++ b/deploy/systemd/openvibe-chat.service
@@@ -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.target
Wants=openvibe-live.service openvibe-network.service
@@@ -16,7 +16,7 @@ Environment=PORT=4400
Environment=HOST=127.0.0.1
Environment=PATH=/usr/local/bin:/usr/bin:/bin
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
TimeoutStopSec=15
Restart=always
ddiff --git a/docs/chat-ingress.md b/docs/chat-ingress.md
iindex 27710bf..167925f 100644
---- a/docs/chat-ingress.md
++++ b/docs/chat-ingress.md
@@@ -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`, plan
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
`{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.
ddiff --git a/docs/cutover.md b/docs/cutover.md
iindex c95efc8..7081f36 100644
---- a/docs/cutover.md
++++ b/docs/cutover.md
@@@ -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 rollback
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.
@@@ -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`).
## Prerequisites
1. **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` to
alpha 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`, `live
after the script name — Node consumes it as environment, which can point the script at
Chat'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 already
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
`/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 and
espeak-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_messages
report. 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 rows
Chat 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 forwarded
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
@@@ -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
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
-- 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 rol
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.
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
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
@@@ -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 t
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] <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
Sort). 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`) and
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
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:
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
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` /
`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
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
ddiff --git a/migrations/0006_stop_live_mirror_triggers.sql b/migrations/0006_stop_live_mirror_triggers.sql
nnew file mode 100644
iindex 0000000..598262a
---- /dev/null
++++ b/migrations/0006_stop_live_mirror_triggers.sql
@@@ -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();
ddiff --git a/scripts/mirror-flush.js b/scripts/mirror-flush.js
ddeleted file mode 100644
iindex b7f4851..0000000
---- a/scripts/mirror-flush.js
++++ /dev/null
@@@ -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); });
ddiff --git a/server/app.js b/server/app.js
iindex 3f9d6ba..43fd3e0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 = null
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 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 = null
checks,
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,
ddiff --git a/server/bridge/live-mirror.js b/server/bridge/live-mirror.js
ddeleted file mode 100644
iindex 75b9efb..0000000
---- a/server/bridge/live-mirror.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex 27e853d..38dd0bb 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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'),
ddiff --git a/server/db/database.js b/server/db/database.js
iindex f410600..918c295 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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'],
ddiff --git a/server/index.js b/server/index.js
iindex 8546536..92bfa31 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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) {
ddiff --git a/server/live-context.js b/server/live-context.js
iindex 9cc0229..7db64be 100644
---- a/server/live-context.js
++++ b/server/live-context.js
@@@ -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
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 4063ea1..2c00848 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -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 once
hang: false, // Live hangs: connections are accepted and never answered
nextAnon: 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 { /* */ }
ddiff --git a/test/no-live-mirror.test.js b/test/no-live-mirror.test.js
nnew file mode 100644
iindex 0000000..70694c7
---- /dev/null
++++ b/test/no-live-mirror.test.js
@@@ -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();
ddiff --git a/test/outbox-mirror.test.js b/test/outbox-mirror.test.js
ddeleted file mode 100644
iindex f2cd6c2..0000000
---- a/test/outbox-mirror.test.js
++++ /dev/null
@@@ -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(); });
ddiff --git a/test/outbox.test.js b/test/outbox.test.js
nnew file mode 100644
iindex 0000000..17b76ad
---- /dev/null
++++ b/test/outbox.test.js
@@@ -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(); });
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex bbb607d..8433a10 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -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(); });
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
iindex c73a1ef..5455ba3 100644
---- a/test/service-kit.test.js
++++ b/test/service-kit.test.js
@@@ -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