openvibers/openvibe.chat Commits
Chat: the migration era is deleted (SQLite and Live-import tooling, CHAT_DB_PATH, cutover docs) (#46)
Changes
@@@ -22,9 +22,7 @@ TRUST_PROXY=2#CHAT_LIMITS_MINUTE=120#CHAT_LIMITS_HOUR=3000--# Chat's own database (production: systemd StateDirectory=openvibe-chat)--CHAT_DB_PATH=./data/chat.db--# Chat's caches (101soundboards clips, TTS preview clips). Default: <db dir>/cache++# Chat's caches (101soundboards clips, TTS preview clips). Default: ./data/cache#CHAT_CACHE_DIR=./data/cache# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)@@@ -2,7 +2,7 @@> Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence — one identity, every conversation.--**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,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 chatroutes, 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@@@ -55,11 +55,9 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes thLive for. Reads are cached projections (`ctx_*` tables kept complete by paged syncs; TTLcaches that serve stale while refreshing) warmed when a socket joins, so a chat message neverwaits on a network read. Live pushes invalidations when it changes cached data.--- **Live's side** is `docs/live-patch.diff` (applies to Live `main` with `git apply`):-- `/internal/chat-context/*` and `/internal/chat-effects/*`, and `CHAT_AUTHORITY=chat`, which stops-- Live's chat server and routes. Live's own chat calls reach Chat through the Chat-owned ingress-- (`/internal/chat/*`, `docs/chat-ingress.md`); the bridge proxy at `POST /internal/live/calls` is-- retired (T3 J3a).++- **Live integration.** Live serves `/internal/chat-context/*` and `/internal/chat-effects/*` and++ runs with `CHAT_AUTHORITY=chat`. Its chat calls reach Chat through `/internal/chat/*`++ (`docs/chat-ingress.md`).- **Own database on PostgreSQL** (ADR-035, plan T3) with Live's tables and ids, the Network subject on newrows, and a transactional events outbox. Chat's changes were copied into Live's tables by a readmirror until 2026-10-05: since Live #31 nothing in Live reads its mirror-filled chat tables in chat@@@ -71,13 +69,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th`migrations/0001_initial.sql` is the whole schema (timestamps stay SQLite-format text through`ov_now()`/`datetime()`); `0006_stop_live_mirror_triggers.sql` dropped the twelve read-mirror capturetriggers, 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-- SQLite file (Live's snapshot, the import) use `node:sqlite` through `scripts/lib/sqlite.js`.--- **Import:** `scripts/import-from-live.js --live-db <snapshot> [--apply]` — a dry run unless-- `--apply` (which first copies the rows of the tables it writes to a JSON file), idempotent, never drops-- a row of Chat's tables (`import_hold`), reports counts per table.++ `VALKEY_URL`/`VALKEY_PREFIX` put the per-actor rate-limit counters on Valkey instead of this process.- **The six chat tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,`chat_ai_summaries`, `chat_timeline_events`): Chat's own since the C-04 cutover — Chat is their onlywriter, there is no authority switch any more. Live's writers go through Chat's APIs below and the@@@ -120,7 +112,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 reads them through Chat's internal read API (the read mirror was retired on 2026-10-05 and the rollback through it is gone) |++| 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 (`DATABASE_URL` through `openvibe-sdk/db`; `migrations/` applies at boot); Live reads them through Chat's internal read API || 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) |@@@ -142,9 +134,9 @@ Chat owns (from the cutover): `chat_messages`, `dm_conversations`, `dm_participaChat owns too (C-04 done; there is no authority switch any more): `channel_moderators`,`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`.--The importer no longer copies them; Live reads them through `GET /internal/moderation/*`.++Live reads them through `GET /internal/moderation/*`.--Stays in Live (decided with evidence, `docs/cutover.md`): `media_requests`, `media_request_settings`.++Stays in Live: `media_requests`, `media_request_settings`.### Events@@@ -357,19 +349,6 @@ openvibe.chat's Settings page edits all four. When Network cannot answer, an enfwhen the revision is newer (without the subscription, within the TTL). Network down: the cached copywith `stale: true`, else 503; writes 503.- API tokens read only. A person Live knows without a Network subject gets 409 `prefs.subject_unknown`.--- **Migration** of what Live kept server-side (`user_preferences.chat_settings`, the browser's whole-- `chatSettings`): `scripts/migrate-chat-preferences.js`. Only choices move (`showTimestamps` →-- `timestamps`, `compactMode` → `compact`, `fontSize` small/large → `font_scale` 0.88/1.18, `showBadges`-- false → `show_badges`); a person with defaults only gets no record, and an existing record is never-- overwritten (create-only, `If-Match: 0`), so it is safe to re-run. The rest of `chatSettings` (TTS,-- volumes, cross-feed, notifications, …) has no field in schema v1 and stays in Live for now.----```bash--node scripts/migrate-chat-preferences.js --live-db /tmp/live-snapshot.db # dry run--node scripts/migrate-chat-preferences.js --live-db /tmp/live-snapshot.db --apply --backup /root/chat-prefs-$(date +%F).json--node scripts/migrate-chat-preferences.js --rollback /root/chat-prefs-<date>.json [--apply] # undo that run--```--## Running it```bash@@@ -379,8 +358,6 @@ npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,snpm test # Node 22; stub Live and Network in-processnpm run test:pg # the same on PostgreSQL + PgBouncer + Valkey (OV_TEST_PG_URL, OV_TEST_PG_DIRECT_URL,# 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/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@@@ -402,8 +379,7 @@ the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or aits own interval by that much. It also reports whether events are relayed and the Events consumer'scounters.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`.++`/etc/openvibe/chat.env`.### Per-actor limits@@@ -454,8 +430,8 @@ server/calls/ moved from Live: the call server (/ws/call), its RESTserver/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service tokenserver/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/)--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/++scripts/ parity, subscribe-events, n-1-record++docs/ calls-cutover.md, parity.md, chat-ingress.md, capabilities-proposal/```## Owns@@@ -561,7 +537,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`).++rollback is a Chat release rollback, not a switch back to Live.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@@@ -4,7 +4,7 @@"code": true,"wave": "W6","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "runtime": "node 22, express, ws, PostgreSQL through openvibe-sdk/db (DATABASE_URL via PgBouncer, DATABASE_DIRECT_URL for migrations/; embedded PGlite outside production), Valkey for the per-actor limits (VALKEY_URL); better-sqlite3 gone (plan T3)",++ "runtime": "node 22, express, ws, PostgreSQL through openvibe-sdk/db (DATABASE_URL via PgBouncer, DATABASE_DIRECT_URL for migrations/; embedded PGlite outside production), Valkey for the per-actor limits (VALKEY_URL)","port": 4400,"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)","contracts": "openvibe-contracts v0.126.0",@@@ -47,7 +47,7 @@"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 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",++ "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). 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",@@@ -4,8 +4,7 @@## include /opt/openvibe.chat/deploy/nginx/openvibe.live-chat.locations.conf;#--# Adding the include (+ nginx -t + reload) is the traffic flip; removing it is the rollback--# (docs/cutover.md). Headers and limits are Live's for the same paths: the /api/ zone and burst,++# The include routes these paths to Chat. Headers and limits are Live's for the same paths: the /api/ zone and burst,# the body size, X-Forwarded-For for trust proxy 2, CF-Connecting-IP + upgrade for the socket.# `^~` keeps the static-asset regex location from ever taking these (e.g. /api/sounds/file/x.mp3).# Trailing slashes matter: /api/chat-ai (Live's AI routes) must NOT match /api/chat/.@@@ -36,7 +36,6 @@ RestrictRealtime=true# Chat's own database and caches. The code tree stays read-only.StateDirectory=openvibe-chat--Environment=CHAT_DB_PATH=/var/lib/openvibe-chat/chat.dbEnvironment=CHAT_CACHE_DIR=/var/lib/openvibe-chat/cache# Channel sounds stay in Live's sounds directory (rows hold absolute paths into it; Live's# donation alerts read alert sounds there). Legacy layout and release layout (shared/data).@@@ -122,8 +122,7 @@ after this long and the caller is told (see below).- `curl -s https://openvibe.live/api/streams/voice-channels | jq '.channels[].id'` → `"public"` andwhatever users created since the flip.- Two test accounts in the Chat tab: join the Public voice channel, hear and see each other, mute-- shows on the other side, leave. `sqlite3 /var/lib/openvibe-chat/chat.db "select id, kind, state,-- channel_id, end_reason from calls order by id desc limit 5"` shows the `channel` session `active`,++ shows on the other side, leave. `psql "$DATABASE_DIRECT_URL" -c "select id, kind, state, channel_id, end_reason from calls order by id desc limit 5"` shows the `channel` session `active`,then `ended` / `empty`.- Call one test account from the other (user menu → Call): the callee rings (and gets thenotification), accept → connected; the row is `direct`, `ringing` → `active` → `ended`. Decline →ddeleted file mode 100644@@@ -1,59 +0,0 @@--# T3 cutover: closeout evidence — OpenVibe.Chat----What the read-only interfaces can actually prove about the Chat PostgreSQL cutover (plan T3, ADR-035). The--cutover ran **2026-09-23 02:03 UTC** (`README.md`, `docs/cutover.md`); production serves the--PostgreSQL-only release. Checked **2026-10-04 23:03Z (UTC)**; the values below were captured then by the--orchestrator through `ov access run openvibe-ovh <action>` — `health`, `releases`, `env-set`. Every result--below is a literal output of one of those actions or of a read-only Git query.----Nothing here was deployed, restarted, rolled back or imported; no credential, URL or env **value** was--read. Checks that need an interface the broker does not expose are marked **pending** in their own--section and were not guessed. SHAs are printed as `ovhost` reports them (12 hex characters).----## Release and readiness — verified----- `health chat` (exit 0): `chat 1d8c8aa14e69 ready openvibe-chat=active chat sockets=2`. `ovhost` needs-- the service's `/api/ready` to answer before it calls the service `ready`.--- `releases chat` (exit 0): `c88d5814d7df → 0e00f50d6ba9 → 1d8c8aa14e69 deployed` (2026-10-04T12:03Z,-- 2026-10-04T21:23Z, 2026-10-04T22:43Z). `1d8c8aa14e69` is the release running in production; the-- PostgreSQL cutover itself deployed 2026-09-23 02:03 UTC (`docs/cutover.md`).----**Why this is a PostgreSQL-only release.** `server/db/database.js` serves through `openvibe-sdk/db`,--which in production requires `DATABASE_URL` (runtime, through PgBouncer) and `DATABASE_DIRECT_URL` (the--owner role, for `migrations/`) and only falls back to embedded PGlite outside production. A release that--is `active`/`ready` therefore booted with both set. `better-sqlite3` is not a dependency and nothing the--service loads opens SQLite, guarded by `test/no-sqlite.test.js`; the schema is `migrations/0001_initial.sql`.----## Env presence and mode — verified----- `env-set chat` (names only, file `/etc/openvibe/chat.env`): **`DATABASE_URL`**, **`DATABASE_DIRECT_URL`**,-- **`VALKEY_URL`**, **`VALKEY_PREFIX`**, `EVENTS_URL`, `LIVE_MIRROR`, `OV_NETWORK_INTERNAL_URL`,-- `OV_LIVE_INTERNAL_URL`, `BASE_URL`, `SITE_URL`, `PORT` and the rest present, none empty. `CHAT_DB_PATH`-- comes from the unit and is read only by the one-time importer.----## Import and row parity — pending----The SQLite→PostgreSQL import and its per-table count+checksum report live in--`docs/pg-port/progress-attempt4.md`; the production import report (`scripts/import-sqlite-to-pg.js`,--recorded in `import_runs`) and the rollback copy (`chat.db.pre-pg`) are not exposed by any read-only--action. **Not checked.**----## Valkey ACL scope — pending----Presence of `VALKEY_URL`/`VALKEY_PREFIX` is provable (above). The scope of the Valkey user (commands,--`~ov:chat:*` confinement in `/etc/valkey/services.acl`) needs a connection no read-only action opens.--**Not checked.**----## Reproduce----```bash--ov access run openvibe-ovh health chat--ov access run openvibe-ovh service-status chat--ov access run openvibe-ovh plan chat--ov access run openvibe-ovh releases chat--ov access run openvibe-ovh validate chat--ov access run openvibe-ovh env-set chat--ov access run openvibe-ovh journal openvibe-chat.service 200--```----Every one of these is a low-risk read. `deploy`, `rollback`, `restart` and `db-backup` were not used.ddeleted file mode 100644@@@ -1,336 +0,0 @@--# Chat cutover — Live → OpenVibe.Chat (roadmap Wave 6)----What changes at the cutover: browsers keep calling `https://openvibe.live`, but nginx sends the chat--socket and the chat REST prefixes to OpenVibe.Chat (127.0.0.1:4400), and Live runs with--`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 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 Live chat bridge is retired (T3 J3a).** Live #20/#21 route every producer through Chat's own--ingress (`/internal/chat/*`, `docs/chat-ingress.md`) and dropped Live's bridge outbox, so Chat no--longer mounts `POST /internal/live/calls` or `GET /internal/live/presence`, and deploy notices arrive--only as `live.release.deployed`. Where the steps below mention the bridge, they record the cutover as--it ran. Its tables `bridge_applied` / `bridge_refs` stayed as data until J3b dropped them--(`migrations/0005_drop_bridge_tables.sql`).----## Rehearsal----`ov rehearse` loads this seed after main's migrations, then this branch's migrations:----```rehearse--# Chat's PostgreSQL seed; test/fixtures/live-chat-schema.sql is Live's SQLite schema for the import tests--seed: test/rehearsal/seed.sql--```----- [What is served where](#what-is-served-where)--- [Data authority](#data-authority)--- [Prerequisites](#prerequisites)--- [Rehearsal](#rehearsal)--- [Rehearsal on a live.db snapshot](#rehearsal-on-a-livedb-snapshot)--- [Cutover](#cutover)--- [Rollback](#rollback)--- [Behaviour that is not byte-for-byte identical](#behaviour-that-is-not-byte-for-byte-identical)--- [Live quirks moved as they are](#live-quirks-moved-as-they-are)----## What is served where----nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in Live's--`server { … server_name openvibe.live … }` block above `location /api/`):----| nginx location | Chat serves |--| --- | --- |--| `^~ /ws/chat` | the chat WebSocket (`?stream=`; session cookie or Authorization bearer; the deprecated `?token=` still works; protocol unchanged) |--| `^~ /api/chat/` | `GET gif/providers`, `GET gif/trending`, `GET gif/search`, `POST send`, `GET search`, `GET user/:userId/history`, `GET user/:username/profile`, `GET relay-user/:platform/:username`, `GET relay-user/:platform/:username/logs`, `GET anon/:anonId`, `GET anon/:anonId/logs`, `GET filters/friendly`, `GET global/history`, `GET :streamId/replay`, `GET :streamId/history`, `GET channel/:userId/history`, `GET :streamId/users`, `POST admin/purge/preview`, `DELETE admin/purge`, `GET admin/logs`, `GET admin/logs/export` |--| `^~ /api/dm/` | `GET/POST conversations`, `GET/PATCH conversations/:id`, `GET/POST conversations/:id/messages`, `DELETE conversations/:id/messages/:msgId`, `POST conversations/:id/read`, `POST conversations/:id/participants`, `DELETE conversations/:id/participants/:userId`, `GET unread`, `GET users/search`, `GET blocks`, `POST/DELETE blocks/:userId`, `GET blocks/check/:userId` |--| `^~ /api/tts/` | `GET voices`, `GET settings`, `GET/PUT admin/settings`, `POST admin/test`, `GET audio/:file` (Chat's clips; Live's arena/mod-preview clips are fetched from Live under the same URL), `GET queue`, `POST queue/skip`, `POST queue/clear`, `POST queue/:id/report` (the TTS and sound queue) |--| `= /api/sounds` and `^~ /api/sounds/` | `POST /api/sounds` (upload), `GET channel/:userId`, `GET all/:streamId`, `DELETE :id`, `PATCH command`, `GET file/:filename`, `GET alert/mine`, `POST/DELETE alert/:kind` |----> **WebSocket credentials.** C-05 shipped in `2dcca25`: a `/ws/chat` upgrade authenticates with the--> session cookie or an `Authorization` bearer, and Live's legacy `token` cookie is ignored there. The--> deprecated `?token=` query param is still honoured for Live's bot guide and call client, counted in--> `chat_ws_url_token_uses`. C-06 — dropping the `ov_token` cookie fallback so upgrades authenticate by--> bearer only — is gated on `chat_ws_cookie_reliant`: the browser clients now send their token in the--> first message of every socket (`join`, `join_room`, the call's `auth-update`), and a socket the--> cookie signed in whose first handled auth message has no valid token counts there, once; call--> sockets silent past the token grace period count too. J7 removes the cookie--> branch from `extractWsToken` once `chat_ws_cookie_reliant` stays 0 for one full release after this--> ships. `chat_ws_cookie_uses` is only the raw "cookie seen" series: browsers send the cookie on every--> same-origin upgrade, so it never reads 0.----Stay on Live (do not route): `/api/chat-ai/*` (the trailing slash in `/api/chat/` keeps it out),--`/api/emotes/*`, `/api/channels/*` (channel moderation dashboard), `/api/mod/*`, `/api/media/*`,--`/ws/call` (until the calls cutover, `docs/calls-cutover.md`), `/ws/broadcast`, `/ws/control`, and--everything else.----Loopback only, never routed: Chat `/internal/live/*`, `/internal/chat/*`, `/health`, `/ready`; Live--`/internal/chat-context/*`, `/internal/chat-effects/*` (both refuse anything that came through--nginx).----The additive Chat-owned ingress at `/internal/chat/{messages,events,moderation,invalidate,presence}`--is documented in [chat-ingress.md](chat-ingress.md). Live keeps using `/internal/live/*` until its--writers are repointed; deploy the three new capability grants before that Live release.----## Data authority----| 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 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` (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`,-- `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-- capabilities as undefined (Live's CI with the patch, Chat's non-blocking contracts job).--2. **Network.** Create the principal and grants:-- ```bash-- sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js create chat --write-env /etc/openvibe/chat.env-- ```-- Network's env file goes **before** the script name so Node loads it as configuration;-- `--write-env` names the file the secret is written to (Chat's). Never put `--env-file`-- 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`,-- `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-- viewer, relay and donation lines, deploy notices); since T3 J3a it guards-- `POST /internal/chat/messages`. Add them to-- `DEFAULT_GRANTS` in `server/identity/principals.js` so every boot keeps them.-- VIP member badges (after this cutover, any time): `chat vip.entitlement.check openvibe.vip`;-- without it no message carries a badge (README, "VIP member badges").--3. **Live.** Apply `docs/live-patch.diff` to Live `main` (`git apply docs/live-patch.diff`), run its-- tests (`node test/chat-context.test.js`, `npm test`), deploy it **without** `CHAT_AUTHORITY` —-- nothing changes for users; the new internal routes answer only service tokens and every effect-- answers 409 while the flag is off. Add `OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400` to-- `/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` 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-- sudo cp deploy/systemd/openvibe-chat.service /etc/systemd/system/ && sudo systemctl daemon-reload-- ```-- Do not start it before the rehearsal is signed off.----## Rehearsal on a live.db snapshot----Nothing here touches production data. A rehearsal Chat may read production Live--(`/internal/chat-context/*` is read-only), but every effect is refused while Live's flag is off —--no coins, AI replies or pastes happen; anon numbers come out temporary (`anon9xxxxxxxx`).----```bash--# 1. snapshot (online, consistent)--sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-rehearsal.db"--SNAP_AT="$(date -u '+%Y-%m-%d %H:%M:%S')"----# 2. import into a scratch database--cd /opt/openvibe.chat--node scripts/import-from-live.js --live-db /tmp/live-rehearsal.db --chat-db /tmp/chat-rehearsal.db--node scripts/import-from-live.js --live-db /tmp/live-rehearsal.db --chat-db /tmp/chat-rehearsal.db --apply --no-backup--```----Check the report: per table `live` = `inserted` (first run), `held_total` 0 (anything held: look at--`import_hold.reason`), `media_requests.not_moved`, `chat_messages.next_id` = Live's max + headroom.--Cross-check: `sqlite3 /tmp/live-rehearsal.db "select count(*) from chat_messages"` against the--report. Run the import again: every `inserted` must be 0 (idempotent).----```bash--# 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 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)--node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "$SNAP_AT" \-- --stream <a live stream id> --stream <an ended stream id> --channel <streamer user id> --channel <another> \-- --token <a test account's Network JWT>--```----Expected: `all paths answer the same`. Then by hand against 4401 (`wscat -H 'Origin: https://openvibe.live'---c ws://127.0.0.1:4401/ws/chat?stream=<id>`): `{"type":"join","streamId":<id>}` → `auth`; a join--with a token → `authenticated: true`; `/help`; a message → stored in `/tmp/chat-rehearsal.db` and--returned by `/api/chat/<id>/history` on 4401. Stop the rehearsal Chat and delete `/tmp/chat-rehearsal*`.----## Cutover----Pick a quiet moment (`curl -s https://openvibe.live/api/streams`: restarting Live drops RTMP--streamers; `deploy.sh --wait-idle` waits for none).----1. **Start Chat on an empty database, import.**-- ```bash-- sudo systemctl start openvibe-chat && curl -s http://127.0.0.1:4400/ready | jq-- sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-cutover-1.db"-- sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-cutover-1.db --chat-db /var/lib/openvibe-chat/chat.db --apply-- ```-- Chat now holds everything up to snapshot 1 and its id sequences start 10000 above Live's-- (`--headroom`), so rows Live writes until the flip still fit below.--2. **Flip.** Set `CHAT_AUTHORITY=chat` in `/etc/openvibe/live.env`, restart Live (it tells chat-- clients it restarts), and as soon as `https://openvibe.live/api/ready` answers, add the include-- to nginx and reload:-- ```bash-- sudo systemctl restart openvibe-live # or deploy.sh --wait-idle-- # add: include /opt/openvibe.chat/deploy/nginx/openvibe.live-chat.locations.conf;-- sudo nginx -t && sudo systemctl reload nginx-- ```-- Between the two steps `/ws/chat` on Live answers 503 and clients retry with backoff; afterwards-- they land on Chat.--3. **Second import pass** — rows Live wrote between snapshot 1 and its restart:-- ```bash-- 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 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).-- - 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-- reload), `/help`, a DM between two test accounts (live delivery + unread badge), a moderator-- `/timeout` on a test account, a channel `!sound`, the global feed on the home page.-- - 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); Live's readers now use Chat's internal read API-- (`server/chat/internal-reads.js`).----## Rollback----There is no rollback to Live. Chat is required in every mode: Live runs with `CHAT_AUTHORITY=chat`--and mounts no chat routes, so unsetting the flag would leave `/ws/chat` and the chat REST prefixes--unanswered, not served by Live. The nginx include stays in place through any rollback.----1. A bad Chat release is undone on Chat's side: `sudo ovhost rollback chat --to <sha>` (README,-- "Deploys") restarts the previous release. nginx, the routing include and `/etc/openvibe/live.env`-- 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. 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-- any later recovery.----## Behaviour that is not byte-for-byte identical----- **Ordering of Live's reactions.** The OpenCoins chat bonus (`coin_earned`), AI-viewer reactions,-- translations and the PowerChat relay happen in Live after the message is broadcast (one call per-- message); before, the coin reply was sent before the broadcast. Arena and media-queue replies-- arrive a round trip later.--- **Freshness of Live data.** Chat answers from caches: changes made in Live reach Chat at once-- through invalidations (channel moderators/settings and alert sounds via Live's db writes, IP-- approvals, role/avatar pushes, admin user edits, bans followed by a disconnect) and otherwise-- within the TTL — bans 10 s, channel policy 15 s, settings 30 s, follows/cosmetics/tags 60 s,-- new users 30 s (all users every 15 min), live/ended streams 10 s. Live's synchronous reads of-- chat presence (viewer counts, AI-viewer pacing, `getConnectedUserIp` in `/api/mod` bans) are up-- to 3 s old.--- **Placeholder ids.** A chat row Live's own modules create (AI viewers, relays, donations) gets its-- id from Chat; the Live caller sees a placeholder (≤ -2^40), mapped to the real id for everything it-- sends to chat afterwards. What such a caller stores itself keeps the placeholder:-- `ai_viewer_log.chat_message_id` and the PowerChat message id of AI-viewer lines.--- **Separate rate-limit budget.** The chat REST routes count against Chat's own `/api` limiter-- (same numbers) instead of sharing Live's; Live's request analytics no longer see them.--- **Live unreachable.** Chat keeps serving rooms from its caches; new sign-ins resolve as anonymous-- and new anonymous visitors get a temporary number (`anon9xxxxxxxx`) until Live answers; effects are-- skipped (coins, AI viewers) or answer an error (`/color`, media commands).--- **Deploy notices** are still Live's (its commits, announced in chat through the OpenVibe.Events-- event `live.release.deployed`; the bridge op is retired, T3 J3a); Chat's own deploys are not announced.--- **Responses** carry the same fields as before; Chat's `*subject_id` columns are stripped from API-- answers.----## Live quirks moved as they are----Found while moving; deliberately not fixed in this wave (fix after the cutover, in one place):----- `GET /api/chat/search` always reports `total: 0` (its count regex does not cross the SELECT's line-- break).--- `POST /api/chat/send` never attaches cosmetics (Live required a module that does not exist).--- A `/timeout` stores an ISO `expires_at` that Live compares as TEXT with SQLite's-- `'YYYY-MM-DD HH:MM:SS'`, so a timeout lasts at least until the end of that UTC day.--- `/api/tts/admin/settings` masks credentials in the TTS engine's cached settings object, so for up-- to 30 s after an admin views them the owner's view — and Google/AWS synthesis — see the mask.--- `/paste` announces "Anonymous shared a paste" and titles it "Chat paste by anon" (it reads-- fields the chat client object does not have).--- The AI-viewer / PowerChat "is moderator" flag passes the channel owner's user id where a channel-- id belongs (`canModerateChannel(user, channelUserId)`).----## PostgreSQL switch (plan T3, ADR-035)----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--`DATABASE_DIRECT_URL` (the owner role, a direct connection, for migrations). `VALKEY_URL` / `VALKEY_PREFIX`--are optional: with them the per-actor rate-limit counters are shared across processes and hosts, without--them they stay in this process (as before). The role pair is the one `openvibe-sdk/testing`'s `store:'pg'`--creates, so `npm run test:pg` rehearses the same shape.----**Rehearse on a copy.** Take a consistent copy of the running database and import it into a local PGlite:---- sqlite3 /var/lib/openvibe-chat/chat.db ".backup /tmp/chat-pg-source.db"-- node scripts/import-sqlite-to-pg.js --sqlite /tmp/chat-pg-source.db --pglite ./data/pglite----The import (`openvibe-sdk/db` `importSqlite`, reading the file with `node:sqlite`) copies every table the--migration has, parents first, ids kept; identity sequences are `setval`'d past the imported maximum; each--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 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: 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):---- node scripts/import-sqlite-to-pg.js --sqlite /var/lib/openvibe-chat/chat.db --url "$DATABASE_DIRECT_URL"----3. Check the counts: the report's rows per table against `sqlite3 /var/lib/openvibe-chat/chat.db "select-- count(*) from <table>"` for `chat_messages`, `dm_messages`, `rooms`, `room_messages`, `audio_requests`;-- `ok` on the tail line.--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`). (`/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 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--served a full release.----The read-only closeout record — release/readiness, env presence, the pending checks — is `docs/cutover-evidence-t3.md`.ddeleted file mode 100644@@@ -1,1596 +0,0 @@--diff --git a/.env.example b/.env.example--index 31f53b7..19810f1 100644--+++ b/.env.example--@@ -107,3 +107,9 @@ OPS_ALERT_WEBHOOK_URL=-- # Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)-- PASTES_ON_COMMUNITY=0-- OV_COMMUNITY_URL=https://openvibe.community--+--+# Chat runs in OpenVibe.Chat when this is "chat" (roadmap W6; server/chat/chat-authority.js): Live--+# stops serving /ws/chat and /api/chat|dm|tts|sounds (nginx sends them to Chat), forwards its own--+# chat calls to Chat and keeps its chat tables as Chat's read mirror. Unset = Live runs chat.--+#CHAT_AUTHORITY=chat--+OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400--diff --git a/docs/chat-system.md b/docs/chat-system.md--index 65d1647..fd58f07 100644--+++ b/docs/chat-system.md--@@ -8,6 +8,29 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte-- - **REST API**: `server/chat/routes.js` — moderation endpoints, message search, admin tools-- - **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync----+### OpenVibe.Chat (roadmap Wave 6)--+--+Chat is moving to [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (127.0.0.1:4400)--+with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches it on--+(`server/chat/chat-authority.js`); without it nothing here changes.--+--+- nginx sends `/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/` and `/api/sounds` to Chat; Live answers--+ them with 503 if one still arrives, and `/ws/chat` upgrades are refused.--+- `require('./chat/chat-server')` returns `chat-remote.js`: the same methods, forwarded in order to--+ Chat's `POST /internal/live/calls` (broadcasts, DMs, TTS, channel sounds, disconnects, user updates).--+ Chat-table writes Live's other modules make (AI viewers, relays, donations, `/api/mod`) are forwarded--+ too; inserts return a placeholder id (≤ -2^40) Chat maps to the real one. Unacknowledged writes wait--+ in `chat_bridge_outbox` (and are applied here if Live boots without the flag again — rollback).--+- Synchronous reads (`getTotalConnections`, viewer counts, slow modes, a connected user's IP) come from--+ Chat's presence snapshot, polled every 3 s.--+- Chat reads Live data and asks for side effects on `/internal/chat-context/*` and--+ `/internal/chat-effects/*` (`server/chat/live-context-routes.js`), with Network service tokens--+ (`live.chat_context.read`, `live.chat_effects.write`, `live.chat_mirror.write`,--+ `server/net/service-guard.js`). Effects re-check the acting moderator/owner/admin and are refused--+ unless `CHAT_AUTHORITY=chat`.--+- Live's chat tables become Chat's read mirror (`POST /internal/chat-effects/mirror`): same ids, so home--+ stats, recaps, AI context, VOD chat replay and the `/api/mod` queues keep reading them in place.--+-- ## Features---- ### Real-time Chat--diff --git a/server/auth/auth.js b/server/auth/auth.js--index e2dd198..4a7e908 100644--+++ b/server/auth/auth.js--@@ -413,6 +413,10 @@ function reloadNetworkKey() {-- module.exports = {-- apiTokenAllows,-- verifyToken,--+ verifyTokenWithReason,--+ getNetworkIssuer,--+ // The Network's RS256 key also signs service tokens (server/net/service-guard.js).--+ getNetworkPublicKey: () => openvibeToolsPublicKey,-- requireAuth,-- optionalAuth,-- requireAdmin,--diff --git a/server/chat/chat-authority.js b/server/chat/chat-authority.js--new file mode 100644--index 0000000..4867b0b--+++ b/server/chat/chat-authority.js--@@ -0,0 +1,23 @@--+'use strict';--+/**--+ * Who runs chat (roadmap Wave 6).--+ *--+ * CHAT_AUTHORITY=chat: OpenVibe.Chat (127.0.0.1:4400) serves /ws/chat and the chat REST routes--+ * (nginx routes them there) and owns the chat tables. Live then--+ * - does not start its chat WebSocket server or mount /api/chat, /api/dm, /api/tts, /api/sounds;--+ * - hands every chat call its other modules make to Chat (server/chat/chat-remote.js — what--+ * require('./chat/chat-server') returns in this mode);--+ * - keeps its chat tables as a read mirror that Chat writes (POST /internal/chat-effects/mirror)--+ * so its own readers (home stats, recaps, AI context, /api/mod queues) keep working and a--+ * rollback loses nothing;--+ * - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.--+ * Anything else (default): Live runs chat itself, exactly as before.--+ */--+--+const CHAT_URL = (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/+$/, '');--+--+function isRemote() {--+ return process.env.CHAT_AUTHORITY === 'chat';--+}--+--+module.exports = { isRemote, CHAT_URL };--diff --git a/server/chat/chat-remote.js b/server/chat/chat-remote.js--new file mode 100644--index 0000000..7809725--+++ b/server/chat/chat-remote.js--@@ -0,0 +1,374 @@--+'use strict';--+/**--+ * The chat server, when OpenVibe.Chat runs it (CHAT_AUTHORITY=chat, see chat-authority.js).--+ *--+ * require('./chat/chat-server') returns this object instead of a ChatServer, so every module that--+ * talks to chat keeps working unchanged:--+ * - calls that push to browsers (broadcastToStream, forwardToGlobal, sendDm, TTS, channel sounds,--+ * disconnectUser, sendUserUpdate …) are queued, in order, and sent to Chat's--+ * POST /internal/live/calls in small batches (service token for audience openvibe.chat,--+ * capability chat.live_bridge.write);--+ * - the chat-table writes Live's other modules make (db.saveChatMessage from AI viewers, relays,--+ * donations; /api/mod deletes and queues; moderation log) are forwarded the same way, because--+ * Chat owns those tables. Updates and deletes also run on Live's mirror at once so their--+ * return values (deleted ids) are right; inserts run only in Chat and return a placeholder id--+ * (≤ -2^40) that Chat maps to the real one for every later call of this boot. Forwarded writes--+ * are kept in chat_bridge_outbox until Chat acknowledges them, so a restart or a Chat outage--+ * loses none;--+ * - synchronous reads (connection counts, viewer counts, slow modes, a connected user's address)--+ * come from a presence snapshot polled from Chat's GET /internal/live/presence every few seconds.--+ */--+const crypto = require('crypto');--+const db = require('../db/database');--+const principal = require('../net/network-principal');--+const { CHAT_URL } = require('./chat-authority');--+--+const AUDIENCE = 'openvibe.chat';--+const BOOT = crypto.randomUUID();--+const REF_BASE = 2 ** 40;--+const BATCH = 200;--+const PRESENCE_MS = 3000;--+const EPHEMERAL_TTL_MS = 30000; // a broadcast older than this is dropped, not delivered late--+const MAX_QUEUE = 20000;--+--+// Chat-table writes Live still makes, and whether they also run on Live's mirror right away.--+// Inserts never run here (their ids are Chat's): the caller gets a placeholder.--+const FORWARDED_DB = {--+ saveChatMessage: 'insert',--+ logModerationAction: 'insert',--+ holdMessageForApproval: 'insert',--+ hideRelayUser: 'insert',--+ createChannelSound: 'insert',--+ mergeChatMessageMetadata: 'local',--+ deleteChatMessage: 'local',--+ deleteUserChatMessages: 'local',--+ deleteAnonChatMessages: 'local',--+ deleteRelayUserMessages: 'local',--+ deleteChatMessagesByTimeRange: 'local',--+ reviewPendingIpMessage: 'local',--+ approveAllFromIp: 'local',--+ denyAllFromIp: 'local',--+ recordRelayUser: 'local',--+ unhideRelayUser: 'local',--+ unhideRelayUserByIdentity: 'local',--+ recordFirstChat: 'local',--+ setTtsVoiceOverride: 'local',--+ deleteTtsVoiceOverride: 'local',--+ setChannelSoundEmote: 'local',--+ deleteChannelSound: 'local',--+ renameChannelSoundCommand: 'local',--+ updateChannelSoundEmoteRefs: 'local',--+};--+--+// Live-owned data Chat caches, written by Live's own routes (dashboard, /api/channels, /api/mod):--+// after the write, Chat is told to reload it instead of waiting for its cache to expire.--+const OBSERVED_DB = {--+ addChannelModerator: (a) => ['channel', a[0]],--+ removeChannelModerator: (a) => ['channel', a[0]],--+ upsertChannelModerationSettings: (a) => ['channel', a[0]],--+ setChannelAlertSound: (a) => ['channel', a[0]],--+ approveIp: (a) => ['approvals', a[0]],--+ revokeIpApproval: (a) => ['approvals', a[0]],--+ forgiveBan: () => ['bans', null],--+};--+--+function create(ChatServer) {--+ // A local ChatServer that never listens: its address and anon-number helpers are Live's own--+ // (call-server uses them for voice calls, and Chat asks Live for anon numbers).--+ const local = new ChatServer();--+ const queue = []; // { seq, op, args, ref, durableId, at }--+ let seq = 0;--+ let refCounter = 0;--+ let busy = false;--+ let inflight = 0; // ops at the head of the queue that are being sent right now--+ let batchLimit = BATCH; // 1 after Chat refused a batch as a whole (too large / malformed)--+ let backoffUntil = 0;--+ let flushTimer = null;--+ let presenceTimer = null;--+ let started = false;--+ let presence = { total: 0, streams: {}, slow_mode: {}, users: [], anons: [], at: null };--+ const slowModeByStream = new Map();--+ const stats = { sent: 0, failed: 0, dropped: 0, lastError: null };--+--+ function ensureOutbox() {--+ db.getDb().exec(`CREATE TABLE IF NOT EXISTS chat_bridge_outbox (--+ id INTEGER PRIMARY KEY AUTOINCREMENT,--+ boot TEXT NOT NULL,--+ ref INTEGER,--+ op TEXT NOT NULL,--+ args TEXT NOT NULL,--+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP--+ )`);--+ }--+--+ function schedule(ms = 0) {--+ if (flushTimer) return;--+ flushTimer = setTimeout(() => { flushTimer = null; flush().catch(() => {}); }, ms);--+ if (flushTimer.unref) flushTimer.unref();--+ }--+--+ function enqueue(op, args, { ref = null, durable = false, boot = BOOT, durableId = null } = {}) {--+ let id = durableId;--+ if (durable && !id) {--+ try {--+ ensureOutbox();--+ id = db.getDb().prepare('INSERT INTO chat_bridge_outbox (boot, ref, op, args) VALUES (?, ?, ?, ?)').run(boot, ref, op, JSON.stringify(args)).lastInsertRowid;--+ } catch (err) { console.warn('[ChatRemote] outbox write failed:', err.message); }--+ }--+ if (queue.length >= MAX_QUEUE) {--+ // Drop the oldest broadcast, never a write.--+ const i = queue.findIndex((q, idx) => idx >= inflight && !q.durableId);--+ if (i >= 0) { queue.splice(i, 1); stats.dropped++; }--+ }--+ queue.push({ seq: ++seq, op, args, ref, durableId: id, boot, at: Date.now() });--+ schedule();--+ }--+--+ async function post(path, body, retried = false) {--+ const res = await fetch(`${CHAT_URL}${path}`, {--+ method: body ? 'POST' : 'GET',--+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await principal.serviceHeaders(AUDIENCE)) },--+ body: body ? JSON.stringify(body) : undefined,--+ signal: AbortSignal.timeout(10000),--+ });--+ if (res.status === 401 && !retried) { principal.invalidate(AUDIENCE); return post(path, body, true); }--+ if (!res.ok) { const err = new Error(`Chat ${res.status}`); err.status = res.status; throw err; }--+ return res.json();--+ }--+--+ async function flush() {--+ if (busy || !queue.length) return;--+ if (Date.now() < backoffUntil) { schedule(backoffUntil - Date.now()); return; }--+ busy = true;--+ try {--+ while (queue.length) {--+ // Stale broadcasts are dropped; forwarded writes always go.--+ const now = Date.now();--+ for (let i = queue.length - 1; i >= 0; i--) {--+ if (!queue[i].durableId && now - queue[i].at > EPHEMERAL_TTL_MS) { queue.splice(i, 1); stats.dropped++; }--+ }--+ if (!queue.length) break;--+ // One boot per request (replayed writes of an earlier boot keep their own id space).--+ const boot = queue[0].boot;--+ const batch = [];--+ for (const q of queue) { if (q.boot !== boot || batch.length >= batchLimit) break; batch.push(q); }--+ let out;--+ inflight = batch.length;--+ try {--+ // A forwarded write carries its outbox id as an idempotency key: Chat applies it once--+ // even when a lost response makes Live send it again.--+ out = await post('/internal/live/calls', { boot, ops: batch.map((q) => ({ seq: q.seq, op: q.op, args: q.args, ref: q.ref, key: q.durableId ? `live:${q.durableId}` : undefined })) });--+ } catch (err) {--+ inflight = 0;--+ stats.failed++;--+ if (err.status === 400 || err.status === 413) {--+ // Refused as a request, not per op: retry one by one; a single op still refused--+ // can never be delivered, so it is dropped (logged) instead of blocking the queue.--+ if (batch.length > 1) { batchLimit = 1; continue; }--+ const q = queue.shift();--+ stats.dropped++;--+ console.error(`[ChatRemote] Chat refused ${q.op}${q.op === 'db' ? ` ${q.args[0]}` : ''} (${err.status}); dropped`);--+ if (q.durableId) { try { db.getDb().prepare('DELETE FROM chat_bridge_outbox WHERE id = ?').run(q.durableId); } catch { /* */ } }--+ continue;--+ }--+ if (stats.lastError !== err.message) console.warn(`[ChatRemote] Chat unreachable (${queue.length} queued): ${err.message}`);--+ stats.lastError = err.message;--+ backoffUntil = Date.now() + 2000;--+ schedule(2000);--+ return;--+ }--+ stats.lastError = null;--+ inflight = 0;--+ batchLimit = BATCH;--+ queue.splice(0, batch.length);--+ stats.sent += batch.length;--+ const done = batch.filter((q) => q.durableId).map((q) => q.durableId);--+ if (done.length) {--+ try { db.getDb().prepare(`DELETE FROM chat_bridge_outbox WHERE id IN (${done.map(() => '?').join(',')})`).run(...done); } catch { /* */ }--+ }--+ for (const r of (out && out.results) || []) {--+ if (!r.ok) console.warn(`[ChatRemote] Chat refused op #${r.seq}: ${r.error}`);--+ }--+ }--+ } finally { busy = false; }--+ }--+--+ async function pollPresence() {--+ try {--+ const p = await post('/internal/live/presence');--+ presence = p;--+ slowModeByStream.clear();--+ for (const [k, v] of Object.entries(p.slow_mode || {})) slowModeByStream.set(Number(k), Number(v) || 0);--+ } catch { /* keep the last snapshot */ }--+ }--+--+ // ── Forwarded database writes ───────────────────────────────--+ const originals = {};--+ function installDbForwarding() {--+ for (const [fn, mode] of Object.entries(FORWARDED_DB)) {--+ if (typeof db[fn] !== 'function' || originals[fn]) continue;--+ originals[fn] = db[fn];--+ db[fn] = function forwardedChatWrite(...args) {--+ if (mode === 'insert') {--+ const ref = -(REF_BASE + (++refCounter));--+ enqueue('db', [fn, ...args], { ref, durable: true });--+ return { changes: 1, lastInsertRowid: ref };--+ }--+ let result;--+ try { result = originals[fn](...args); } catch (err) { result = undefined; console.warn(`[ChatRemote] mirror ${fn}: ${err.message}`); }--+ enqueue('db', [fn, ...args], { durable: true });--+ return result;--+ };--+ }--+ }--+--+ function installInvalidations() {--+ for (const [fn, target] of Object.entries(OBSERVED_DB)) {--+ if (typeof db[fn] !== 'function' || originals[fn]) continue;--+ originals[fn] = db[fn];--+ db[fn] = function observedLiveWrite(...args) {--+ const result = originals[fn](...args);--+ try { const [kind, id] = target(args); enqueue('invalidate', [kind, id]); } catch { /* */ }--+ return result;--+ };--+ }--+ }--+--+ function replayOutbox() {--+ try {--+ ensureOutbox();--+ const rows = db.getDb().prepare('SELECT id, boot, ref, op, args FROM chat_bridge_outbox ORDER BY id').all();--+ for (const r of rows) {--+ let args;--+ try { args = JSON.parse(r.args); } catch { continue; }--+ queue.push({ seq: ++seq, op: r.op, args, ref: r.ref, durableId: r.id, boot: r.boot, at: Date.now() });--+ }--+ if (rows.length) console.log(`[ChatRemote] replaying ${rows.length} chat write(s) queued before the restart`);--+ } catch (err) { console.warn('[ChatRemote] outbox replay:', err.message); }--+ }--+--+ // A stream row carries Live's stream keys: Chat only ever needs the owner.--+ const slimStream = (s) => (s ? { id: s.id, user_id: s.user_id } : s);--+ const send = (op) => (...args) => { enqueue(op, args); };--+--+ // Live code that iterated chatServer.clients and wrote to each socket (a global delete in--+ // /api/mod) gets one pseudo-socket whose send() reaches every chat client through Chat.--+ const everyone = { readyState: 1, bufferedAmount: 0, send: (raw) => enqueue('broadcastAllRaw', [String(raw)]), close() {} };--+ const clients = new Map([[everyone, { pseudo: true }]]);--+--+ const remote = {--+ remote: true,--+ boot: BOOT,--+ stats,--+ clients,--+ slowModeByStream,--+--+ init() {--+ if (started) return null;--+ started = true;--+ ensureOutbox();--+ replayOutbox();--+ schedule();--+ pollPresence();--+ presenceTimer = setInterval(pollPresence, PRESENCE_MS);--+ if (presenceTimer.unref) presenceTimer.unref();--+ console.log(`[Chat] CHAT_AUTHORITY=chat — chat runs in OpenVibe.Chat (${CHAT_URL}); Live forwards to it`);--+ return null;--+ },--+ handleUpgrade(req, socket) {--+ // nginx sends /ws/chat to Chat; a socket that still lands here is told to retry.--+ try { socket.write('HTTP/1.1 503 Service Unavailable\r\nRetry-After: 2\r\nConnection: close\r\n\r\n'); } catch { /* */ }--+ try { socket.destroy(); } catch { /* */ }--+ return true;--+ },--+ close() {--+ if (presenceTimer) clearInterval(presenceTimer);--+ presenceTimer = null;--+ return flush().catch(() => {});--+ },--+ flush,--+--+ // ── Pushes to browsers (Chat delivers) ──--+ broadcastToStream: send('broadcastToStream'),--+ broadcastToChannelRoom: send('broadcastToChannelRoom'),--+ broadcastGlobal: send('broadcastGlobal'),--+ broadcastAll: send('broadcastAll'),--+ forwardToGlobal: send('forwardToGlobal'),--+ forwardToGlobalByChannel: send('forwardToGlobalByChannel'),--+ forwardToStreamerRooms: send('forwardToStreamerRooms'),--+ broadcastToOwnerStreams: send('broadcastToOwnerStreams'),--+ sendDm: send('sendDm'),--+ sendUserUpdate: send('sendUserUpdate'),--+ disconnectUser: send('disconnectUser'),--+ sendToConn: send('sendToConn'),--+ synthesizeAndBroadcastTTS(...args) { enqueue('synthesizeAndBroadcastTTS', args); return Promise.resolve(); },--+ triggerChannelSound(ws, client, stream, command, args = [], relay = null) {--+ const c = client ? { streamId: client.streamId || null, user: client.user ? { id: client.user.id, username: client.user.username, display_name: client.user.display_name, role: client.user.role, avatar_url: client.user.avatar_url, profile_color: client.user.profile_color } : null, anonId: client.anonId || null, ip: client.ip || null } : {};--+ enqueue('triggerChannelSound', [null, c, slimStream(stream), command, args, relay]);--+ },--+ /** Commits this boot shipped (deploy-notice.js decides which; Chat stores and shows them). */--+ deployNotice(commits) { enqueue('deployNotice', [commits], { durable: true }); },--+ /** Live changed a user (role push, avatar): Chat drops what it cached about them. */--+ userChanged(userId) { if (userId) enqueue('userChanged', [userId]); },--+--+ // ── Synchronous reads (presence snapshot) ──--+ getTotalConnections() { return Number(presence.total) || 0; },--+ getStreamViewerCount(streamId) { return Number((presence.streams || {})[streamId]) || 0; },--+ getConnectedUserIp(userId) {--+ const hit = (presence.users || []).find((u) => u.user_id === userId);--+ return hit ? hit.ip : null;--+ },--+ findClientByAnonId(anonId, streamId) {--+ // Same match as ChatServer.findClientByAnonId: the socket's stream must equal streamId.--+ const hit = (presence.anons || []).find((a) => a.anon_id === anonId && (a.stream_id ?? null) === streamId);--+ return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;--+ },--+ getUserList() { return { logged: [], anonCount: 0 }; },--+--+ // ── Live's own address / anon helpers ──--+ normalizeIp: (ip) => local.normalizeIp(ip),--+ getClientIp: (req) => local.getClientIp(req),--+ getAnonIdForIp: (ip) => local.getAnonIdForIp(ip),--+ getAnonIdForConnection(ip, streamId = null) {--+ const key = local.normalizeIp(ip);--+ const hit = (presence.anons || []).find((a) => a.ip === key && (streamId == null || (a.stream_id || null) === streamId));--+ return hit ? hit.anon_id : local.getAnonIdForIp(key);--+ },--+ /** The anon number for an address (Network's unified resolve, else Live's table) — for Chat. */--+ async resolveAnon(ip) {--+ const key = local.normalizeIp(ip);--+ const num = await local._resolveUnifiedAnonNum(key);--+ return { anon_number: num, first_seen: db.getAnonFirstSeen(key) };--+ },--+ };--+--+ installDbForwarding();--+ installInvalidations();--+ remote._restoreDb = () => { for (const [fn, orig] of Object.entries(originals)) db[fn] = orig; };--+ return remote;--+}--+--+/**--+ * Rollback: Live runs chat itself again (CHAT_AUTHORITY unset) but chat writes it forwarded were--+ * never acknowledged by Chat (Chat down). Apply them to Live's own tables so nothing is lost.--+ * A write Chat did apply whose answer was lost is applied twice — rare, and only for that window.--+ */--+function drainToLocal(log = console) {--+ try {--+ const d = db.getDb();--+ if (!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_bridge_outbox'").get()) return 0;--+ const rows = d.prepare("SELECT id, args FROM chat_bridge_outbox WHERE op = 'db' ORDER BY id").all();--+ let applied = 0;--+ for (const r of rows) {--+ try {--+ const [fn, ...args] = JSON.parse(r.args);--+ if (FORWARDED_DB[fn] && typeof db[fn] === 'function') { db[fn](...args); applied++; }--+ } catch (err) { log.warn(`[ChatRemote] could not apply queued chat write #${r.id}: ${err.message}`); }--+ }--+ d.prepare('DELETE FROM chat_bridge_outbox').run();--+ if (rows.length) log.log(`[ChatRemote] applied ${applied} of ${rows.length} chat write(s) OpenVibe.Chat never acknowledged`);--+ return applied;--+ } catch (err) { log.warn('[ChatRemote] drain:', err.message); return 0; }--+}--+--+module.exports = { create, drainToLocal, FORWARDED_DB, OBSERVED_DB, REF_BASE, AUDIENCE };--diff --git a/server/chat/chat-server.js b/server/chat/chat-server.js--index cfce589..ae77d68 100644--+++ b/server/chat/chat-server.js--@@ -2491,4 +2491,6 @@ class ChatServer {-- }-- }-----module.exports = new ChatServer();--+// CHAT_AUTHORITY=chat: OpenVibe.Chat runs the chat server; Live's modules get a proxy with the same--+// methods that hands every call to it (chat-remote.js). Default: Live runs chat itself.--+module.exports = require('./chat-authority').isRemote() ? require('./chat-remote').create(ChatServer) : new ChatServer();--diff --git a/server/chat/deploy-notice.js b/server/chat/deploy-notice.js--index 4b0f91a..33f8811 100644--+++ b/server/chat/deploy-notice.js--@@ -82,6 +82,14 @@ async function announce({ db, chatServer, log = console }) {-- const { head, commits } = await newCommits(db);-- if (!head || !commits.length) return { announced: 0 };----+ // CHAT_AUTHORITY=chat: Live still decides what shipped; OpenVibe.Chat stores the rolling--+ // message and shows it (its own copy of this module), so hand it the commits.--+ if (chatServer && chatServer.remote) {--+ chatServer.deployNotice(commits);--+ try { db.setSetting(SETTING, head); } catch (err) { log.warn('[Deploy notice] not recorded:', err.message); }--+ return { announced: commits.length };--+ }--+-- let saved;-- try { saved = persist(db, commits); db.setSetting(SETTING, head); }-- catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }--diff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js--new file mode 100644--index 0000000..53a72ce--+++ b/server/chat/live-context-routes.js--@@ -0,0 +1,656 @@--+'use strict';--+/**--+ * What OpenVibe.Chat asks of Live (roadmap Wave 6). Chat owns chat; Live still owns accounts,--+ * streams, channels, bans, cosmetics, coins, AI viewers, the arena, the media queue and hardware.--+ * Chat's single adapter (OpenVibe.Chat server/live-context.js) calls these with a Network service--+ * token for audience openvibe.live (server/net/service-guard.js); nothing here is routed by nginx.--+ *--+ * /internal/chat-context/* capability live.chat_context.read — projections Chat caches--+ * POST /auth {token} → { user, expires_at, reason } (Live's own token rules)--+ * GET /users?after_id&limit → { rows } users projection (paged by id)--+ * POST /users/lookup {ids?, usernames?} → { users }--+ * GET /users/:id/follows → { streamer_ids }--+ * GET /users/profile?username&viewer_id → the /api/chat/user/:username/profile card--+ * GET /streams?after_id&limit, /streams/active, /streams/:id--+ * GET /managed-streams?after_id&limit, /channels?after_id&limit, /channels/by-user/:userId--+ * GET /channels/:id/policy → { channel, settings, moderator_ids, language }--+ * GET /channels/:id/approved-ip?ip → { approved }--+ * GET /bans?version → { version, bans } | { version, unchanged: true }--+ * POST /decor {user_ids} → { decor: { id: { cosmetic, tag } } }--+ * GET /settings → { settings } (tts_*, GIF and soundboard keys only)--+ * GET /anon/:num, /anon-first-seen?ip → { first_seen }--+ * GET /tts-audio/:file → a clip from the arena voice cache--+ *--+ * /internal/chat-effects/* capability live.chat_effects.write — side effects (CHAT_AUTHORITY=chat only)--+ * anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,--+ * alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,--+ * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, asset-sync--+ * mirror (capability live.chat_mirror.write) — Chat's changes to its tables, applied to Live's copy--+ *--+ * Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat--+ * checked first; Live does not take its word for it.--+ */--+const express = require('express');--+const fs = require('fs');--+const path = require('path');--+const db = require('../db/database');--+const config = require('../config');--+const permissions = require('../auth/permissions');--+const { guard } = require('../net/service-guard');--+const { isRemote } = require('./chat-authority');--+--+const contextRouter = express.Router();--+const effectsRouter = express.Router();--+--+const int = (v, d = 0) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };--+const pageArgs = (req) => ({ after: Math.max(0, int(req.query.after_id)), limit: Math.min(5000, Math.max(1, int(req.query.limit, 1000))) });--+const fail = (res, status, error) => res.status(status).json({ error });--+--+// ── Projections ─────────────────────────────────────────────────--+--+const SUBJECT_SQL = "(SELECT la.subject_id FROM linked_accounts la WHERE la.user_id = u.id AND la.service = 'network' AND la.subject_id IS NOT NULL ORDER BY la.id DESC LIMIT 1)";--+const USER_SQL = `SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, u.role, u.is_banned, u.ban_reason,--+ COALESCE(u.is_owner, 0) AS is_owner, u.created_at, ${SUBJECT_SQL} AS subject_id FROM users u`;--+const STREAM_SQL = 'SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams';--+const MS_SQL = 'SELECT id, user_id, slug, title, sort_order, created_at FROM managed_streams';--+const CHANNEL_SQL = 'SELECT id, user_id, title FROM channels';--+--+/** The chat projection of a user row (no secrets: no email, password, stream key, balances). */--+function userProjection(user, subjectId) {--+ if (!user) return null;--+ let subject = subjectId || user.subject_id || null;--+ if (!subject) {--+ try { subject = db.get("SELECT subject_id FROM linked_accounts WHERE user_id = ? AND service = 'network' AND subject_id IS NOT NULL ORDER BY id DESC LIMIT 1", [user.id])?.subject_id || null; } catch { subject = null; }--+ }--+ return {--+ id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url || null,--+ profile_color: user.profile_color || null, role: user.role, is_banned: user.is_banned ? 1 : 0, ban_reason: user.ban_reason || null,--+ is_owner: user.is_owner ? 1 : 0, created_at: user.created_at, subject_id: subject,--+ };--+}--+--+contextRouter.use(guard('live.chat_context.read'));--+--+// Resolve a browser/bot token exactly as Live's requireAuth / authenticateWs do.--+contextRouter.post('/auth', (req, res) => {--+ const token = String((req.body && req.body.token) || '');--+ if (!token) return res.json({ user: null, reason: 'invalid' });--+ const auth = require('../auth/auth');--+ const apiUser = auth.authenticateApiToken(token);--+ if (apiUser) {--+ const user = { ...userProjection(apiUser), auth_source: 'api_token', scopes: apiUser.scopes || [] };--+ return res.json({ user, expires_at: null, reason: null });--+ }--+ const r = auth.verifyTokenWithReason(token);--+ if (!r.ok) return res.json({ user: null, reason: 'invalid' });--+ const user = auth.resolveNetworkUser(r.decoded);--+ if (!user) return res.json({ user: null, reason: 'unresolved' });--+ res.json({--+ user: { ...userProjection(user, r.decoded.subject_id), auth_source: 'network' },--+ expires_at: typeof r.decoded.exp === 'number' ? new Date(r.decoded.exp * 1000).toISOString() : null,--+ reason: null,--+ });--+});--+--+contextRouter.get('/users', (req, res) => {--+ const { after, limit } = pageArgs(req);--+ res.json({ rows: db.all(`${USER_SQL} WHERE u.id > ? ORDER BY u.id LIMIT ?`, [after, limit]) });--+});--+--+contextRouter.post('/users/lookup', (req, res) => {--+ const ids = (Array.isArray(req.body?.ids) ? req.body.ids : []).map(Number).filter(Number.isInteger).slice(0, 500);--+ const names = (Array.isArray(req.body?.usernames) ? req.body.usernames : []).map(String).slice(0, 100);--+ const rows = [];--+ if (ids.length) rows.push(...db.all(`${USER_SQL} WHERE u.id IN (${ids.map(() => '?').join(',')})`, ids));--+ for (const n of names) { const r = db.get(`${USER_SQL} WHERE u.username = ? COLLATE NOCASE`, [n]); if (r) rows.push(r); }--+ res.json({ users: rows });--+});--+--+contextRouter.get('/users/profile', (req, res) => {--+ // The same card /api/chat/user/:username/profile built in Live.--+ try {--+ const name = String(req.query.username || '');--+ let user = db.getUserByUsername(name);--+ if (!user) user = db.get('SELECT * FROM users WHERE display_name = ? COLLATE NOCASE', [name]);--+ if (!user) return fail(res, 404, 'User not found');--+ const profile = db.getUserProfile(user.id);--+ if (!profile) return fail(res, 404, 'Profile not found');--+ // OpenCoins are part of the game; Vibes (real money) and presence stay with the user.--+ const viewerId = int(req.query.viewer_id, 0);--+ if (!viewerId || viewerId !== user.id) {--+ delete profile.openvibe_bucks_balance;--+ delete profile.last_seen;--+ }--+ try {--+ const game = require('../game/game-engine');--+ const player = game.getPlayer(user.id);--+ if (player) {--+ profile.game = {--+ total_level: player.total_level,--+ mining_level: player.mining_level,--+ fishing_level: player.fishing_level,--+ woodcut_level: player.woodcut_level,--+ farming_level: player.farming_level,--+ combat_level: player.combat_level,--+ crafting_level: player.crafting_level,--+ mining_xp: player.mining_xp,--+ fishing_xp: player.fishing_xp,--+ woodcut_xp: player.woodcut_xp,--+ farming_xp: player.farming_xp,--+ combat_xp: player.combat_xp,--+ crafting_xp: player.crafting_xp,--+ total_coins_earned: player.total_coins_earned || 0,--+ };--+ }--+ } catch { /* game not initialized or player doesn't exist */ }--+ res.json(profile);--+ } catch (err) {--+ fail(res, 500, 'Failed to get profile');--+ }--+});--+--+contextRouter.get('/users/:id/follows', (req, res) => {--+ res.json({ streamer_ids: db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [int(req.params.id)]).map((r) => r.streamer_id) });--+});--+--+contextRouter.get('/streams', (req, res) => {--+ const { after, limit } = pageArgs(req);--+ res.json({ rows: db.all(`${STREAM_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });--+});--+// Live now, plus anything that ended in the last quarter hour (so Chat sees it go offline).--+contextRouter.get('/streams/active', (req, res) => {--+ res.json({ rows: db.all(`${STREAM_SQL} WHERE is_live = 1 OR (ended_at IS NOT NULL AND ended_at >= datetime('now', '-15 minutes'))`) });--+});--+contextRouter.get('/streams/:id', (req, res) => {--+ const stream = db.get(`${STREAM_SQL} WHERE id = ?`, [int(req.params.id)]);--+ if (!stream) return res.json({ stream: null });--+ res.json({--+ stream,--+ owner: db.get(`${USER_SQL} WHERE u.id = ?`, [stream.user_id]) || null,--+ managed_stream: stream.managed_stream_id ? (db.get(`${MS_SQL} WHERE id = ?`, [stream.managed_stream_id]) || null) : null,--+ channel: stream.channel_id ? (db.get(`${CHANNEL_SQL} WHERE id = ?`, [stream.channel_id]) || null) : (db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [stream.user_id]) || null),--+ });--+});--+--+contextRouter.get('/managed-streams', (req, res) => {--+ const { after, limit } = pageArgs(req);--+ res.json({ rows: db.all(`${MS_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });--+});--+--+contextRouter.get('/channels', (req, res) => {--+ const { after, limit } = pageArgs(req);--+ res.json({ rows: db.all(`${CHANNEL_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });--+});--+contextRouter.get('/channels/by-user/:userId', (req, res) => {--+ res.json({ channel: db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [int(req.params.userId)]) || null });--+});--+contextRouter.get('/channels/:id/policy', (req, res) => {--+ const channelId = int(req.params.id);--+ const channel = db.get(`${CHANNEL_SQL} WHERE id = ?`, [channelId]) || null;--+ let language = 'en';--+ try { if (channel) language = require('../i18n/translate').channelLanguage(channel.user_id) || 'en'; } catch { language = 'en'; }--+ res.json({--+ channel,--+ settings: db.getChannelModerationSettings(channelId),--+ moderator_ids: db.all('SELECT user_id FROM channel_moderators WHERE channel_id = ?', [channelId]).map((r) => r.user_id),--+ language,--+ });--+});--+contextRouter.get('/channels/:id/approved-ip', (req, res) => {--+ res.json({ approved: db.isIpApproved(int(req.params.id), String(req.query.ip || '')) });--+});--+--+// Active bans (user, IP and CIDR rows). `version` changes whenever the table does, so an--+// unchanged table is one small answer.--+contextRouter.get('/bans', (req, res) => {--+ const v = db.get('SELECT COUNT(*) AS c, COALESCE(MAX(id), 0) AS m, COALESCE(SUM(id * 7 + COALESCE(stream_id, 0) + LENGTH(COALESCE(expires_at, \'\'))), 0) AS s FROM bans');--+ const version = `${v.c}:${v.m}:${v.s}`;--+ if (req.query.version && String(req.query.version) === version) return res.json({ version, unchanged: true });--+ res.json({--+ version,--+ bans: db.all('SELECT id, stream_id, user_id, ip_address, anon_id, expires_at FROM bans WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP'),--+ });--+});--+--+contextRouter.post('/decor', (req, res) => {--+ const ids = (Array.isArray(req.body?.user_ids) ? req.body.user_ids : []).map(Number).filter(Number.isInteger).slice(0, 500);--+ let cosmetics = null, tags = null;--+ try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }--+ try { tags = require('../game/tags'); } catch { /* */ }--+ const decor = {};--+ for (const id of ids) {--+ let cosmetic = {}, tag = null;--+ try { if (cosmetics) cosmetic = cosmetics.getCosmeticProfile(id) || {}; } catch { cosmetic = {}; }--+ try { if (tags) tag = tags.getTagProfile(id) || null; } catch { tag = null; }--+ decor[id] = { cosmetic, tag };--+ }--+ res.json({ decor });--+});--+--+// Site settings chat reads (TTS config, GIF provider keys, the 101soundboards key) — nothing else.--+const CHAT_SETTING_KEYS = new Set(['gif_tenor_api_key', 'gif_giphy_api_key', 'soundboard_101_api_key']);--+const isChatSetting = (k) => CHAT_SETTING_KEYS.has(k) || /^tts_[a-z0-9_]+$/.test(k);--+contextRouter.get('/settings', (req, res) => {--+ const settings = {};--+ for (const r of db.all("SELECT key FROM site_settings WHERE key LIKE 'tts\\_%' ESCAPE '\\' OR key IN ('gif_tenor_api_key', 'gif_giphy_api_key', 'soundboard_101_api_key')")) {--+ if (isChatSetting(r.key)) settings[r.key] = db.getSetting(r.key);--+ }--+ res.json({ settings });--+});--+--+contextRouter.get('/anon/:num', (req, res) => {--+ res.json({ first_seen: db.get('SELECT created_at FROM anon_ip_mappings WHERE anon_num = ?', [int(req.params.num)])?.created_at || null });--+});--+contextRouter.get('/anon-first-seen', (req, res) => {--+ res.json({ first_seen: db.getAnonFirstSeen(String(req.query.ip || '')) });--+});--+--+contextRouter.get('/tts-audio/:file', (req, res) => {--+ try {--+ const hit = require('../arena/voice').cachedByName(req.params.file);--+ if (!hit) return fail(res, 404, 'No such clip');--+ res.set({ 'Content-Type': hit.mimeType });--+ require('fs').createReadStream(hit.path).pipe(res);--+ } catch (err) { fail(res, 500, err.message); }--+});--+--+// ── Effects ────────────────────────────────────────────────────--+--+effectsRouter.post('/mirror', guard('live.chat_mirror.write'), (req, res, next) => next());--+effectsRouter.use((req, res, next) => {--+ if (req.path === '/mirror') return next();--+ return guard('live.chat_effects.write')(req, res, next);--+});--+// Live only takes Chat's writes while Chat is the authority — a rehearsal Chat pointed at--+// production Live can never award coins, post to AI viewers or overwrite chat tables.--+effectsRouter.use((req, res, next) => {--+ if (!isRemote()) return fail(res, 409, 'Live runs chat itself (CHAT_AUTHORITY is not "chat")');--+ next();--+});--+--+const chatServer = () => require('./chat-server');--+const actorOf = (id) => (id ? db.getUserById(id) : null);--+--+effectsRouter.post('/anon', async (req, res) => {--+ const ip = String(req.body?.ip || '');--+ if (!ip) return fail(res, 400, 'ip required');--+ try { res.json(await chatServer().resolveAnon(ip)); } catch (err) { fail(res, 500, err.message); }--+});--+--+effectsRouter.post('/ip-log', (req, res) => {--+ const entries = Array.isArray(req.body?.entries) ? req.body.entries.slice(0, 1000) : [];--+ let ipUtils = null;--+ try { ipUtils = require('../admin/ip-utils'); } catch { /* */ }--+ for (const e of entries) {--+ try {--+ const geo = ipUtils ? ipUtils.enrichIp(e.ip) : null;--+ db.logIp({ userId: e.userId || null, anonId: e.anonId || null, ip: e.ip, action: e.action || 'chat', geo });--+ } catch { /* non-critical */ }--+ }--+ res.json({ ok: true, logged: entries.length });--+});--+--+effectsRouter.post('/viewer-counts', (req, res) => {--+ for (const [sid, count] of Object.entries(req.body?.counts || {})) {--+ try { db.updateViewerCount(int(sid), Math.max(0, int(count))); } catch { /* */ }--+ }--+ res.json({ ok: true });--+});--+--+effectsRouter.post('/viewer-snapshots', (req, res) => {--+ for (const s of (Array.isArray(req.body?.snapshots) ? req.body.snapshots : []).slice(0, 1000)) {--+ try { db.insertViewerSnapshot(int(s.stream_id), Math.max(0, int(s.viewer_count)), Math.max(0, int(s.chat_messages_5m))); } catch { /* */ }--+ }--+ res.json({ ok: true });--+});--+--+// /color: the user changes their own color.--+effectsRouter.post('/user-color', (req, res) => {--+ const userId = int(req.body?.user_id);--+ const color = String(req.body?.color || '');--+ if (!userId || !/^#[0-9a-fA-F]{6}$/.test(color)) return fail(res, 400, 'user_id and a #rrggbb color required');--+ db.run('UPDATE users SET profile_color = ? WHERE id = ?', [color, userId]);--+ res.json({ ok: true });--+});--+--+// /ban, /timeout, /unban from chat. The moderator is checked again: global staff, or a--+// moderator of the stream chat decided on (for an offline channel room, its latest stream).--+effectsRouter.post('/ban', (req, res) => {--+ const b = req.body || {};--+ const actor = actorOf(int(b.actor_user_id));--+ const modStream = int(b.moderation_stream_id) || null;--+ const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && permissions.canModerateStream(actor, modStream)));--+ if (!allowed) return fail(res, 403, 'You do not have permission.');--+ const streamId = b.stream_id != null ? int(b.stream_id) || null : null;--+ if (b.action === 'unban') {--+ const userId = int(b.user_id);--+ if (!userId) return fail(res, 400, 'user_id required');--+ db.run('DELETE FROM bans WHERE user_id = ? AND (stream_id = ? OR stream_id IS NULL)', [userId, streamId]);--+ return res.json({ ok: true });--+ }--+ if (b.action !== 'ban') return fail(res, 400, 'action must be ban or unban');--+ if (b.user_id) {--+ const target = db.getUserById(int(b.user_id));--+ if (!target) return fail(res, 404, 'User not found');--+ // Prevent non-admins from banning admins--+ if (permissions.isGlobalModOrAbove(target) && target.role === 'admin' && actor.role !== 'admin') return fail(res, 403, 'You cannot ban an admin.');--+ db.run('INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)',--+ [streamId, target.id, String(b.reason || 'Banned by moderator').slice(0, 200), actor.id, b.expires_at || null]);--+ } else if (b.ip_address) {--+ db.run('INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)',--+ [streamId, String(b.ip_address), b.anon_id ? String(b.anon_id) : null, String(b.reason || 'Banned by moderator').slice(0, 200), actor.id]);--+ } else {--+ return fail(res, 400, 'user_id or ip_address required');--+ }--+ res.json({ ok: true });--+});--+--+// IP approval mode: an address that chatted here before is approved automatically ('auto_existing').--+effectsRouter.post('/approve-ip', (req, res) => {--+ const channelId = int(req.body?.channel_id);--+ const ip = String(req.body?.ip || '');--+ if (!channelId || !ip) return fail(res, 400, 'channel_id and ip required');--+ db.approveIp(channelId, ip, req.body.approved_by ? int(req.body.approved_by) : null, String(req.body.source || 'auto').slice(0, 32));--+ res.json({ ok: true });--+});--+--+// /slow persists the channel's slow mode.--+effectsRouter.post('/channel-settings', (req, res) => {--+ const channelId = int(req.body?.channel_id);--+ const actor = actorOf(int(req.body?.actor_user_id));--+ if (!channelId || !actor || !permissions.canModerateChannel(actor, channelId)) return fail(res, 403, 'You do not have permission.');--+ const fields = {};--+ if (req.body.fields && req.body.fields.slow_mode_seconds !== undefined) fields.slow_mode_seconds = Math.max(0, int(req.body.fields.slow_mode_seconds));--+ if (!Object.keys(fields).length) return fail(res, 400, 'no chat-settable fields');--+ db.upsertChannelModerationSettings(channelId, fields);--+ res.json({ ok: true });--+});--+--+// Donation / goal alert sounds: the streamer's own channel only, files in the shared sounds dir.--+effectsRouter.post('/alert-sound', (req, res) => {--+ const channelId = int(req.body?.channel_id);--+ const channel = channelId ? db.getChannelById(channelId) : null;--+ if (!channel || channel.user_id !== int(req.body?.actor_user_id)) return fail(res, 403, 'Not your channel');--+ const url = req.body.url ? path.resolve(String(req.body.url)) : null;--+ if (url) {--+ // Compared through realpath: Chat and Live may reach the shared sounds dir via different symlinks.--+ let inside = false;--+ try { inside = path.dirname(fs.realpathSync(url)) === fs.realpathSync(path.resolve(config.sounds.path)); } catch { inside = false; }--+ if (!inside) return fail(res, 400, 'alert sounds live in the sounds directory');--+ }--+ db.setChannelAlertSound(channelId, req.body.kind === 'goal' ? 'goal' : 'donation', url, url ? String(req.body.mime || 'audio/mpeg') : null);--+ res.json({ ok: true });--+});--+--+effectsRouter.post('/ensure-channel', (req, res) => {--+ const userId = int(req.body?.user_id);--+ if (!userId || !db.getUserById(userId)) return fail(res, 404, 'User not found');--+ const ch = db.ensureChannel(userId);--+ res.json({ channel: ch ? { id: ch.id, user_id: ch.user_id, title: ch.title } : null });--+});--+--+// TTS admin settings: admins; credentials only the owner (Live's /api/tts/admin/settings rules).--+effectsRouter.post('/site-settings', (req, res) => {--+ const actor = actorOf(int(req.body?.actor_user_id));--+ if (!actor || actor.role !== 'admin') return fail(res, 403, 'Admin access required');--+ const allowed = ['tts_enabled', 'tts_provider', 'tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region', 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global', 'tts_default_voice'];--+ const secret = new Set(['tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key']);--+ const owner = permissions.isOwner(actor);--+ let count = 0;--+ for (const [key, value] of Object.entries(req.body.settings || {})) {--+ if (!allowed.includes(key)) continue;--+ if (secret.has(key) && !owner) continue;--+ if (secret.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;--+ db.setSetting(key, value);--+ count++;--+ }--+ try { require('./tts-engine').invalidateSettingsCache(); } catch { /* */ }--+ res.json({ ok: true, updated: count });--+});--+--+// One real chat line: OpenCoins chat bonus, AI chat viewers, the PowerChat overlay relay.--+effectsRouter.post('/chat-message', (req, res) => {--+ const b = req.body || {};--+ let coin = null;--+ if (b.award && b.user_id && b.stream_id) {--+ try { coin = require('../monetization/opencoins').awardChat(int(b.user_id), int(b.stream_id)); } catch { coin = null; }--+ }--+ if (b.ai && b.stream_id) {--+ try {--+ require('../integrations/ai-chatbot-service').onRealChatMessage(int(b.stream_id), {--+ username: b.username,--+ message: b.message,--+ userId: b.user_id || null,--+ anonId: b.anon_id || null,--+ isStreamer: !!b.is_streamer,--+ isMod: !!b.is_mod,--+ msgId: b.msg_id || null,--+ channelUserId: b.channel_user_id || null,--+ });--+ } catch { /* non-critical */ }--+ }--+ if (b.powerchat && b.channel_user_id && b.powerchat_chat) {--+ try {--+ const pc = require('../integrations/powerchat-platform');--+ const channelUserId = int(b.channel_user_id);--+ if (pc.channelRelayEnabled(channelUserId, b.stream_id || null)) {--+ let isSub = false;--+ try { isSub = !!(b.user_id && db.isActiveSubscriber(int(b.user_id), channelUserId)); } catch { /* */ }--+ const c = b.powerchat_chat;--+ pc.forwardChat(channelUserId, {--+ chatterName: c.chatterName,--+ externalChatterId: c.externalChatterId,--+ message: c.message,--+ avatarUrl: c.avatarUrl || undefined,--+ isModerator: !!c.isModerator,--+ isSubscriber: isSub,--+ });--+ }--+ } catch { /* non-critical */ }--+ }--+ res.json({ coin });--+});--+--+effectsRouter.post('/ai/mod-command', (req, res) => {--+ try {--+ const engine = require('../integrations/ai-chatbot-service');--+ const reply = engine.onModCommand--+ ? engine.onModCommand(req.body.channel_user_id || null, req.body.stream_id || null, Array.isArray(req.body.args) ? req.body.args : [], { by: req.body.by })--+ : 'AI viewers: command not supported by this engine.';--+ res.json({ reply: reply || null });--+ } catch (err) { fail(res, 400, err.message); }--+});--+--+// !hype / !beef / !arena: arena-chat answers the sender through Chat (sendToConn).--+effectsRouter.post('/arena-command', (req, res) => {--+ const client = req.body?.client || {};--+ const cs = chatServer();--+ const shim = {--+ sendTo: (_ws, payload) => cs.sendToConn(client.conn_id, payload),--+ broadcastToStream: (streamId, payload) => cs.broadcastToStream(streamId, payload),--+ };--+ let handled = false;--+ try { handled = require('../arena/arena-chat').handle(shim, null, client, String(req.body.cmd || ''), Array.isArray(req.body.parts) ? req.body.parts : []); } catch (e) { console.warn('[Arena] chat command:', e.message); }--+ res.json({ handled: !!handled });--+});--+--+// !sr / !queue / !np / !skip — the media queue is Live's.--+effectsRouter.post('/media-queue', async (req, res) => {--+ const b = req.body || {};--+ const mediaQueue = require('../media/media-queue');--+ const streamerId = int(b.streamerId);--+ if (!streamerId) return fail(res, 400, 'streamerId required');--+ try {--+ if (b.op === 'add') {--+ const request = await mediaQueue.addRequest({ streamerId, streamId: int(b.streamId) || null, userId: int(b.userId), username: String(b.username || ''), input: String(b.input || '') });--+ return res.json({ request });--+ }--+ if (b.op === 'state') return res.json({ state: mediaQueue.getState(streamerId) });--+ if (b.op === 'skip') {--+ const actor = actorOf(int(b.actorUserId));--+ const streamId = int(b.streamId) || null;--+ const allowed = actor && (actor.id === streamerId || permissions.isGlobalModOrAbove(actor) || (streamId && permissions.canModerateStream(actor, streamId)));--+ if (!allowed) return fail(res, 403, 'Only the streamer or a moderator can skip media.');--+ const ended = mediaQueue.finishCurrent(streamerId, 'skipped');--+ const next = mediaQueue.startNext(streamerId);--+ return res.json({ ended: ended || null, next: next || null });--+ }--+ fail(res, 400, 'unknown op');--+ } catch (err) {--+ fail(res, 400, err.message || 'Media command failed.');--+ }--+});--+--+// Cozmo / !say: the robot's control socket is in Live, keyed by the streamer's stream key.--+const HARDWARE_COMMANDS = new Set(['forward', 'backward', 'turn_left', 'turn_right', 'lift_up', 'lift_down', 'head_up', 'head_down']);--+effectsRouter.post('/hardware', (req, res) => {--+ const command = String(req.body?.command || '');--+ if (!HARDWARE_COMMANDS.has(command) && !/^say:[\s\S]{1,200}$/.test(command)) return fail(res, 400, 'unknown command');--+ const user = db.getUserById(int(req.body.streamer_user_id));--+ if (!user) return res.json({ ok: false, reason: 'no_user' });--+ const controlServer = require('../controls/control-server');--+ const hwWs = controlServer.hardwareClients.get(user.stream_key);--+ if (!hwWs || hwWs.readyState !== 1) return res.json({ ok: false, reason: 'no_hardware' });--+ hwWs.send(JSON.stringify({--+ type: 'command',--+ command,--+ from_user: String(req.body.from_user || ''),--+ timestamp: new Date().toISOString(),--+ }));--+ res.json({ ok: true });--+});--+--+// /paste from chat, through Live's pastes client (Community or Media per PASTES_AUTHORITY).--+effectsRouter.post('/paste', async (req, res) => {--+ const b = req.body || {};--+ try {--+ const paste = await require('../pastes-client').createPaste({--+ title: String(b.title || '').slice(0, 200),--+ content: String(b.content || ''),--+ language: b.language || 'auto',--+ visibility: b.visibility || 'public',--+ user_id: b.user_id || undefined,--+ stream_id: b.stream_id || undefined,--+ });--+ res.json({ paste: { slug: paste.slug } });--+ } catch (err) { fail(res, 502, err.message); }--+});--+--+effectsRouter.post('/translate', async (req, res) => {--+ let i18n;--+ try { i18n = require('../i18n/translate'); } catch { return res.json({ translation: null }); }--+ if (!i18n.available()) return res.json({ translation: null });--+ try {--+ const tr = await i18n.translateChatMessage(String(req.body?.text || ''), req.body?.channel_user_id || null);--+ res.json({ translation: tr && tr.text ? tr : null });--+ } catch { res.json({ translation: null }); }--+});--+--+effectsRouter.post('/notify/dm', (req, res) => {--+ try {--+ const { pushBulkNotification, actorInfo } = require('../utils/notify');--+ const sender = db.getUserById(int(req.body?.sender_id));--+ const otherIds = (Array.isArray(req.body?.recipient_ids) ? req.body.recipient_ids : []).map(Number).filter(Boolean).slice(0, 50);--+ const convId = int(req.body?.conversation_id);--+ if (otherIds.length && convId) {--+ const senderName = sender?.display_name || sender?.username || 'Someone';--+ // Generic preview — the message itself only travels over the live socket.--+ pushBulkNotification(otherIds, {--+ type: 'DIRECT_MESSAGE',--+ title: `Message from ${senderName}`,--+ message: 'Sent you a message',--+ url: `https://openvibe.live/dm/${convId}`,--+ ...actorInfo(sender),--+ });--+ }--+ } catch { /* non-critical */ }--+ res.json({ ok: true });--+});--+--+effectsRouter.post('/notify/dm-read', (req, res) => {--+ try {--+ const { markNotificationsRead } = require('../utils/notify');--+ markNotificationsRead(int(req.body?.user_id), 'DIRECT_MESSAGE', `%/dm/${int(req.body?.conversation_id)}`);--+ } catch { /* non-critical */ }--+ res.json({ ok: true });--+});--+--+// Channel sound files are mirrored to OpenVibe.Media from Live's copy of channel_sounds.--+effectsRouter.post('/asset-sync', (req, res) => {--+ try {--+ const sync = require('../media-proxy/asset-sync');--+ if (req.body?.op === 'syncSoon') sync.syncSoon();--+ else if (req.body?.op === 'remove-sound') {--+ const row = db.get('SELECT media_asset_id FROM channel_sounds WHERE id = ?', [int(req.body.asset_id)]);--+ if (row && row.media_asset_id) sync.removeAsset(row.media_asset_id);--+ }--+ } catch { /* best-effort */ }--+ res.json({ ok: true });--+});--+--+// ── The read mirror of Chat's tables ──────────────────────────────--+// Same tables, same ids. Upserts set only the columns Live's table has (Chat's *subject_id--+// columns are skipped; Live-only columns such as channel_sounds.media_asset_id are kept).--+const MIRROR_TABLES = {--+ chat_messages: ['id'],--+ dm_conversations: ['id'],--+ dm_participants: ['id'],--+ dm_messages: ['id'],--+ dm_blocks: ['id'],--+ tts_voice_overrides: ['identity_key'],--+ channel_sounds: ['id'],--+ relay_users: ['platform', 'username'],--+ hidden_relay_users: ['id'],--+ pending_ip_messages: ['id'],--+ stream_first_chats: ['chatter_key', 'channel_user_id'],--+ moderation_actions: ['id'],--+};--+const _cols = new Map();--+function liveColumns(table) {--+ if (!_cols.has(table)) _cols.set(table, new Set(db.getDb().prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));--+ return _cols.get(table);--+}--+--+function applyMirror(changes) {--+ const d = db.getDb();--+ let applied = 0;--+ const skipped = [];--+ // A copy of the authority's rows: foreign keys are the authority's business. Synchronous, so--+ // nothing else runs while they are off.--+ d.pragma('foreign_keys = OFF');--+ try {--+ d.transaction(() => {--+ for (const c of changes) {--+ const pk = MIRROR_TABLES[c && c.table];--+ if (!pk) { skipped.push({ table: c && c.table, reason: 'not a mirrored table' }); continue; }--+ const have = liveColumns(c.table);--+ try {--+ if (c.op === 'delete') {--+ d.prepare(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`).run(...pk.map((k) => c.pk[k]));--+ } else if (c.op === 'upsert' && c.row) {--+ const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));--+ const upd = cols.filter((k) => !pk.includes(k));--+ d.prepare(`INSERT INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})--+ ON CONFLICT(${pk.join(', ')}) DO ${upd.length ? `UPDATE SET ${upd.map((k) => `${k} = excluded.${k}`).join(', ')}` : 'NOTHING'}`)--+ .run(...cols.map((k) => c.row[k]));--+ } else { skipped.push({ table: c.table, reason: 'bad change' }); continue; }--+ applied++;--+ } catch (err) {--+ skipped.push({ table: c.table, pk: c.pk || (c.row && pk.map((k) => c.row[k])), reason: err.message });--+ }--+ }--+ })();--+ } finally {--+ d.pragma('foreign_keys = ON');--+ }--+ return { applied, skipped };--+}--+--+effectsRouter.post('/mirror', (req, res) => {--+ const changes = Array.isArray(req.body?.changes) ? req.body.changes.slice(0, 1000) : [];--+ const out = applyMirror(changes);--+ if (out.skipped.length) console.warn(`[ChatMirror] ${out.skipped.length} change(s) not applied:`, JSON.stringify(out.skipped.slice(0, 3)));--+ res.json({ ok: true, ...out });--+});--+--+module.exports = { contextRouter, effectsRouter, applyMirror, userProjection, MIRROR_TABLES };--diff --git a/server/index.js b/server/index.js--index 894e159..2763945 100644--+++ b/server/index.js--@@ -585,6 +585,11 @@ app.get('/internal/analytics-summary', (req, res) => {-- } catch (err) { res.status(500).json({ ok: false, error: err.message }); }-- });----+// OpenVibe.Chat reads Live data and asks for side effects here, with Network service tokens--+// (server/chat/live-context-routes.js); mounted before the X-Internal-Key routes below.--+const chatLiveContext = require('./chat/live-context-routes');--+app.use('/internal/chat-context', chatLiveContext.contextRouter);--+app.use('/internal/chat-effects', chatLiveContext.effectsRouter);-- app.use('/internal', require('./internal/routes'));------@@ -653,7 +658,11 @@ app.use('/api/streams', streamRoutes);-- // Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).-- const liveEvents = require('./streaming/live-events');-- app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));---app.use('/api/chat', chatRoutes);--+// CHAT_AUTHORITY=chat: OpenVibe.Chat serves the chat REST routes (nginx sends /api/chat/, /api/dm/,--+// /api/tts/, /api/sounds there). One that still reaches Live must not write to the mirror.--+const chatRemote = require('./chat/chat-authority').isRemote();--+const chatMoved = (req, res) => res.status(503).set('Retry-After', '2').json({ error: 'Chat is served by OpenVibe.Chat' });--+app.use('/api/chat', chatRemote ? chatMoved : chatRoutes);-- app.use('/api/funds', monetizationRoutes);-- app.use('/api/coins', coinsRoutes);-- app.use('/api/payments', require('./monetization/payments-routes'));--@@ -675,7 +684,7 @@ app.use('/api/restream', restreamRoutes);-- app.use('/api/thumbnails', thumbnailRoutes);-- app.use('/api/themes', themeRoutes);-- app.use('/api/emotes', emoteRoutes);---app.use('/api/sounds', require('./chat/sounds-routes'));--+app.use('/api/sounds', chatRemote ? chatMoved : require('./chat/sounds-routes'));-- app.use('/api/ai-viewers', require('./ai/viewers/routes'));-- app.use('/api/powerchat', require('./integrations/powerchat-routes'));-- // Game & Canvas — migrated to openvibe.games--@@ -695,9 +704,9 @@ app.use('/api/media', require('./media/routes'));-- app.use('/api/img-proxy', require('./media/external-image-proxy'));-- app.use('/api/vibe-coding', vibeCodingRoutes);-- const ttsRoutes = require('./chat/tts-routes');---app.use('/api/tts', ttsRoutes);--+app.use('/api/tts', chatRemote ? chatMoved : ttsRoutes);-- const dmRoutes = require('./chat/dm-routes');---app.use('/api/dm', dmRoutes);--+app.use('/api/dm', chatRemote ? chatMoved : dmRoutes);-- const analyticsRoutes = require('./streaming/analytics-routes');-- app.use('/api/analytics', analyticsRoutes);-- const newsRoutes = require('./news/news-routes');--@@ -1081,6 +1090,8 @@ async function start() {-- // Initialize DM tables-- const dm = require('./chat/dm');-- dm.ensureTables();--+ // Back from CHAT_AUTHORITY=chat (rollback): chat writes OpenVibe.Chat never acknowledged land here.--+ if (!require('./chat/chat-authority').isRemote()) require('./chat/chat-remote').drainToLocal();-- // Migrate: add last_heartbeat column if missing-- try { db.run("ALTER TABLE streams ADD COLUMN last_heartbeat DATETIME"); console.log('[DB] Added last_heartbeat column'); } catch { /* already exists */ }-- // Migrate: add theme_id to users table if missing--diff --git a/server/internal/routes.js b/server/internal/routes.js--index 540fcb2..d962f81 100644--+++ b/server/internal/routes.js--@@ -17,6 +17,11 @@ function requireInternalKey(req, res, next) {---- router.use(requireInternalKey);----+// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.--+function notifyChat(userId) {--+ try { const cs = require('../chat/chat-server'); if (cs.remote) cs.userChanged(userId); } catch { /* non-critical */ }--+}--+-- // ── Site copy for the shared footer (OpenVibe.Network → here, once a day) ─────────────-- // The Network sends facts about each site plus an allow-list of link ids; the model writes a-- // short blurb per site and PICKS ids from that list. It never returns URLs or markup, so nothing--@@ -81,6 +86,7 @@ router.post('/user-avatar', (req, res) => {-- if (!user && username) user = db.getUserByUsername(username);-- if (!user) return res.status(404).json({ ok: false, error: 'user not found' });-- if ((user.avatar_url || null) !== url) db.updateUserAvatar(user.id, url, null);--+ notifyChat(user.id);-- return res.json({ ok: true, id: user.id, changed: (user.avatar_url || null) !== url });-- } catch (err) {-- console.error('[Internal] user-avatar error:', err.message);--@@ -107,6 +113,7 @@ router.post('/user-role', (req, res) => {-- // Never strip the owner's admin role via a role push (is_owner is local).-- const finalRole = (user.is_owner && role !== 'admin') ? 'admin' : role;-- db.getDb().prepare('UPDATE users SET role = ? WHERE id = ?').run(finalRole, user.id);--+ notifyChat(user.id);-- console.log(`[Internal] role push: ${user.username} -> ${finalRole}`);-- return res.json({ ok: true, id: user.id, username: user.username, role: finalRole });-- } catch (err) {--diff --git a/server/net/service-guard.js b/server/net/service-guard.js--new file mode 100644--index 0000000..e4b13d4--+++ b/server/net/service-guard.js--@@ -0,0 +1,45 @@--+'use strict';--+/**--+ * Live as a receiver of service tokens (roadmap Wave 6).--+ *--+ * guard(capability) checks a Network client-credentials token (RS256, contract--+ * identity.service-token-claims@1) for audience openvibe.live on Live's own internal routes —--+ * today the ones OpenVibe.Chat calls (/internal/chat-context/*, /internal/chat-effects/*). The--+ * token is verified with the same Network key Live verifies user JWTs with.--+ *--+ * Chat's capabilities (live.chat_context.read, live.chat_effects.write, live.chat_mirror.write)--+ * are proposals not yet in the openvibe-contracts registry, whose requireCapability() refuses--+ * unknown ids; until they are registered the grant is checked with capabilities.grants() and the--+ * guard switches to capabilities.check() by itself once the registry knows the id. Like--+ * X-Internal-Key routes, these are loopback-only: anything that came through nginx is refused.--+ */--+const { serviceAuth, capabilities, http } = require('openvibe-contracts');--+const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');--+--+const AUDIENCE = 'openvibe.live';--+--+function viaProxy(req) {--+ return !!(req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']);--+}--+--+function guard(capability) {--+ const registered = !!capabilities.get(capability);--+ return function liveServiceGuard(req, res, next) {--+ const ctx = http.requestContext(req.headers);--+ if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });--+ const auth = String(req.headers.authorization || '');--+ if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });--+ const publicKey = getNetworkPublicKey();--+ if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });--+ const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: getNetworkIssuer(), audience: AUDIENCE });--+ if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });--+ const c = registered--+ ? capabilities.check(r.claims, capability)--+ : (capabilities.grants(r.claims.cap, capability) ? { allowed: true } : { allowed: false, code: 'capability.denied', reason: `${capability} not granted` });--+ if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });--+ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };--+ next();--+ };--+}--+--+module.exports = { guard, AUDIENCE };--diff --git a/test/chat-context.test.js b/test/chat-context.test.js--new file mode 100644--index 0000000..1d935cc--+++ b/test/chat-context.test.js--@@ -0,0 +1,283 @@--+'use strict';--+--+// Chat moved to OpenVibe.Chat (roadmap Wave 6). What Live answers it on /internal/chat-context/*--+// and /internal/chat-effects/* (service tokens, capabilities, re-checked moderators, the read--+// mirror), and the chat-server proxy Live's own modules get with CHAT_AUTHORITY=chat (ordered--+// bridge calls, placeholder ids for forwarded inserts, presence reads). Against stub Network and--+// Chat servers.--+--+const assert = require('assert');--+const fs = require('fs');--+const os = require('os');--+const path = require('path');--+const http = require('http');--+const crypto = require('crypto');--+const jwt = require('jsonwebtoken');--+const { serviceAuth } = require('openvibe-contracts');--+--+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chatctx-'));--+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });--+fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);--+fs.mkdirSync(path.join(tmp, 'sounds'));--+process.env.DB_PATH = path.join(tmp, 'live.db');--+process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');--+process.env.OV_NETWORK_URL = 'https://openvibe.network';--+process.env.OV_OAUTH_CLIENT_ID = 'live';--+process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';--+process.env.SOUNDS_PATH = path.join(tmp, 'sounds');--+process.env.CHAT_AUTHORITY = 'chat';--+const quiet = console.log;--+console.log = () => {};--+console.warn = () => {};--+--+const ISS = 'https://openvibe.network';--+const now = () => Math.floor(Date.now() / 1000);--+let jti = 0;--+function serviceToken(cap, { aud = 'openvibe.live', sub = 'svc:chat' } = {}) {--+ return serviceAuth.signServiceToken({ iss: ISS, sub, actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey);--+}--+const READ = serviceToken(['live.chat_context.read']);--+const WRITE = serviceToken(['live.chat_effects.write']);--+const MIRROR = serviceToken(['live.chat_mirror.write']);--+--+// Stub Network (Live's own service token for audience openvibe.chat) and stub Chat (the bridge).--+const bridgeCalls = [];--+const network = http.createServer((req, res) => {--+ let raw = '';--+ req.on('data', (c) => { raw += c; });--+ req.on('end', () => {--+ res.setHeader('Content-Type', 'application/json');--+ if (req.url === '/oauth/token') {--+ const f = new URLSearchParams(raw);--+ return res.end(JSON.stringify({ access_token: serviceToken(['chat.live_bridge.write', 'chat.presence.read'], { aud: f.get('audience'), sub: 'svc:live' }), expires_in: 300 }));--+ }--+ res.statusCode = 404; res.end('{}');--+ });--+});--+const chat = http.createServer((req, res) => {--+ let raw = '';--+ req.on('data', (c) => { raw += c; });--+ req.on('end', () => {--+ res.setHeader('Content-Type', 'application/json');--+ assert.ok(String(req.headers.authorization || '').startsWith('Bearer '), 'Live calls Chat with a service token');--+ if (req.url === '/internal/live/calls') {--+ const body = JSON.parse(raw);--+ bridgeCalls.push(body);--+ return res.end(JSON.stringify({ ok: true, results: body.ops.map((o) => ({ seq: o.seq, ok: true })) }));--+ }--+ if (req.url === '/internal/live/presence') {--+ return res.end(JSON.stringify({ total: 7, streams: { 1: 3 }, slow_mode: { 1: 5000 }, users: [{ user_id: 3, ip: '198.51.100.3', stream_id: 1 }], anons: [{ anon_id: 'anon9', ip: '203.0.113.9', stream_id: 1 }] }));--+ }--+ res.statusCode = 404; res.end('{}');--+ });--+});--+--+const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));--+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));--+--+(async () => {--+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;--+ process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${await listen(chat)}`;--+--+ const db = require('../server/db/database');--+ db.initDb();--+ require('../server/chat/dm').ensureTables();--+ const d = db.getDb();--+ const mkUser = (username, role = 'user', extra = {}) => {--+ const id = db.createUser({ username, email: `${username}@example.test`, password_hash: '!x', display_name: username.toUpperCase(), stream_key: `key-${username}` }).lastInsertRowid;--+ d.prepare('UPDATE users SET role = ?, is_owner = ? WHERE id = ?').run(role, extra.owner ? 1 : 0, id);--+ return Number(id);--+ };--+ const owner = mkUser('owner', 'admin', { owner: true });--+ const admin = mkUser('admin2', 'admin');--+ const streamer = mkUser('streamer', 'streamer');--+ const mod = mkUser('moddy');--+ const viewer = mkUser('viewer');--+ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '501', 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ')").run(viewer);--+ db.createChannel({ user_id: streamer, title: 'Streamer TV' });--+ const channel = db.getChannelByUserId(streamer);--+ const streamId = Number(db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' }).lastInsertRowid);--+ db.addChannelModerator ? db.addChannelModerator(channel.id, mod, streamer) : d.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(channel.id, mod, streamer);--+ d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)').run(viewer, streamer);--+ db.setSetting('tts_enabled', 'true');--+ db.setSetting('stripe_secret_key', 'sk_live_never_shared');--+--+ const express = require('express');--+ const routes = require('../server/chat/live-context-routes');--+ const app = express();--+ app.use(express.json());--+ app.use('/internal/chat-context', routes.contextRouter);--+ app.use('/internal/chat-effects', routes.effectsRouter);--+ const port = await listen(http.createServer(app));--+ const call = async (method, p, { token, body, headers = {} } = {}) => {--+ const res = await fetch(`http://127.0.0.1:${port}${p}`, {--+ method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers },--+ body: body ? JSON.stringify(body) : undefined,--+ });--+ const text = await res.text();--+ let json = null; try { json = JSON.parse(text); } catch { /* */ }--+ return { status: res.status, body: json, text };--+ };--+--+ let exit = 0;--+ try {--+ // 1. Service tokens only: none, wrong audience, missing capability, through nginx.--+ assert.strictEqual((await call('GET', '/internal/chat-context/users')).status, 401);--+ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: serviceToken(['live.chat_context.read'], { aud: 'openvibe.media' }) })).status, 401);--+ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: WRITE })).status, 403);--+ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: READ, headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');--+ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: READ, body: { user_id: viewer, color: '#ff00ff' } })).status, 403, 'reads cannot write');--+--+ // 2. Users projection: paged, with the Network subject, never secrets.--+ const users = (await call('GET', '/internal/chat-context/users?after_id=0&limit=2', { token: READ })).body.rows;--+ assert.strictEqual(users.length, 2);--+ const all = (await call('GET', `/internal/chat-context/users?after_id=${users[1].id}`, { token: READ })).body.rows;--+ const v = all.find((u) => u.id === viewer);--+ assert.strictEqual(v.subject_id, 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ');--+ for (const u of users.concat(all)) { assert.ok(!('email' in u) && !('password_hash' in u) && !('stream_key' in u), 'no secrets in the projection'); }--+--+ // 3. Token resolution follows Live's rules and carries the subject.--+ const userJwt = jwt.sign({ sub: '501', username: 'viewer', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 600 });--+ const auth = (await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: userJwt } })).body;--+ assert.strictEqual(auth.user.id, viewer);--+ assert.strictEqual(auth.user.subject_id, 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ');--+ assert.strictEqual(auth.user.auth_source, 'network');--+ assert.ok(auth.expires_at);--+ assert.deepStrictEqual((await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: 'garbage' } })).body, { user: null, reason: 'invalid' });--+--+ // 4. Streams, channel policy (settings + moderators + language), follows, bans with version.--+ const s = (await call('GET', `/internal/chat-context/streams/${streamId}`, { token: READ })).body;--+ assert.strictEqual(s.stream.user_id, streamer);--+ assert.strictEqual(s.owner.username, 'streamer');--+ assert.strictEqual(s.channel.id, channel.id);--+ assert.ok(!('stream_key' in s.stream));--+ const active = (await call('GET', '/internal/chat-context/streams/active', { token: READ })).body.rows;--+ assert.ok(active.some((r) => r.id === streamId && r.is_live === 1));--+ const policy = (await call('GET', `/internal/chat-context/channels/${channel.id}/policy`, { token: READ })).body;--+ assert.deepStrictEqual(policy.moderator_ids, [mod]);--+ assert.strictEqual(policy.settings.allow_anonymous, 1, 'defaults when the channel has no row');--+ assert.deepStrictEqual((await call('GET', `/internal/chat-context/users/${viewer}/follows`, { token: READ })).body.streamer_ids, [streamer]);--+ const b1 = (await call('GET', '/internal/chat-context/bans', { token: READ })).body;--+ assert.deepStrictEqual(b1.bans, []);--+ assert.strictEqual((await call('GET', `/internal/chat-context/bans?version=${encodeURIComponent(b1.version)}`, { token: READ })).body.unchanged, true);--+ const settings = (await call('GET', '/internal/chat-context/settings', { token: READ })).body.settings;--+ assert.strictEqual(settings.tts_enabled, db.getSetting('tts_enabled'), 'typed like Live\'s getSetting');--+ assert.ok(!('stripe_secret_key' in settings), 'only chat settings leave Live');--+--+ // 5. Effects need CHAT_AUTHORITY=chat.--+ process.env.CHAT_AUTHORITY = '';--+ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 409);--+ process.env.CHAT_AUTHORITY = 'chat';--+ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 200);--+ assert.strictEqual(db.getUserById(viewer).profile_color, '#ff00ff');--+--+ // 6. Bans: Live re-checks the moderator; the rows are exactly chat-server's.--+ const ban = (body) => call('POST', '/internal/chat-effects/ban', { token: WRITE, body });--+ assert.strictEqual((await ban({ action: 'ban', actor_user_id: viewer, moderation_stream_id: streamId, stream_id: streamId, user_id: mod })).status, 403, 'a viewer cannot ban');--+ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: owner })).status, 403, 'a channel mod cannot ban an admin');--+ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer, reason: 'Banned by moderator', banned_by: mod })).status, 200);--+ assert.ok(db.isUserBanned(viewer, streamId));--+ const b2 = (await call('GET', `/internal/chat-context/bans?version=${encodeURIComponent(b1.version)}`, { token: READ })).body;--+ assert.strictEqual(b2.bans.length, 1, 'the version moved with the table');--+ assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);--+ assert.ok(!db.isUserBanned(viewer, streamId));--+--+ // 7. TTS settings: admins; credentials only the owner.--+ const put = (actor, settingsBody) => call('POST', '/internal/chat-effects/site-settings', { token: WRITE, body: { actor_user_id: actor, settings: settingsBody } });--+ assert.strictEqual((await put(viewer, { tts_enabled: 'false' })).status, 403);--+ assert.strictEqual((await put(admin, { tts_max_length: 300, tts_google_api_key: 'admin-cannot' })).body.updated, 1);--+ assert.notStrictEqual(db.getSetting('tts_google_api_key'), 'admin-cannot', 'an admin cannot set a credential');--+ assert.strictEqual((await put(owner, { tts_google_api_key: 'owner-can' })).body.updated, 1);--+ assert.strictEqual(db.getSetting('tts_google_api_key'), 'owner-can');--+--+ // 8. Slow mode persistence and alert sounds: only for people who may.--+ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { slow_mode_seconds: 9 } } })).status, 403);--+ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { slow_mode_seconds: 9 } } })).status, 200);--+ assert.strictEqual(db.getChannelModerationSettings(channel.id).slow_mode_seconds, 9);--+ const snd = path.join(tmp, 'sounds', 'alert.mp3'); fs.writeFileSync(snd, 'x');--+ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, kind: 'donation', url: snd } })).status, 403, 'only the channel owner');--+ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');--+ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: snd } })).status, 200);--+--+ // 9. The read mirror: same ids, Chat-only columns ignored, Live-only columns kept.--+ const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { token: MIRROR, body: { changes } });--+ assert.strictEqual((await call('POST', '/internal/chat-effects/mirror', { token: WRITE, body: { changes: [] } })).status, 403, 'mirror has its own capability');--+ let m = await mirror([{ table: 'chat_messages', op: 'upsert', row: { id: 900001, stream_id: streamId, channel_user_id: streamer, user_id: viewer, username: 'VIEWER', message: 'hi from chat', message_type: 'chat', is_global: 0, is_deleted: 0, timestamp: '2026-09-22 10:00:00', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' } }]);--+ assert.strictEqual(m.body.applied, 1);--+ assert.strictEqual(db.getChatMessageById(900001).message, 'hi from chat');--+ // Columns only Live's copy has (media-proxy/asset-sync adds them at start).--+ for (const c of ['media_url TEXT', 'media_asset_id INTEGER']) { try { d.exec(`ALTER TABLE channel_sounds ADD COLUMN ${c}`); } catch { /* present */ } }--+ d.prepare("INSERT INTO channel_sounds (id, channel_owner_id, command, url, media_asset_id) VALUES (77, ?, 'honk', '/x.mp3', 555)").run(streamer);--+ m = await mirror([{ table: 'channel_sounds', op: 'upsert', row: { id: 77, channel_owner_id: streamer, command: 'honk2', url: '/x.mp3', created_by_subject_id: null } }, { table: 'chat_messages', op: 'delete', pk: { id: 900001 } }, { table: 'users', op: 'delete', pk: { id: viewer } }]);--+ assert.strictEqual(m.body.applied, 2);--+ assert.strictEqual(m.body.skipped.length, 1, 'only chat tables are mirrored');--+ const s77 = d.prepare('SELECT command, media_asset_id FROM channel_sounds WHERE id = 77').get();--+ assert.deepStrictEqual(s77, { command: 'honk2', media_asset_id: 555 });--+ assert.strictEqual(db.getChatMessageById(900001), undefined);--+ assert.ok(db.getUserById(viewer), 'users are never touched');--+--+ // 10. The proxy Live's modules get: ordered bridge calls, placeholders, presence.--+ const chatServer = require('../server/chat/chat-server');--+ assert.strictEqual(chatServer.remote, true);--+ chatServer.init();--+ const before = d.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n;--+ const saved = db.saveChatMessage({ stream_id: streamId, user_id: null, username: 'Bot', message: 'beep', message_type: 'chat', source_platform: 'ai' });--+ assert.ok(saved.lastInsertRowid <= -(2 ** 40), 'a placeholder id');--+ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, before, 'inserts happen in Chat, not here');--+ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 1, 'kept until Chat acknowledges');--+ chatServer.broadcastToStream(streamId, { type: 'chat', id: saved.lastInsertRowid, message: 'beep' });--+ chatServer.synthesizeAndBroadcastTTS(streamId, 'Bot', 'beep', null, null, 'aibot:bot', null, `m${saved.lastInsertRowid}`);--+ await chatServer.flush();--+ await sleep(100);--+ const ops = bridgeCalls.flatMap((c) => c.ops);--+ assert.deepStrictEqual(ops.map((o) => o.op), ['db', 'broadcastToStream', 'synthesizeAndBroadcastTTS']);--+ assert.strictEqual(ops[0].args[0], 'saveChatMessage');--+ assert.strictEqual(ops[0].ref, saved.lastInsertRowid);--+ assert.ok(/^live:\d+$/.test(ops[0].key), 'forwarded writes carry an idempotency key');--+ assert.strictEqual(ops[1].args[1].id, saved.lastInsertRowid);--+ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 0, 'acknowledged writes leave the outbox');--+ // Deletes run on the mirror at once (their ids are returned) and are forwarded too.--+ d.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (900002, ?, 'MODDY', 'x')").run(mod);--+ assert.deepStrictEqual(db.deleteUserChatMessages(mod, {}), [900002]);--+ await chatServer.flush();--+ assert.deepStrictEqual(bridgeCalls.at(-1).ops[0].args.slice(0, 2), ['deleteUserChatMessages', mod]);--+ // Live's own writes to data Chat caches (dashboard mods, IP approvals) tell Chat to reload it.--+ db.addChannelModerator(channel.id, viewer, streamer);--+ await chatServer.flush();--+ assert.deepStrictEqual(bridgeCalls.at(-1).ops.map((o) => [o.op, ...o.args]), [['invalidate', 'channel', channel.id]]);--+ assert.ok(d.prepare('SELECT 1 FROM channel_moderators WHERE channel_id = ? AND user_id = ?').get(channel.id, viewer), 'the write itself still happens in Live');--+ // The /api/mod global delete loops over chatServer.clients: one pseudo-socket reaches everyone.--+ for (const [ws] of chatServer.clients) { if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'delete-messages', ids: [1] })); }--+ await chatServer.flush();--+ assert.strictEqual(bridgeCalls.at(-1).ops[0].op, 'broadcastAllRaw');--+ // Synchronous reads come from Chat's presence snapshot.--+ await sleep(200);--+ assert.strictEqual(chatServer.getTotalConnections(), 7);--+ assert.strictEqual(chatServer.getStreamViewerCount(1), 3);--+ assert.strictEqual(chatServer.slowModeByStream.get(1), 5000);--+ assert.strictEqual(chatServer.getConnectedUserIp(3), '198.51.100.3');--+ assert.strictEqual(chatServer.findClientByAnonId('anon9', 1).ip, '203.0.113.9');--+ assert.strictEqual(chatServer.findClientByAnonId('anon9', 2), null);--+ // A /ws/chat upgrade that still lands on Live is refused, not served from the mirror.--+ let written = '';--+ chatServer.handleUpgrade({}, { write: (x) => { written += x; }, destroy() {} });--+ assert.match(written, /^HTTP\/1\.1 503/);--+ chatServer.close();--+--+ // Rollback: writes Chat never acknowledged are applied to Live's own tables.--+ d.prepare("INSERT INTO chat_bridge_outbox (boot, ref, op, args) VALUES ('old', -1, 'db', ?)").run(JSON.stringify(['recordFirstChat', 'user:77', streamer]));--+ chatServer._restoreDb();--+ assert.strictEqual(require('../server/chat/chat-remote').drainToLocal({ log() {}, warn() {} }), 1);--+ assert.ok(d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = 'user:77'").get());--+ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 0);--+--+ quiet('chat context (Live side of OpenVibe.Chat): all checks passed');--+ } catch (err) {--+ console.error(err);--+ exit = 1;--+ } finally {--+ try { db.close(); } catch { /* */ }--+ fs.rmSync(tmp, { recursive: true, force: true });--+ process.exit(exit);--+ }--+})();ddeleted file mode 100644@@@ -1,63 +0,0 @@--Repository: /home/workstation/OpenVibers/OpenVibe.Chat (work ONLY here). Never open .env files or anything holding secrets; never commit, push, deploy, or touch other repositories (read others freely). Use Node 22: prefix node/npm with `fnm exec --using=22.22.1`. Run every command in the foreground. Build fake keys in tests at runtime. Never change how any page looks. `rm` is denied: delete files with `git rm`. Migrations: new files under `migrations/`; never edit one another commit added. Work in commits-sized steps and keep `npm test` green after each; you do not commit — list the steps in your report. End with what you changed per step (file:line), the per-table import report format, the p95 numbers of the history page before/after, and the final summary lines of `npm test` and `npm run test:pg`.----# Plan T3 (last part): OpenVibe.Chat on PostgreSQL + Valkey----Read first: docs/pg-port/research.md (the verified research: every table, file,--sync call site, risk and step). It was written BEFORE T3 finished; the facts below override it.----## What changed since the brief (facts)--- T3 is done: Chat has been the only writer of the six tables since 2026-09-29 14:46 UTC; the staged machinery-- (stagedApply/stagedSlice/tableAuthority ops, sliceHash, applyStagedChanges, scripts/table-authority.js,-- docs/staged-tables-cutover.md) and `table_authority` are DELETED. Brief steps 8, 10 and 11 and risks 10 and the-- staged half of 3 no longer apply; open question 1 is moot.--- STILL LIVE: the C-02/C-03 bridge and the mirror of the TWELVE non-staged tables into Live's copies-- (`live_mirror_outbox`, `installMirrorTriggers()` TEMP triggers gated per connection, `scripts/mirror-flush.js`).--- Chat pins openvibe-sdk v0.23.1 today; the target is v0.25.2 (ships `openvibe-sdk/db`, `/testing`, `/limits`,-- `/valkey`, and `tools/asyncify` in the SDK repo at the tools/asyncify folder of github.com/OpenVibers/OpenVibe.SDK (clone it)).--- Patterns to copy (read-only): OpenVibe.Media `server/db/database.js` (openDb/initDb/getDb, PGlite dev fallback) and-- `migrations/0001_initial.sql` prelude (ov_ts, ov_now, datetime(), json_* compat); OpenVibe.Events/OpenVibe.Community-- test/helpers for the per-process migrated test database and `npm run test:pg`.----## Decisions (Opus; build exactly these)--1. Mirror capture: real PostgreSQL triggers (plpgsql, in the migration) on the twelve mirrored tables append to-- `live_mirror_outbox`, skipped when `current_setting('ov.mirror_skip', true) = '1'`; the bridge's apply path (Live's-- writes) runs `SET LOCAL ov.mirror_skip = '1'` inside its transaction so Live's changes never come back.-- `installMirrorTriggers()` and the TEMP-trigger code go. Test: "Live's changes never come back", "every Chat writer is-- captured", "pending 0 after a flush", on PGlite and PostgreSQL.--2. `chat_messages` page order is `id DESC` alone (ids are preserved by the import, so order is too); keep `timestamp`-- as SQLite-format text via `ov_now()` for display. Index to match; EXPLAIN in the latency test shows an index scan.--3. Audio queue: the claim is one atomic `UPDATE audio_requests SET state='playing', attempts=attempts+1 WHERE id=$1 AND-- state='queued' RETURNING *` (zero rows = someone else took it); the synth runs OUTSIDE any transaction; enqueue's-- dedupe is a unique index + `ON CONFLICT DO NOTHING` with the caps counted inside the transaction. Boot keeps today's-- handling of rows left 'playing'. Test: two concurrent pump() loops on real PostgreSQL → exactly one play per-- request.--4. SDK v0.25.2 first (step 0, its own step), with `pg`, `@electric-sql/pglite`, `iovalkey`.--5. Valkey backs the per-actor rate limiter (`openvibe-sdk/limits` + `createValkeyLimitStore`, `VALKEY_URL`/-- `VALKEY_PREFIX`, a /ready check that reports `skipped` when unset). The history-store memo stays in-process.--6. N-1: the engine-switch release cannot replay SQLite-dialect SQL. `test/n-1.test.js` prints one-- `skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys)` line for this release only-- (the ○ convention), and `npm run n-1:record` must work against PostgreSQL afterwards (record the previous-- release's statements from its PostgreSQL schema). Do not delete the harness.--7. Account export / subject merge / live-mirror dynamic SQL: dedicated static queries per table (no interpolated-- identifiers), ordered by the primary key where the SQLite code used rowid ("most recent N" = highest id).--8. `relay_users` gets a real identity column `id` (the answers keep their `id` field; `CHAT_TABLES.relay_users` keys-- by id).--9. Timestamps stay SQLite-format text (the compat prelude), like Media and Live's decision; booleans stay integers-- where the code compares with 0/1.--10. Production import: `scripts/import-sqlite-to-pg.js` wrapping `openvibe-sdk/db` `importSqlite` (per-table count +-- checksum report written to `import_runs`, sequences `setval` above the imported max), runnable with-- `--pglite <dir>` as a dry run. It is run later by Opus through Host's switch tooling; you only build and test it-- (a test imports a SQLite fixture into PGlite and checks the report and a post-import insert id).--11. Production requires `DATABASE_URL` (runtime, PgBouncer) and `DATABASE_DIRECT_URL` (owner, migrations); dev and-- `npm test` use embedded PGlite; `npm run test:pg` uses `eval "$(a local PostgreSQL 16 + Valkey (install with apt in this session if missing) up)"`.-- Last step: better-sqlite3 leaves `package.json` and nothing in `server/` requires it (a guard test).----## Steps (brief §9, renumbered): 0 SDK+drivers → 1 migrations scaffold (+ triggers of decision 1) → 2 open/init/get +--test preload → 3 dialect fixes + RETURNING helper → 4 asyncify (+ the MANUAL sites, + dynamic SQL per decision 7) →--5 history store (decision 2, latency test over 200k rows) → 6 audio queue (decision 3) → 7 mirror on triggers--(decision 1) → 8 Valkey limiter → 9 N-1 (decision 6) → 10 import script (decision 10) → 11 drop better-sqlite3.----Gate: `npm test` (PGlite) and `npm run test:pg` both green at the end; `test/live-down.test.js`, `test/bridge.test.js`,--`test/outbox-mirror.test.js`, `test/audio-queue.test.js`, `test/chat-apis.test.js`, `test/chat-ai.test.js` unchanged in--meaning. Update README.md (storage section), STATUS.json and docs/cutover.md (the PostgreSQL switch runbook: backup,--drain, import with the report, switch env, verify /ready and counts, rollback = previous release + the SQLite file).ddeleted file mode 100644@@@ -1,135 +0,0 @@--# T3 — OpenVibe.Chat on PostgreSQL + Valkey (attempt 3)----Worked only in /home/workstation/OpenVibers/OpenVibe.Chat, Node 22 (`fnm exec --using=22.22.1`), every command in the--foreground, no commits, no `rm`. Nothing on a page was touched (CSS/markup/copy), so there is no "For Opus" item.----## What this attempt did----Attempt 3 finished the one remaining step that is independent of the engine move (step 9) and verified the whole tree.--It did **not** start the engine move (steps 2–4): that change is atomic — the moment `server/db/database.js` hands back--Promises, every one of ~467 db-op call sites in 40 server files and ~40 test files breaks at once, and the reading is--that a half-converted tree is worse than a consistent one. So the tree is left green and consistent, with a precise--statement of what remains.----### Step 9 — N-1 (decision 6): DONE--- `test/n-1.test.js:37-48` — added a guard that prints exactly one skip line and exits 0, for this release only:-- `n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys)`.-- Verified against `openvibe-shared/test-runner`'s `SKIP_RE`, so the file is listed with ○ and **not** counted as-- passed (no fake green). The harness (`test/n-1/harness.js`) and the fixtures are kept, as required.--- The second half of decision 6 — `npm run n-1:record` recording the previous release's statements from its PostgreSQL-- schema — is **blocked on the engine move**: the recorder reads the release's schema, which is still SQLite until-- step 2 lands. `scripts/n-1-record.js` is unchanged and still works against the current (SQLite) release.----### Verified (not changed) — the work a1/a2 left in the tree--- `package.json:11` `test:pg`, `package.json:34` openvibe-sdk v0.25.2 (pg/@electric-sql/pglite/iovalkey present).--- `migrations/0001_initial.sql` (738 lines): 41 tables, 42 indexes, the SQLite-compat prelude-- (`ov_ts/ov_now/ov_now_iso/datetime/julianday/json_*/instr`), `relay_users` identity `id`, and 12 real PostgreSQL-- mirror triggers on the twelve Live-read tables, each gated on `current_setting('ov.mirror_skip', true) = '1'`.--- `server/config.js:47-56` `db.{url,directUrl}` and `valkey.{url,prefix}`.--- Step 5 (decision 2): `server/chat/history-store.js:103` `ORDER BY cm.id DESC` alone; `:41/:55/:170` portable-- `datetime('now')`; `server/db/schema.sql:53` partial index `idx_chat_page_live (id DESC) WHERE is_deleted = 0`;-- `test/history-latency.test.js`.--- Step 8 (decision 5): `server/net/actor-limits.js:30,87-94` lazy limiter behind `createValkeyLimitStore`;-- `server/index.js:38` `limits.useValkey(valkey)` at boot; `server/app.js:146` optional `/ready` valkey check;-- `test/actor-limits.test.js` shared-budget case (runs on PG+Valkey, skips without `VALKEY_URL`).--- Step 10 (decision 10): `scripts/import-sqlite-to-pg.js` (84 lines).--- Docs: `README.md` storage section, `STATUS.json` runtime line, `docs/cutover.md` PostgreSQL switch runbook.----## Per-table import report format (decision 10 / step 10)----`node scripts/import-sqlite-to-pg.js --sqlite <file> (--url <pg-url> | --pglite <dir>) [--truncate] [--no-verify]`.--It wraps `openvibe-sdk/db` `importSqlite`, which returns--`{ ok, tables: [{ table, source, rows, ms, checksum }], problems: [] }`; `scripts/import-sqlite-to-pg.js:63-67` prints--one line per table, then the tail, then records the run in `import_runs`:---- [import] chat_messages 12345 rows checksum 9f3c… (16 hex) (42 ms)-- [import] dm_messages 678 rows checksum 1a0b… (7 ms)-- …-- [import] PROBLEM <table>: <problem> # only when a table fails to settle-- [import] 41 tables, 123456 rows, ok # or FAILED, which exits 1----Per table: `table` (name), `source` (SQLite table it came from), `rows` (count), `ms` (insert time), `checksum`--(16 hex chars — row count + a canonical-text hash of every row walked in primary-key order, computed on both sides and--compared; `--no-verify` omits it). `problems[]` entries carry `{ table, problem }`. The session runs with--`SET ov.mirror_skip = '1'` (imported rows never queue into Live's mirror), then `ANALYZE` as the owner, then--`SET ov.mirror_skip = '0'`; identity sequences are `setval`'d past the imported max. Covered by--`test/import-pg.test.js` (report, rows, empty mirror queue, post-import id past the max).----## History page p95 — before / after (decision 2 / step 5)----Same query (the global page of `history-store.js`), 100 reps, `LIMIT 60`, owner-side `ANALYZE`, measured on--PGlite 0.5.8 and on the real PostgreSQL 18 + PgBouncer containers:----| rows | store | BEFORE `ORDER BY timestamp DESC, id DESC` | AFTER `ORDER BY id DESC` | AFTER plan |--| --- | --- | --- | --- | --- |--| 60 000 | PGlite | 12.92 ms | **9.61 ms** | Limit → Index Scan, no Sort |--| 60 000 | PostgreSQL | 7.54 ms | **6.36 ms** | Index Scan Backward, no Sort (BEFORE: Incremental Sort over `idx_chat_timestamp`) |--| 200 000 | PGlite | 15.98 ms | **13.50 ms** | Limit → Index Scan, no Sort |--| 200 000 | PostgreSQL | 9.49 ms | **9.44 ms** | Index Scan Backward using `chat_messages_pkey`, no Sort |----The real requirement is the plan, not the wall clock: AFTER never has a Sort (the ordering comes straight from the--index), BEFORE does. The large a2 number — and why the import runs `ANALYZE` — is confirmed here: when the runtime--role's `ANALYZE` is (silently) a no-op, there are no column statistics and the AFTER page query degrades to--bitmap scan + Sort (**≈ 290 ms at 60 000 rows**, measured), versus **≈ 6 ms** with statistics. The runtime role is--DML-only, so `ANALYZE` must run as the owner (importer, autovacuum).----## Final gate----`npm test` (PGlite):---- 44/45 test files passed, 1 skipped (n-1.test.js: n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys))----`npm run test:pg` (containers up: `eval "$(OpenVibe.SDK/scripts/test-services.sh up)"`):---- 44/45 test files passed, 1 skipped (n-1.test.js: n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys))----Caveat, stated plainly: until the engine move lands, the app itself still serves from SQLite, so `test:pg` exercises--PostgreSQL only through the PG-backed tests (`history-latency`, `actor-limits` on Valkey, `pg-schema`, `import-pg`) and--runs the rest of the suite on SQLite. It is green, but it is not yet the production shape.----## Steps — status----| Step | What | Status |--| --- | --- | --- |--| 0 | SDK v0.25.2 + `pg`/`pglite`/`iovalkey` | done (a1) |--| 1 | `migrations/0001_initial.sql` + mirror triggers | done (a1) |--| 2 | `openDb/initDb/getDb` over `openvibe-sdk/db` + `test/helpers/pg-preload.mjs` | **not started** |--| 3 | dialect fixes + `RETURNING` helper | **not started** |--| 4 | `tools/asyncify` + the manual sites + dynamic SQL per table | **not started** |--| 5 | history store `id DESC` + index + latency test | done (a2) |--| 6 | audio queue atomic claim | **not started** (needs 2–4) |--| 7 | drop `installMirrorTriggers`/TEMP triggers, `SET LOCAL ov.mirror_skip` on the bridge path | **not started** (needs 2–4) |--| 8 | Valkey limiter | done (a1) |--| 9 | N-1 skip line | done (a3); the `n-1:record` PG path needs 2–4 |--| 10 | import script + report | done (a1) |--| 11 | drop better-sqlite3 (+ guard test) | **not started** (needs 2–4) |----## What remains, concretely (the atomic engine move)----One change-sized step, all-or-nothing, no commit in between:--1. Rewrite `server/db/database.js` (1492 lines, ~140 async exports) on `createDb({ url, pglite })`: `openDb/initDb/getDb`,-- `db.tx` for `transaction`, `db.prepare` for `stmt`, ambient transactions so callers need not thread a handle; keep-- `CHAT_TABLES`; delete `installMirrorTriggers()`/the TEMP-trigger block and `initDb({ captureMirror })` gating-- (decision 1/7 — the real triggers are already in the migration).--2. `npm test`: add `test/helpers/pg-preload.mjs` (copy `OpenVibe.Media/test/helpers/pg-preload.mjs`, `service:'chat'`)-- and load it with `--import` from `test/run.js` (`store: process.env.OV_TEST_STORE || 'pglite'`).--3. Convert every caller to `await` + `async` (~467 sites in 40 server files — `calls/call-server.js` 53, `rooms/rooms.js`-- 46, `live-context.js` 43, `chat/dm.js` 35, `chat/routes.js` 24, `chat/chat-server.js`/`chat/audio-queue.js`/-- `chat/account-data.js` 23 each, …; `test/helpers.js` and ~40 test files), replacing `CURRENT_TIMESTAMP` value sites-- with `ov_now()`/`datetime('now')`, and the account-export / subject-merge / live-mirror dynamic SQL with static-- statements per table ordered by the primary key (decision 7).--4. Then, on the converted tree: audio atomic claim (decision 3), mirror `SET LOCAL ov.mirror_skip` on the bridge apply-- path, `n-1:record` from the PostgreSQL schema, and dropping `better-sqlite3` (decision 11, with a guard test that-- nothing under `server/` requires it).----## For Opus----Nothing — no page, styling, copy or icon change was needed by this attempt.------Report written to `/home/workstation/openvibe/agents/jobs/t3-chat-pg.out.a3.report.md`.----This attempt finished step 9 (the N-1 skip line in `test/n-1.test.js:37-48`), verified the whole tree, and left it green: `npm test` and `npm run test:pg` both read `44/45 test files passed, 1 skipped (n-1.test.js: …engine switch to PostgreSQL…)`. I measured the history-page p95 before/after (60k and 200k, PGlite and PostgreSQL): roughly 13→9.6 ms and 16→13.5 ms on PGlite, 7.5→6.4 ms (60k) and 9.5→9.4 ms (200k) on PG — the real win is no Sort node, and the plan collapses to ~290 ms when the owner `ANALYZE` is skipped.----I deliberately did not start the engine move (steps 2–4): it is atomic across ~467 call sites in 80 files, and a half-converted tree would be worse. Steps 6, 7, 11 and the N-1 `record` PG path all wait on it; the report lists exactly how to do it. No UI changes needed. (The SDK's shared test containers are left up.)\\ No newline at end of fileddeleted file mode 100644@@@ -1,138 +0,0 @@--# [cloud] t3-chat-pg-engine: Chat serves from PostgreSQL (openvibe-sdk/db; PGlite for dev and npm test)----Plan T3, last step: the engine move (brief steps 2–4 and what follows from them). One coherent change on top of--`83497e7` (a1–a3: SDK v0.25.2, `migrations/0001_initial.sql`, the import script, the Valkey limiter, the N-1 skip).----## What I built----**Database layer** — `server/db/database.js` on `openvibe-sdk/db`:--- `openDb/initDb/getDb/setDb` in Media's shape: `DATABASE_URL` (runtime role through PgBouncer, `DATABASE_POOL_MAX`-- optional) with `migrations/` applied first as the owner on `DATABASE_DIRECT_URL`; outside production without-- `DATABASE_URL`, an embedded PGlite in `data/pglite` (`CHAT_PGLITE_DIR`). Production refuses to start without both URLs.--- `run/get/all/transaction` (and `tx`) are async with better-sqlite3's shapes: `run()` answers-- `{ changes, lastInsertRowid }` — an `INSERT` into a table with an `id` column gets `RETURNING id` appended — so-- no caller, bridge answer or HTTP body changes shape. `transaction(fn)` is `db.tx` with ambient transactions.--- `installMirrorTriggers()`, the TEMP-trigger code, `ADDED_COLUMNS`/`PRAGMA` patching and `initDb({ captureMirror })`-- are gone; the four runtime schema creators (`rooms`, `token_revocations`, `network_blocks`,-- `account_data_events`) are no-ops (their tables are in the migration). `server/db/schema.sql` is removed.--- Boot awaits `initDb()`; background loops that now await the database log a rejection instead of ending the process.----**Every call site async** — `tools/asyncify` of OpenVibe.SDK over `server/**`, `scripts/**`, `test/**` (config--with `asyncGlobals` for the helpers and `apis` for `db`/`ctx`/`chatServer`/…); a scratch patch to the tool taught it--class methods (`this.x()`), and the rest by hand: `db.transaction(…)` → `db.tx`, the MANUAL sites--(`.filter/.some` callbacks → loops or one `= ANY(?)` read), computed calls in the bridge (`db[fn]`, `chatServer[op]`),--a getter that became async (`slowModeByStream()`), factory instances (`mirror.pending()`, `eventsConsumer.apply()`),--and write loops the tool had turned into `Promise.all` put back in order. The Events inbox is the SDK's--`createPgInbox`. Two orderings that used to be one synchronous tick are kept: a call's session row ends before--anyone hears `call-ended`, and the channel leaves memory before the database is written.----**SQL dialect** — `CURRENT_TIMESTAMP` → `ov_now()` (text in SQLite's shape, so comparisons with stored text stay--right); `INSERT OR IGNORE/REPLACE` → `ON CONFLICT`; `LIKE` → `ILIKE`; `COLLATE NOCASE` → `lower()`; `IS ?` →--`IS NOT DISTINCT FROM`; `MAX(a, ?)` → `GREATEST`; `"window"` quoted; `datetime(?, 'unixepoch')` → `to_timestamp`;--`HAVING` on expressions and complete `GROUP BY`; `SUM` cast to bigint; derived-table aliases; qualified columns in--`ON CONFLICT DO UPDATE`; a safe anon-id cast; `ORDER BY id` where SQLite's rowid order was relied on.----**Decisions**--1. Mirror: the migration's triggers capture all twelve tables; `live-mirror.js` reads rows with one static statement-- per table; with `LIVE_MIRROR` off the queue is emptied on the relay's cadence (as when capture was off). The-- importers set `ov.mirror_skip`. The bridge's `stagedApply` apply path was deleted on main before this change, so-- no bridge op applies Live's own rows any more (see For Opus).--2. History: `id DESC` page (a2), and `deletedIds()` now windows by `id DESC` too.--3. Audio queue: `claim(id)` = `UPDATE … SET state='playing', attempts=attempts+1 WHERE id=? AND state='queued'-- RETURNING *`; the synth runs outside any transaction; skip/clear of a row still being made send no skip frame;-- enqueue: caps counted in the transaction, dedupe by the unique `(room, dedupe_key)` + `ON CONFLICT DO NOTHING`.-- Boot keeps today's handling of rows left `playing`. `test/audio-claim.test.js`: two pumps (two module instances,-- separate pool connections on PostgreSQL) over 40 rooms → every request played exactly once (PostgreSQL: pump a-- 29–33, pump b 7–11 across runs), one claim wins, concurrent enqueues of one key store one row.--5. Valkey limiter (a1) unchanged; `/ready` valkey check skips when unset.--6. N-1: `npm run n-1:record` records a release on PostgreSQL (booted on its own PGlite dir; its applied migrations-- with their text; its statements as PostgreSQL received them, hooked on `openvibe-sdk/db`; source literals-- compiled to `$n`); `test/n-1.test.js` skips while the fixture is from a SQLite release (the same line as a3) and-- replays a PostgreSQL fixture by itself (rebuilds N-1's database from its migrations, this release migrates and-- serves it, every N-1 statement `PREPARE`s, no INSERT misses a new required column). Verified locally: recorded-- from this commit → 319 statements, all checks pass; a migration dropping `relay_users.display_name` fails it. The-- committed fixture is still the SQLite one, so this release reports the skip.--7. Account export/erase, subject merge and the mirror are static statements per table (`= ANY(@param)`), export-- "most recent N" by primary key.--8. `relay_users.id` is a real identity; `CHAT_TABLES.relay_users` keys by `id`.--10. `scripts/import-sqlite-to-pg.js` reads the file with `node:sqlite`, applies `migrations/` first (also for-- `--url`), then imports, `ANALYZE`s and records the report in `import_runs`.--11. better-sqlite3 leaves `package.json` (the lock keeps it only as an optional peer of another package, never-- installed). One-time tools read SQLite through `scripts/lib/sqlite.js` (`node:sqlite`, better-sqlite3's shape).-- `test/no-sqlite.test.js` guards it. `scripts/import-from-live.js` (Live snapshot → Chat) is ported too: a-- savepoint per row, `setval` instead of `sqlite_sequence`, and a JSON copy of the tables it writes instead of-- `VACUUM INTO`.----**Migration changes** (`0001_initial.sql` is not on main yet, so it was still editable): identities--`GENERATED BY DEFAULT` (the bridge, the importers and tests keep ids); `moderation_actions.scope_id` and--`chat_messages.deleted_by` are `text` — SQLite's INTEGER columns held ids *and* names (a room slug, a purge's display--name), and an id reads back as a number wherever a row leaves Chat (`moderationRow`/`chatMessageRow`).----**Tests** — `test/helpers/pg-preload.mjs` (`node --import`, from `test/run.js`) gives every test process one migrated--database: an in-memory PGlite, or with `OV_TEST_STORE=pg` a schema and roles of its own through PgBouncer. A Chat or a--script the test spawns reaches the same database (`h.childDbEnv()`: the run's URLs, or this process's PGlite served--over the wire protocol by `@electric-sql/pglite-socket`, new dev dependency, with a one-connection pool). Tests that--opened SQLite files read through that handle; `ready.test.js` changes the schema as the owner. Docs: README storage--section, STATUS.json runtime, `docs/cutover.md` (PostgreSQL switch runbook refreshed, rollback flush command).----## How I tested it----PostgreSQL 16 (port 55432, superuser `ov`, database `ovtest`), PgBouncer 1.22 in transaction mode with `auth_query`--(56432) and Valkey 7.2 (56379), installed with apt in the session:---- export OV_TEST_PG_URL=postgres://ov:…@127.0.0.1:56432/ovtest-- export OV_TEST_PG_DIRECT_URL=postgres://ov:…@127.0.0.1:55432/ovtest-- export OV_TEST_VALKEY_URL=redis://127.0.0.1:56379/0---- npm test-- 45/47 test files passed, 2 skipped (message-deleted.test.js: message-deleted end-to-end: skipped (no OpenVibe.Events with redaction at /home/user/OpenVibe.Events) | n-1.test.js: n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys))---- npm run test:pg-- 45/47 test files passed, 2 skipped (message-deleted.test.js: message-deleted end-to-end: skipped (no OpenVibe.Events with redaction at /home/user/OpenVibe.Events) | n-1.test.js: n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys))---- N1_LIVE_REPO=<Live clone> npm run n-1:record # then node test/n-1.test.js on that fixture → "n-1: all checks passed" (fixture not committed)----Every test file was also run alone with every failing statement logged: the only SQL errors are the expected ones--(the import dry run's foreign-key holds, `ready.test.js`'s renamed table). The message-deleted end-to-end half skips--because there is no OpenVibe.Events checkout here (it skipped before this change too).----**History page p95, 200 000 rows** (the global page of `history-store.js`, `LIMIT 60`, 200 repetitions after 10--warm-up reads, `ANALYZE` as the owner; one chat message in 50 deleted):----| store | query | p50 | p95 | plan |--| --- | --- | --- | --- | --- |--| BEFORE: SQLite (main, better-sqlite3, in-process) | `ORDER BY timestamp DESC, id DESC` | 0.91 ms | **1.19 ms** | `SCAN cm USING INDEX idx_chat_ts_deleted` + temp B-tree for the ORDER BY |--| PostgreSQL 16 via PgBouncer | old order | 2.38 ms | 3.39 ms | Limit → Incremental Sort → Index Scan Backward `idx_chat_timestamp` |--| AFTER: PostgreSQL 16 via PgBouncer | `ORDER BY id DESC` | 1.99 ms | **2.74 ms** | Limit → Index Scan `idx_chat_page_live`, no Sort |--| PGlite (dev, `npm test`) | old order | 5.62 ms | 8.08 ms | Incremental Sort |--| AFTER: PGlite | `ORDER BY id DESC` | 4.88 ms | **7.11 ms** | Index Scan `idx_chat_page_live`, no Sort |----The move adds a network round trip (≈1.5 ms here, loopback) to an in-process read; the new order removes the Sort.--Readers see it at most once per 2 s per room (the page memo in front of it).----## For Opus----- **Push/PR access**: this session could not push (`OpenVibers/OpenVibe.Chat` is not in its authorized repository-- set; the git proxy refused a credential) and `codeload.github.com` is blocked by egress policy (npm installed the-- three GitHub-tag dependencies from git clones at the same tags; `package.json`/lock keep the codeload URLs). The-- branch is delivered as `t3-chat-pg.bundle` (see the session's message).--- **The cutover** (not attempted): `docs/cutover.md` § PostgreSQL switch — drain the mirror, stop, keep-- `chat.db.pre-pg`, `node scripts/import-sqlite-to-pg.js --sqlite /var/lib/openvibe-chat/chat.db --url-- "$DATABASE_DIRECT_URL"` (migrates, imports, verifies per table, `ANALYZE`, writes `import_runs`), check counts,-- set `DATABASE_URL`/`DATABASE_DIRECT_URL`/`VALKEY_URL`/`VALKEY_PREFIX` in `/etc/openvibe/chat.env` (roles from Host's-- `roles/data add-service.sh chat`; the runtime role needs `USAGE, SELECT, UPDATE` on sequences), start,-- `/ready`, then `npm run n-1:record` from the deployed commit and commit the fixture. Rollback: previous release on-- `chat.db.pre-pg` (loses Chat-only writes made after the switch: rooms, calls, audio queue).--- **Decision 1, the bridge half**: `SET LOCAL ov.mirror_skip` is on the importers only. The bridge no longer has an-- apply path for Live's own rows (`stagedApply` went with f7bf6a6); its `db` ops are Chat writes made for Live's-- current release, which the old per-connection TEMP triggers also captured, so they are still mirrored.--- **Decision 8 and Live's mirror**: mirror changes for `relay_users` now carry `pk: { id }`; upserts still carry-- `platform`/`username` in the row, and Chat never deletes `relay_users` rows, but Live's applier should key that-- table by `(platform, username)` (its copy has no `id`).--- **Two column types changed from the brief's migration** (`moderation_actions.scope_id`, `chat_messages.deleted_by`-- → text), because production rows hold names in them; identities are `BY DEFAULT`. Both need your nod since the-- migration becomes immutable once merged.--- **Erasure**: account deletion now erases the person's own chat-AI summaries/timeline (`scope='user'`, by Live id).-- On SQLite that statement compared a bigint to `usr_…` subjects and matched nothing.--- `/ready` and behaviour notes: a restart no longer resets per-actor limits when `VALKEY_URL` is set (a1); an-- unhandled rejection in a background loop is now logged instead of ending the process.ddeleted file mode 100644@@@ -1,342 +0,0 @@--# OpenVibe.Chat → PostgreSQL + Valkey, and taking over Live's six staged tables (plan track T3)----Read-only survey. Every claim is `file:line`. Nothing in the repository was edited.----The brief under review is `brief-chat-pg.out.a3.report.md` (249 lines, written by a weaker model). Everything below was re-derived from the code. Where the draft was right, it is kept; where it drifted, the corrected line number is given and the reason is in the Corrections table.----## Corrections (what the previous draft got wrong)----| # | Claim in draft | Verified truth |--|---|---|---|--| **The blocking one** | "The data layer to use: `openvibe-sdk/db` — `src/db/index.js`… `src/db/sqlite-import.js`… `tools/asyncify`…" | **Chat pins `openvibe-sdk` v0.12.0 (`package.json:32`), which has no `db` module, no `src/db/`, no `tools/`, no `src/testing/`.** `openvibe-sdk/db` first shipped in **v0.14.0** (SDK commit `1a93eeb`); ambient transactions (the draft's whole "no handle threading" argument) arrived in **v0.18.0** (SDK commit `4762731`). SDK HEAD is **v0.22.0** (`/home/workstation/OpenVibers/OpenVibe.SDK/package.json:3`), with `src/db/{index,migrate,prepare,sqlite-import,sql,sqlite-cli}.js`. **Raising the SDK pin is an unstated prerequisite.** |--| | (unstated) | **`pg`, `@electric-sql/pglite`, `iovalkey` are not dependencies of Chat** — not in `package.json:23-35`, not installed in `node_modules`. Media has them. The plan needs them added. |--| H3/H9 | `Media/server/db/database.js:22-49` (openDb) and `:22-63` (shape) | `openDb` **:28-46** (`:22-27` is its comment), `initDb` :49-53, `getDb` :56-61, `setDb` :64. |--| H3 | `Media/migrations/0001_initial.sql:1-33` and `:8-33` prelude; `datetime` `:16-22` | Prelude **:6-30**; `datetime` **:17-24**; `:1-5` are comments; `:32` starts `CREATE TABLE apps`. |--| H3 | `Media/test/helpers/pg-preload.mjs:1-25` | File is **20 lines**; one db :12, sequences→100000 :15-17, `globalThis.__ovMediaTestDb` :19. Env var is **`MEDIA_TEST_STORE`**, not `OV_TEST_STORE`. |--| H3/§6 | Media `test/helpers/db.js:18-40` (`createTestDb`, `createTestValkey`) | **No such file** — `OpenVibe.Media/test/helpers/` holds only `pg-preload.mjs` and `run-script.js`. `createTestDb`/`createTestValkey` live in **`openvibe-sdk/testing`** (`src/testing/db.js:26`, `:85`, exports `:91`), which the Media preload already requires at `:11`. |--| H3 | SDK `src/db/index.js:6-12`, `:20-24`, `:24-25` | Dialect/PgBouncer doc **:5-8**; ambient **:16-20** (right); `db.prepare` **:22-23**; pooler rule **:25-26**; `tx` retry doc :13, `RETRYABLE` :33, `ISOLATION` :32; `afterCommit` impl :204, handle :233. |--| H3 | `src/db/sqlite-import.js:1-16` | Verification + sequences at **:17-19** (range `:1-19`). |--| H3 | `src/db/sql.js:3-16` | Doc **:3-12**, class :15. |--| H3 | `Media/package.json:10` test:pg | **`:12`** (`:10` is `test`). |--| H3 | `tools/asyncify/README.md:1-50` covers `sqlite-schema-to-pg.js` | That tool is documented at **README `:56-58`**; sqlfix/asyncify order `:10-29`; dialect list `:34-35`; hand-work list `:38`; Blog order `:42-52`. |--| §1.1 | `config.js:39` dbPath | **`server/config.js:42`**. |--| §1.1 | `ADDED_COLUMNS` `:113-119` | **`:116-121`**. |--| §1.1 | `rooms.js:84-129` second owner | `let ready` :82, `ensureSchema` **:83**, `exec()` **:86-132**; rooms :87-101, room_members :102-109, room_messages :111-120, room_attachments :122-131; the `PRAGMA table_info`/ADD COLUMN tail :134-137. |--| §1.2 | `CHAT_TABLES` :28-41, `STAGED_TABLES` :47-54, `STAGED_KEYS` :57-64 | **`:25-38`, `:44-51`, `:53-60`**. |--| §1.2 | `token_revocations` "not in schema.sql" | Right that it is absent from `schema.sql`, but it **is created** on demand at **`server/auth/revocations.js:19`**. |--| §1.1/1.2 | "rooms.js is **a** second schema owner" + implicit `token_revocations` | **Four** modules create tables on first use: `rooms.js:86-132`, `revocations.js:19`, **`server/chat/network-blocks.js:28`** (`network_blocks` + `idx_network_blocks_blocked` :36), **`server/chat/account-data.js:39`** (`account_data_events`, DDL :38-46). **`network_blocks` and `account_data_events` are wholly absent from the draft's table inventory.** |--| §1.3 | `server/chat/rooms.js` | **Does not exist** — it is `server/rooms/rooms.js`. |--| §1.3 | `account-data.js:97` table_authority | **`:96`** (`chatWrites`). `:84` (`ORDER BY rowid DESC`) is right. |--| §1.3 | `database.js:17` "the one handle" | :17 is `require('better-sqlite3')`; the **open is `:66`** (`new Database(_dbPath)`), inside `getDb()` :62-77. |--| §1.2 | schema.sql table ranges | Several drifted: `chat_timeline_events` **296-305**; `table_authority` **308-312**; `ctx_sync` **368-372**; `events_outbox` **375-385**; `live_mirror_outbox` **389-395**; `bridge_applied` **399-403**; `bridge_refs` **436-442**; `chat_meta` **464-467**; `deploy_releases` **474-483**; `chat_event_inbox` **488-493**. (`chat_messages` 24-52, the `dm_*`/staged ranges, `calls` 501-518 and `idx_calls_open` :518 are right.) |--| §1.3 | `test/n-1/preload.js:16` | The require is **`:13`**. |--| §1.2 final note | "all 60+ tables in `server/db/schema.sql`" | **`schema.sql` is 518 lines with 35 `CREATE TABLE` and 39 `CREATE … INDEX` statements (37 plain + 2 unique).** 12 chat + 6 staged + 17 bookkeeping = 35 tables. With `rooms*` (4), `token_revocations`, `network_blocks`, `account_data_events`, Chat owns ~42 tables. |--| §2 table | rooms 44, dm 34 | **rooms 43** (16 `run`, 15 `get`, 8 `all`, 4 `db.transaction`), **dm 33** (11/12/8/1). The other 13 rows' counts check out. |--| §2 | Missing modules | **`server/chat/account-data.js` (23 `d.prepare` + 1 `d.exec` + 1 `d.transaction`), `server/chat/subject-merge.js` (15 `d.prepare` calls, 12 lines, + 1 `d.transaction`), and `server/prefs/from-live.js` (raw `liveDb.prepare` over `sqlite_master`) are not in the table.** Add them; the real conversion surface is **219 `prepare`/`exec`/helper sites across 20 files**, not ~170. |--| §2 | "`db.transaction(() => …)` callbacks at `database.js:213,916,932,1147,1154,1180`" | database.js uses a **local `transaction(fn)` helper (`:101-102`) at :210, 329, 348, 364, 380, 419, 440, 687, 910, 920, 932, 1044, 1059, 1076, 1084, 1094, 1103, 1111, 1126, 1153, 1177** — 21 callbacks. Also `deploy-notice:80`→**:86**; **`lifecycle.js:75` is not a transaction — lifecycle.js has no transaction at all.** (dm 168, rooms 202/320/357/407, audio-queue 187, live-context 218, live-bridge 217, outbox 68/101 are right.) |--| §2 | "15 are inside `transaction()`" | Not reproducible. **34 `transaction(() =>` callbacks** in `server/` (21 database.js + 4 rooms + 2 outbox + 1 each in dm, live-context, live-bridge, audio-queue, deploy-notice, account-data, subject-merge); 11 of them are `db.transaction(`. |--| §2 | live-context `COLLATE NOCASE` `:331-332` | **Only `:235-236`**; there is no COLLATE at 331-332. |--| §2 | live-context upsertRows :210-224; syncTable :736-748; syncActiveStreams :750-758; schedule :760-770 | **:212-225; :737-748; :749-758; :760-773.** |--| §2 | audio-queue pump :207-240; recover :311-316 | pump **:209-241**; recover **:311-319**; `perform` awaited :225, defined near :222. |--| §2 | deploy-notice claimRelease :78-90; `ORDER BY id DESC` :66 | tx at **:86** (function :84); the newest-row read is **:62**. |--| §2 | live-bridge `plain()` :198-201; array-map :186; setTableAuthority op :263-264 | plain **:199-202**; array-map **:190**; **`:263-264` is op `tableAuthority`, `setTableAuthority` is `:265-266`** (op list documented :32-36). |--| §2/§4 | database.js PRAGMA :129; `INSERT OR IGNORE` :1157; `INSERT OR REPLACE` :1192; mirrorPending :872-875; removeChannelModerator :919-928; setEmoteMedia end :1100; grantUserTag :1102-1108; addChatTimelineEvents end :1171; sliceHash end :1217; installMirrorTriggers :143-160; subjectFor :167-171 | **:128**; **:1159**; **:1188**; **:872-873**; **:918-927**; **:1092-1098**; **:1101-1107**; **:1150-1167**; **:1209-1212**; **:143-161**; **:167-170**. (database.js `:534`, `:1226`, `:878`, `:911`, `:896-897` and all of §4's chat-server refs are right.) |--| §2 | history-store 4 sites | `db.get` **:80**, `db.all` **:102, :110, :165** — right, but note `:80` is inside `tipId()`'s try/catch. |--| §3 | history-store ranges | parseRoom **:62-67**; `GLOBAL_TYPES` :26; GLOBAL_SELECT **:27-41**; CHANNEL_SELECT **:43-55**; page **:117-130**; delta **:137-146**; deletedIds **:156-170**; clampLimit **:57-60**; readDelta **:105-111**; readPage **:97-103**; tipId **:79-81**; visibility predicate **:40-41, :54-55, :168**; `PAGE_MEMO_MS` :23, `MAX_LIMIT` :24, memo **:85-94**, key :122; invalidate **:173-176**; `DELETED_WINDOW = 2*MAX_LIMIT` :155. Callers routes.js:516-517, 640, 660-661 and pages.js:146 (`limit: 60`) are **right**. |--| §5 | table_authority :308-315 | **:308-312**. buildChanges **:37-54**. applyStagedChanges end **:1203**, invalidation tail **:1196-1201**. `live-context` `reloadPolicy` is **:402** (called :891) — :354/:396/:889/:899 are `_chatWrites` guards, not `reloadPolicy`. `import-from-live.js --tables` is parsed at **:52-53** (not :19-31). |--| §5 | Live-side keys/endpoint | `chat_table_authority` **chat-tables.js:57**, `chat_table_dual_read` **:58**; **`chat_table_relay_paused` is `chat-tables-sync.js:36`** (not chat-tables.js). Handoff endpoint is Live **`server/internal/routes.js:29/43`** (`POST /internal/chat-tables/:table`). `write()` is **:137**. `chat_staged_outbox` DDL **chat-tables-sync.js:46-52**; `chat_dual_read_stats` DDL **:53-63**. (OPS map chat-tables.js:44-55, `authority()` :81 are right.) |--| §5/§7 | `docs/staged-tables-cutover.md` calendar `:78-95` | Table is **:76-87**; D0 2026-09-28, the flip order/dates (timeline-events 10-05, ai-summaries 10-06, user-tags 10-07, emotes 10-08, moderation-settings 10-09, moderators 10-12) and "not before 2026-12-18" all **right**; rollback **:273-284**. |--| §6 | `test/run.js:16` | **`:15`** (`{ dir: __dirname, timeoutMs: 60000, pad: 32, parallel: 1 }`); `--strict` documented :9. 40 test files — right. |--| §6 | staged-tables cases | File is 239 lines — right, but the cases sit at restart **:47-55**, refuse-at-live **:57-68**, idempotent **:70-97**, dual-read slice :99. |--| §6 | `message-deleted.test.js:23,141` is a better-sqlite3 check | **`:23`** only (`['server/index.js','server/redaction.js','node_modules/better-sqlite3'].every(fs.existsSync)` on the Events checkout). `:141` is a purge-preview assertion, not a driver check. |--| §6 | `app.js` table_authority | **:149** (`stagedAuthorities()`); `/ready` handler **:118-154**, `SELECT MAX(id)` :121 — right. |--| §6 | `helpers.js:338` "the live module" | **:338 is `h.db`** (`require('../server/db/database')`); the live module is **:339** (`h.ctx`). `:288` `CHAT_DB_PATH` and `:36` `sqliteNow()` are right. |--| §6 | "128 `h.db.*` across 26 files" | **137 `h.db.*` across 21 test files.** |--| §6 | `actor-limits.js:26-27` comment | **:21-22**. |--| §6 | history-store memo `:80-91` | **:85-94**; `PAGE_MEMO_MS` :23. |--| §6/§7 | "containers with `OV_TEST_STORE=pg`" | Media's preload reads **`MEDIA_TEST_STORE`** (`pg-preload.mjs:2,12`); the SDK helper defaults to `OV_TEST_STORE` (`openvibe-sdk/src/testing/db.js:26`) and needs `OV_TEST_PG_URL`/`OV_TEST_PG_DIRECT_URL` (`:23,33`). |--| §7 | `import-from-live.js:14-16` pre-write copy | **`:10-12`** (the doc comment). `server/index.js` `db.initDb()` is **:30**. `rooms.js` DDL :87-129 → **:86-132**. `docs/cutover.md` §Rollback **:174**. |--| §8 | risk 2 "`deploy-notice.js:76` `res.lastID \|\| res.id`" | The line is `Number(res && (res.lastInsertRowid \|\| res.lastID \|\| res.id))` — it checks **`lastInsertRowid` first**. `live-bridge.js` plain is **:199-202**, `database.js` `_staged` sniffer :896-897. |--| §8 | risk 5 "read-modify-write" for `attempts` | The attempts bump is **already atomic** — `UPDATE audio_requests SET attempts = attempts + 1 WHERE id = ?` at **audio-queue.js:221**. The race is only over the `state='queued'` claim. Risk 11: CI records the previous release into **`test/fixtures/n-1`** (`.github/workflows/ci.yml:7-8`), so the harness is fixture-based as well as `sqlite_master`-derived. |--| §9 | "`package.json:26` loses better-sqlite3" | **`:24`**. |--| §6 env note | "`npm test` fails 36/40 at `new Database`" | **Confirmed live:** `process.versions.modules` = **137** on node v24.19.0, against a `better_sqlite3.node` built for 127. Not a code defect; a `npm rebuild better-sqlite3` fixes it and installs are denied in this job. |--| (missed) | The draft's §1.3 never lists `server/chat/sounds-routes.js:285` | It reads `result.lastInsertRowid` from `createChannelSound` and puts it straight into the HTTP response. |--| (missed) | `scripts/parity.js` treated as SQLite-touching | `scripts/parity.js` (731 lines) is the **product-level** parity driver (real protocol, WebSocket) and touches **no** SQLite; only `scripts/parity-check.js:35-40` opens files. The draft's §1.3 list is right, but the distinction matters when repointing the CLIs. |------**Model service:** OpenVibe.Media — `server/db/database.js:28-46` (`openDb`: `DATABASE_URL` through PgBouncer, `DATABASE_DIRECT_URL` for migrations, embedded PGlite in dev), `migrations/0001_initial.sql:6-30` (SQLite-compat prelude `ov_ts`/`ov_now`/`ov_now_iso`/`datetime`/`julianday`/`json_valid`/`json_extract`/`json_type`/`instr`; `datetime` :17-24), `test/helpers/pg-preload.mjs:1-20` (one migrated PGlite per test process, identity sequences set to 100000), Valkey for shared counters: `server/config.js:32`, `server/actor-limits.js:58-62`, `server/observability.js:132`, and `openvibe-sdk/testing` `createTestDb`/`createTestValkey` (`src/testing/db.js:26,85`; the `store:'pg'` path at :33-80 with the runtime role's `statement_timeout = 15s` / `lock_timeout = 5s` at :49-50).----**The data layer to use — and the prerequisite the draft omitted:** `openvibe-sdk/db` (≥ **v0.14.0** for `createDb`/`sql`/`tx`/`importSqlite`; ≥ **v0.18.0** for ambient transactions; HEAD v0.22.0) — `src/db/index.js:5-8` (PostgreSQL only; PgBouncer transaction mode; `DATABASE_URL`), `:13,32-33,147` `tx(fn, opts)` with `isolation` and 40001/40P01 retry, `:16-20` ambient transactions via AsyncLocalStorage, `:22-23` `db.prepare(...)` better-sqlite3-shaped but async, `:204,:233` `db.afterCommit`; `src/db/sqlite-import.js:1-19` (`importSqlite`: parents first, batched multi-row inserts, identity columns keep their values and their sequences move past the max, verification by row count + a canonicalised per-row checksum walked in PK order, lost columns refused unless dropped); `src/db/sql.js:3-12` (`sql` tagged template; only `sql.raw` puts text into the statement). Toolchain (SDK repo, **not** the npm package — `tools/asyncify/README.md:3` says so): `tools/asyncify/README.md:56-58` (`sqlite-schema-to-pg.js`: `INTEGER PRIMARY KEY AUTOINCREMENT` → identity, `TEXT` → `text COLLATE "C"`, `INTEGER` → `bigint`, `REAL` → `double precision`, `BLOB` → `bytea`, no `IF NOT EXISTS`, `lower(x)` for `COLLATE NOCASE` indexes, an immutable-row trigger becomes PL/pgSQL, any other trigger refused), `:34-35` the dialect list (`INSERT OR IGNORE/REPLACE` → `ON CONFLICT`, `COLLATE NOCASE` → `lower()`, `LIKE` → `ILIKE`, `IS ?` → `IS NOT DISTINCT FROM`, `PRAGMA`/`sqlite_master` → `information_schema`, `RETURNING` for `lastInsertRowid`), `:38` the by-hand list (`db.transaction(fn)()` → `db.tx(fn)`, scalar `MAX(a,b)` → `GREATEST`, `rowid` tiebreaks → an identity column, `setImmediate` → `db.afterCommit`), `:10-29` `asyncify.js` (adds `await`, makes functions async, iterates to a fixed point, and **reports** `MANUAL` for array callbacks instead of changing them). **Chat must bump `package.json:32` and add `pg`/`@electric-sql/pglite`/`iovalkey` before any of this can be required.**------## 1. Every database, table and file----### 1.1 The database----One SQLite file. `server/config.js:42` `dbPath: process.env.CHAT_DB_PATH || './data/chat.db'`; production `deploy/systemd/openvibe-chat.service:38-39` (`StateDirectory=openvibe-chat`, `CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db`). Opened in `server/db/database.js:62-77` with `journal_mode=WAL` (:67), `foreign_keys=ON` (:68), `busy_timeout=5000` (:69), `synchronous=NORMAL`/`cache_size`/`temp_store` (:71-73), a prepared-statement cache (`stmt()` :79-88, cleared past 500 at :83), and helpers `run` :89, `get` :93, `all` :97, `transaction` :101-102, `close` :105-109.----`initDb()` (`server/db/database.js:123-141`) executes `server/db/schema.sql` verbatim on every boot (:125; every statement is `CREATE … IF NOT EXISTS`), patches four late columns (`ADDED_COLUMNS` **:116-121**: `emotes.media_url`, `emotes.media_asset_id`, `ctx_users.subject_id`, `channel_moderation_settings.sub_only`) with a `PRAGMA table_info` probe at :128, creates `idx_ctx_users_subject` (:132), seeds `table_authority` for the 12 chat tables (:133-134) and the six staged tables as `live` (:135-137), resets the 1 s authority cache (:138) and optionally installs the mirror TEMP triggers (:139, gated by `captureMirror`).----**There are four on-demand schema creators, not one.** Each does `db.getDb().exec()` of SQLite DDL on first use:----- `server/rooms/rooms.js:83-138` — `ensureSchema` (`let ready` :82) creates `rooms` :87-101, `room_members` :102-109 (+ `idx_room_members_user` :108), `room_messages` :111-120 (+ `idx_room_messages_room` :119), `room_attachments` :122-131; the additive-column tail at :134-137.--- `server/auth/revocations.js:17-…` — `token_revocations` (DDL :19).--- `server/chat/network-blocks.js:26-38` — `network_blocks` (:28) + `idx_network_blocks_blocked` (:36).--- `server/chat/account-data.js:38-46` — `account_data_events` (:39), whose `applied_at` default is `strftime('%Y-%m-%dT%H:%M:%fZ','now')` (:44).----On PostgreSQL every one of these must become a migration; `exec()` of SQLite DDL cannot run. **Three of the four are invisible in the draft.**----### 1.2 The tables (server/db/schema.sql)----**`server/db/schema.sql` is 518 lines: 35 `CREATE TABLE`, 39 `CREATE … INDEX` (37 plain, 2 unique).** Chat-owned (authority from the W6 cutover, ids kept from Live; `CHAT_TABLES` map at `server/db/database.js:25-38`):----| Table | schema.sql | key |--| --- | --- | --- |--| `chat_messages` | `:24-52` (8 indexes) | `id` |--| `dm_conversations` | `:54-61` | `id` |--| `dm_participants` | `:63-74` | `id` |--| `dm_messages` | `:76-86` | `id` |--| `dm_blocks` | `:88-97` | `id` |--| `tts_voice_overrides` | `:99-107` | `identity_key` |--| `channel_sounds` | `:109-126` | `id` |--| `relay_users` | `:128-136` | `(platform, username)` |--| `hidden_relay_users` | `:138-148` | `id` |--| `pending_ip_messages` | `:150-163` | `id` |--| `stream_first_chats` | `:165-171` | `(chatter_key, channel_user_id)` |--| `moderation_actions` | `:173-186` | `id` |----Staged copies of tables Live still writes (C-04; `STAGED_TABLES` `server/db/database.js:44-51`, `STAGED_KEYS` `:53-60`):----| Table | schema.sql | key |--| --- | --- | --- |--| `channel_moderators` | `:190-199` | `id` |--| `channel_moderation_settings` | `:201-246` | `channel_id` |--| `emotes` | `:248-266` (incl. `idx_emotes_channel_code`, unique on `COALESCE(channel_owner_id, user_id), code`, :266) | `id` |--| `user_tags` | `:268-276` | `id` |--| `chat_ai_summaries` | `:278-294` | `id` |--| `chat_timeline_events` | `:296-305` (unique `idx_chat_tl_dedup` on `(scope, subject_id, ts, label)`) | `id` |----Bookkeeping: `table_authority` `:308-312`, projections `ctx_users` `:317-332`, `ctx_streams` `:334-348`, `ctx_managed_streams` `:350-359`, `ctx_channels` `:361-366`, `ctx_sync` `:368-372`, `events_outbox` `:375-385`, `live_mirror_outbox` `:389-395`, `bridge_applied` `:399-403`, `audio_requests` `:409-434`, `bridge_refs` `:436-442`, `import_hold` `:445-453`, `import_runs` `:455-462`, `chat_meta` `:464-467`, `deploy_releases` `:474-483`, `chat_event_inbox` `:488-493`, `calls` `:501-518` (`idx_calls_open`, a partial index `WHERE state IN ('pending','ringing','active')`, :518).----**Not in schema.sql — 7 more tables Chat owns:** `rooms`, `room_members`, `room_messages`, `room_attachments` (`rooms.js:87-131`), `token_revocations` (`revocations.js:19`), `network_blocks` (`network-blocks.js:28`), `account_data_events` (`account-data.js:39`). **42 tables in all.**----### 1.3 Files that open SQLite directly (they break first)----- `server/db/database.js:17` (the require) / `:66` (the one service handle).--- `server/chat/account-data.js` — `cols()` does `d.prepare('PRAGMA table_info(...)')` at :34; `d.exec` DDL at :39; **23 raw `d.prepare(...)` sites**, among them `ORDER BY rowid DESC` at **:84** and a `table_authority` read at **:96**; one `d.transaction` at :109.--- `server/chat/subject-merge.js:28-70` — `d = db.getDb()` :29, **15 raw `d.prepare(...)` calls** (12 lines, incl. `PRAGMA table_info` :18, `UPDATE ${t} SET ${col}` :35, dynamic table names :47-49, `network_blocks` :68-70), one `d.transaction` :34. **Missing from the draft.**--- `server/prefs/from-live.js:47-54` — `liveDb.prepare` over `sqlite_master` and `PRAGMA table_info(linked_accounts)` (reads Live's file; a one-off migration tool, so it stays SQLite on purpose).--- `server/chat/audio-queue.js` uses only `db.run/get/transaction` (`:110, :120, :187, :197` via `require('../db/database')` :34) — it never calls `getDb()`.--- `server/live-context.js:419` uses the `sqliteNow()` helper (SQLite `YYYY-MM-DD HH:MM:SS` text), compared against stored text at :428.--- Everything that `require('better-sqlite3')` literally: `scripts/import-from-live.js:39`, `scripts/parity-check.js:37`, `scripts/migrate-chat-preferences.js:73`, `scripts/n-1-record.js:19`; `test/audio-queue.test.js:14,236`, `test/import.test.js:17,37,85+`, `test/migrate-preferences.test.js:18,45,75,110`, `test/n-1.test.js:29`, `test/n-1/preload.js:13`, `test/restart-resume.test.js:16,31`, `test/message-deleted.test.js:23` (the Events checkout's copy, not Chat's).--- `scripts/import-from-live.js` additionally reads `rowid` (:244-245) and `sqlite_sequence` (:279-281) and opens Live's file read-only at :105; `scripts/parity-check.js:35-40` opens both files. `scripts/parity.js` (731 lines) is the product-level driver and opens **no** SQLite.------## 2. Sync call sites by module (the asyncify surface)----Counted as `db.run(` / `db.get(` / `db.all(` / `db.transaction(` / `getDb().prepare(` / `d.prepare(` / `d.exec(`:----| Module | sites | notes |--| --- | --- | --- |--| `server/rooms/rooms.js` | 47 | own `exec()` DDL :86; `db.transaction` :202,320,357,407; `lastInsertRowid` :205,206,323,324,325; scalar `MAX(last_read_id, ?)` **:348** → `GREATEST` |--| `server/chat/dm.js` | 33 | `db.transaction` :168; `lastInsertRowid` :55,183,185,192; `CURRENT_TIMESTAMP` :116,174 |--| `server/chat/account-data.js` | 25 | 23 `d.prepare` + `d.exec` :39 + `d.transaction` :109 — **missing from the draft** |--| `server/live-context.js` | 22 | `upsertRows` :212-225 (prepared stmt :216, `db.transaction` :218); `syncTable` :737-748; `syncActiveStreams` :749-758; `getChannelModerationSettings`/`isChannelModerator` :365-378; `COLLATE NOCASE` :235-236; `SCHEDULE` :760-773 |--| `server/chat/audio-queue.js` | 21 | enqueue transaction :187-203, `pump` :209-241, `recover` :311-319 |--| `server/chat/subject-merge.js` | 13 | 12 lines of `d.prepare` (15 calls), `PRAGMA table_info` :18, one `d.transaction` :34 — **missing from the draft** |--| `server/calls/lifecycle.js` | 11 | state transitions :75,83-86,134-188 (`:56` is a read helper; **no transactions**) |--| `server/bridge/live-bridge.js` | 7 | idempotency transaction :217-223, `sweepRefs` :157-158, `rememberRef` :164, `lookupRef` :172 |--| `server/chat/deploy-notice.js` | 6 | `claimRelease` transaction :86; `lastInsertRowid` read at :76 (`res.lastInsertRowid \|\| res.lastID \|\| res.id`) |--| `server/events/outbox.js` | 6 | `markFailed` :66-69, `flush` :72-108 (`getDb().prepare`+`transaction` at :67/:68 and :100/:101) |--| `server/chat/network-blocks.js` | 5 | DDL :28, run :51, get :61, all :78 and :86 |--| `server/chat/history-store.js` | 4 | `db.get` :80, `db.all` :102, :110, :165 |--| `server/bridge/live-mirror.js` | 4 | `pending` :35, `buildChanges` :37-54, `flush` :56-100 |--| `server/events/consumer.js` | 3 | :102, :206 |--| `server/auth/revocations.js` | 3 | DDL :19, read :33, write :45 |--| `server/chat/routes.js` | 2 | :253, :613 |--| `server/auth/network-session.js` | 1 | :57 |--| `server/chat/chat-server.js` | 1 | `db.get` :778 (the rest are `db.*` domain functions, all of which become async) |--| `server/db/database.js` | 9 | its own `run/get/all/stmt/transaction` surface (:79-102) and 9 direct `prepare`/`exec` sites |----**Total ≈ 219 call sites across 20 files** (the draft said ~170 across 15 files). Of those, **41 lines read `lastInsertRowid` or `.changes`** — 27 `lastInsertRowid` lines and 17 `.changes` lines across 14 files (`rooms.js`, `live-bridge.js`, `audio-queue.js`, `chat-server.js`, `routes.js`, `deploy-notice.js`, `dm.js`, `sounds-routes.js`, `database.js`, `lifecycle.js`, `account-data.js`, `subject-merge.js`, `network-blocks.js`, `consumer.js`). The draft's "15 are inside `transaction()`" is wrong: there are **34 `transaction(() =>` callbacks** in `server/` (21 in database.js, 4 in rooms, 2 in outbox, 1 each in dm, live-context, live-bridge, audio-queue, deploy-notice, account-data, subject-merge), of which 11 are `db.transaction(`. Also note `server/chat/sounds-routes.js:285` — the only DB-adjacent read there — hands `result.lastInsertRowid` straight into the JSON response, so it is an id-shaped API contract, not just a local variable.----`openvibe-sdk/db` gives `db.prepare(sql)` with the same `.get/.all/.run` shape (`src/db/index.js:22-23`), so the prepared-statement cache in `database.js:79-88` and every `db.get(...)` call site keeps its SQL verbatim; only `await`, `RETURNING` and the dialect rewrites change. The ambient transaction (`src/db/index.js:16-20`) means the `db.transaction(…)` callbacks (rooms.js:202,320,357,407; dm.js:168; audio-queue.js:187; live-context.js:218; live-bridge.js:217; outbox.js:68,101) and the local `transaction(…)` callbacks at `database.js:210,329,348,364,380,419,440,687,910,920,932,1044,1059,1076,1084,1094,1103,1111,1126,1153,1177` (helper :101-102), plus `account-data.js:109` and `subject-merge.js:34`, all become `db.tx(async () => …)` and the inner calls need no handle.------## 3. The history store and its cursors----`server/chat/history-store.js` (178 lines):----- Rooms: `'global'`, `'channel:<userId>'` (`parseRoom` :62-67; `'stream:<id>'` only in `deletedIds` :156-170, regex :159). `MAX_LIMIT=500` :24, `PAGE_MEMO_MS=2000` :23, `DELETED_WINDOW = 2*MAX_LIMIT` :155.--- Two joined reads: `GLOBAL_SELECT` **:27-41** (five `ctx_*` LEFT JOINs, `message_type IN ${GLOBAL_TYPES}` :40 with the list at :26), `CHANNEL_SELECT` **:43-55**.--- Cursors: `page(room, {limit, before, channelUsername, decorate})` **:117-130** returns `{messages, latest_id}`; `delta(room, {afterId, limit, …})` **:137-146** returns `{messages, latest_id, complete, deleted_ids}`; `deletedIds(room, afterId)` **:156-170** returns ids at or under the cursor that are deleted or past auto-delete within `DELETED_WINDOW`. `clampLimit` **:57-60**, delta default 200 (:139).--- Reads use **the integer primary key as the cursor**: `readDelta` **:105-111** `WHERE cm.id > ? ORDER BY cm.id ASC LIMIT ?` with `limit+1`; `readPage` **:97-103** `ORDER BY cm.timestamp DESC, cm.id DESC` reversed. The page is a **timestamp cursor**, the delta a **PK range scan**. `tipId()` **:79-81** `SELECT MAX(id) FROM chat_messages` doubles as the memo key (in a try/catch, falling back to `Date.now()`).--- A 2 s page memo **:85-94** keyed on `room|limit|channel|tipId` (key built :122); `invalidate(room)` **:173-176**.--- Visibility predicate repeated in three places: `is_deleted = 0 AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)` (**:40-41**, **:54-55**, and negated with `COALESCE` at **:168**). `datetime()` is a SQLite function; Media's `migrations/0001_initial.sql:17-24` provides a PostgreSQL `datetime(text, modifier)` with the same semantics, so the predicate can stay verbatim.--- Callers: `server/chat/routes.js:516-517,640,660-661` (REST `global/history`, `:streamId/replay`, `channel/:userId/history`) and `server/web/pages.js:146` (the openvibe.chat first paint, `limit: 60`). The **WebSocket does not read history-store per message**; it reads the joined page/delta on join/reconnect only.----**Ordering contract to preserve:** `chat_messages.id` must stay **monotonic in insert order** (gap-free is not required, but the client splices by `id`). A PostgreSQL identity satisfies that; the danger is `ORDER BY timestamp, id` in `readPage` and in `deploy-notice.js:62` if timestamps tie. Second danger: two writers (Chat and, for the staged tables, Live via the bridge) can interleave ids — a single global sequence per table is required, not per writer.------## 4. WebSocket paths that touch the database per message (latency)----`server/chat/chat-server.js`, the `chat.message.send` path (all line refs verified correct):----1. `:772-800` IP-approval check: `db.get('SELECT 1 FROM chat_messages … JOIN ctx_streams …')` `:778-785` and, if the user has never chatted there, `db.holdMessageForApproval` `:792-800` — **one read, and a write, on every message from an unapproved IP in an ip_approval_mode channel**.--2. `:826-842` reply-to: `db.getChatMessageById(replyToId)` `:831`.--3. `:899` VIP badge: `db.subjectFor(client.channelUserId)`.--4. `:906-925` **the write**: `db.saveChatMessage` `:907` → `database.js:202-236` — one `transaction` (:210) doing the `INSERT` (:211-215), a `subjectFor` read at :209 (inside the call, before the tx) and `_outbox().enqueue` (`server/events/outbox.js:31-50`, a `contract validate()` at :46 plus an `events_outbox` INSERT at :48). The id comes back via `res.lastInsertRowid` (`database.js:220-222`).--5. `:944-953` welcome: `db.isFirstChatInChannel` `:949` + `db.recordFirstChat` `:950` — **two more statements per stream message**.--6. `:1531-1543` and `:2169-2181`: the same `saveChatMessage` for a channel sound and a soundboard clip, then `audioQueue.enqueue` (`audio-queue.js:183-204`, its own transaction with up to three reads and one insert).--7. Background: `db.deleteExpiredChatMessages(500)` `:2581` on a timer (`database.js:400-433`).----**Latency budget on PostgreSQL.** Today every one of these is an in-process synchronous SQLite call — sub-100 µs each, so a message costs one WAL commit. On PostgreSQL through PgBouncer each becomes a network round trip plus a commit: 5 statements × ~0.3–1 ms ≈ 2–5 ms per message, of which 2–3 are avoidable:----- Steps 5 and the `subjectFor` read should move **outside** the write transaction or be cached (`ctx` already caches users, `live-context.js:195-201`), so the transaction holds only the `INSERT chat_messages` + `INSERT events_outbox` — one round trip, one commit.--- The message path must never wait on `enqueue`'s contract validation twice or on a mirror flush; `live-mirror.js:56-100` already runs on its own timer.--- The bridge path (`live-bridge.js:217-223`) adds one more round trip for Live's remaining writers, already async.----Rules to hold: never issue a `SELECT` before the `INSERT` on the hot path unless it is served from `ctx`; keep the per-message transaction to two statements; keep the identity next to the client so the id is known before the broadcast, not after a round trip.------## 5. The staged-table bridge and mirror code the cutover deletes----**Chat side, live today:**----- Authority store: `table_authority` (`schema.sql:308-312`), read cached 1 s in `tableAuthority()` `server/db/database.js:853-859` (cache object :852), written by `setTableAuthority` `:860-866`; `stagedAuthorities()` `:868-870`; `mirrorPending(table)` `:872-873`. Seeding and "a restart never moves it back" is `initDb` `:133-138`.--- The gate on writes: `_assertChatWrites(table)` `:883-888` throws `table.not_chat` (:886) — called by every staged writer (`:909,919,931,1043,1058,1070,1083,1093,1102,1110,1120,1151`).--- The answer shape Live consumes: `_staged(value, table, rows, deletedPks)` `:895-905` — `{ value, mirror: [{table, op, row|pk}] }`, with the `{changes, lastInsertRowid}` sniff at :896-897.--- The staged writers: `addChannelModerator` `:908-916`, `removeChannelModerator` `:918-927`, `upsertChannelModerationSettings` `:930-1040`, `setChannelAlertSound` `:1042-1054`, `createEmote` `:1057-1067`, `updateEmote` `:1069-1080`, `deleteEmote` `:1082-1089`, `setEmoteMedia` `:1092-1098`, `grantUserTag` `:1101-1107`, `revokeUserTag` `:1109-1117`, `upsertChatAiSummary` `:1119-1148`, `addChatTimelineEvents` `:1150-1167`.--- **Live → Chat capture path (deleted at cutover):** `applyStagedChanges(changes)` `:1175-1203` — the `INSERT OR REPLACE` applier (:1188), guarded by `tableAuthority(c.table) !== 'live'` (:1181), with `_columns()` filtering (:1182, `:876-880`) and the invalidation tail `:1196-1201`. Its bridge op is `stagedApply` (`server/bridge/live-bridge.js:259-260`); Live's side of it is `chat_staged_outbox` (`chat-tables-sync.js:46-52`, fed by TEMP triggers at :78-82).--- **Dual read (deleted):** `sliceHash(columns, rows)` `:1209-1212` and `stagedSlice(table, where, columns)` `:1219-1228` (exposed as bridge op `stagedSlice` `live-bridge.js:261-262`); Live's counters live in `chat_dual_read_stats` (`chat-tables-sync.js:53-63`, :218-232).--- **The handoff op:** bridge op `setTableAuthority` `live-bridge.js:265-266` (`:263-264` is op `tableAuthority`; the op list is documented :32-36) → `handOver()` `:128-148` (to `chat` requires the mirror; back to `live` drains `mirrorPending` first, with a 15 s budget). Kept as the rollback lever even after the capture is gone.--- **Read mirror (kept until C-01…C-03, 2026-11-06):** TEMP triggers `installMirrorTriggers()` `:143-161` — one `AFTER INSERT/UPDATE/DELETE` per table, the staged ones gated by `WHEN (SELECT authority FROM main.table_authority WHERE table_name = '…') = 'chat'` (:149), writing `(tbl, op, pk JSON)` into `live_mirror_outbox` (:155-159); the relay `server/bridge/live-mirror.js:29-114` (`BATCH` 200 :26, `MAX_BODY` 800 KB :27, `pending` :35, `buildChanges` :37-54 collapses per key and re-reads the row as it is now :50, `flush` :56-100, POSTs `/internal/chat-effects/mirror` :79 with `live.chat_mirror.write` :28). **On PostgreSQL TEMP triggers do not exist** — this is the single biggest rewrite in the move: either a real trigger on a per-session flag (`current_setting('chat.mirror_on')`), or (simpler and preferred) have every write path append to `live_mirror_outbox` inside the same transaction it already has, and drop the trigger mechanism entirely. Note the mirror is also why `initDb` takes `captureMirror` and why the importer calls `initDb({ captureMirror: false })` (`scripts/import-from-live.js:104`, `scripts/mirror-flush.js:18`).--- **The conditional reads in `live-context.js`** — `_chatWrites(table)` `:364`, `getChannelModerationSettings` `:365-371`, `isChannelModerator` `:372-378`, `reloadPolicy` `:402` (called from the settings effect :891), and the settings/alert-sound effects `:889-905` — become unconditional direct reads after the flip.--- **CLI:** `scripts/table-authority.js` (status :19-24, `set` :26-31), `scripts/mirror-flush.js:18-23`, `scripts/parity-check.js:35-40`, the `--tables` mode of `scripts/import-from-live.js:52-53`.--- **On the Live side the cutover deletes:** `server/chat/chat-tables.js` capture/relay/dual-read (the file keeps `authority()` :81 and `write()` :137), `server/chat/chat-tables-sync.js` in full, `chat_staged_outbox`, `chat_dual_read_stats`, and the `OPS` map (`chat-tables.js:44-55`) that routes Live's ten writers to Chat.----**The procedure is already written and does not change:** `docs/staged-tables-cutover.md` — calendar at **lines 76-87** (D0 = 2026-09-28 deploy/import/parity/dual-read; flips 2026-10-05 `chat_timeline_events` → 10-06 `chat_ai_summaries` → 10-07 `user_tags` → 10-08 `emotes` → 10-09 `channel_moderation_settings` → 10-12 `channel_moderators`; the last Live writers removed "not before 2026-12-18"; C-01…C-03 target 2026-11-06), rollback rows at **:273-284**. Live's per-table handoff endpoint is `POST /internal/chat-tables/:table` (`server/internal/routes.js:29,43`) with `GET /internal/chat-tables` (:32) and the relay-pause endpoint :35-36; the app_state keys are `chat_table_authority` / `chat_table_dual_read` (`chat-tables.js:57-58`) and `chat_table_relay_paused` (`chat-tables-sync.js:36`). The remote write is `write()` (`chat-tables.js:137`) over Chat's bridge.----**What changes if T3 (PostgreSQL) lands first:** the six tables must be in PostgreSQL *and* in the same database as everything else before the flips, because a table split across two engines cannot be flipped one at a time — `applyStagedChanges` writes to whichever engine holds the copy, and `handOver()` drains one mirror. The cheapest sequencing is: **move all of Chat to PostgreSQL while every staged table is still `live` (they are read-only on Chat's side), then run the six flips unchanged.** After the last flip, `applyStagedChanges`, `stagedSlice`/`sliceHash` and the `INSERT OR REPLACE` semantics are dead code and can be deleted in one commit together with Live's sync file.------## 6. Tests----Runner: `test/run.js:15` (`openvibe-shared/test-runner`, `{ dir: __dirname, timeoutMs: 60000, pad: 32, parallel: 1 }`; `--strict` documented :9 makes a skip fail). **40 files** (`test/*.test.js`). CI (`.github/workflows/ci.yml:7-8`) runs it with `test/n-1.test.js` replaying the previous release's clients and its SQL against this one, **from `test/fixtures/n-1`**.----**Database-coupled (must be ported, not deleted):**----- `test/staged-tables.test.js` (239 lines) — the whole C-04 contract in 13 cases: boot with the mirror on :36, authority defaults and restart :47-55, every staged write refuses at `live` :57-68, Live's changes arrive idempotently and never flow back :70-97, the dual-read slice :99, the handoff needs the mirror :109, at `chat` the writers and return values :119/:138, every change reaches Live through the mirror :170, `/slow` persisted in place :182, flip-back waits for the mirror :206, `scripts/table-authority.js` :220.--- `test/outbox-mirror.test.js:7-11` — events outbox + the Live read mirror against a stub Events and a stub Live.--- `test/audio-queue.test.js` — rows, transitions, restart recovery; opens the DB file directly at :14 and :236 (`raw()` helper), restart case :237.--- `test/bridge.test.js` — the bridge ops incl. `stagedApply`/`stagedSlice`/`setTableAuthority` and the idempotency key.--- `test/import.test.js:17,37,85+` (opens the Live snapshot, the Chat db and the backup :94-98), `test/migrate-preferences.test.js:18,45,75,110`, `test/restart-resume.test.js:16,31` — open SQLite files and assert on them.--- `test/message-deleted.test.js:23` — checks a `better-sqlite3` **in the sibling OpenVibe.Events checkout**, not Chat's; the redaction end-to-end already runs against PostgreSQL Events (per the eab0c20 commit message) and skips cleanly when the checkout is absent (:77).--- `test/account-data.test.js` — export/erase through `account-data.js:34,39,84,96` (`rowid`, dynamic table names, `PRAGMA table_info`).--- `test/subject-merge.test.js` — the raw-handle merge in `subject-merge.js`, including its `PRAGMA table_info` guard at :18.--- `test/rooms.test.js`, `test/room-kinds.test.js` — the `rooms` DDL and every `rooms*` statement.--- `test/n-1.test.js` (142 lines) + `test/n-1/*` (**1047 lines**) — the N-1 harness replays the **previous release's SQL** (from `test/fixtures/n-1`) against the current schema, and derives the N-1 schema from `sqlite_master` / `PRAGMA table_xinfo` / `PRAGMA table_list` (`test/n-1/harness.js:731,732,773,774,799`, harness 855 lines; the SQLite-capable preload is `test/n-1/preload.js:13`). It has to become a PG harness: record the previous release's SQL calls as fixtures and replay them against the migrated PostgreSQL schema. Media kept `scripts/n-1-record.js` on better-sqlite3 only for that (`OpenVibe.Media/scripts/n-1-record.js:19`); its N-1 story is separate.--- `test/ready.test.js` — `/ready` `db` check (`server/app.js:121` `SELECT MAX(id) FROM chat_messages`) plus `table_authority` (`app.js:149`).--- `test/live-context.test.js` — the projection and the staged-read switch.--- `test/parity.test.js` + `scripts/parity.js` (731 lines) — end-to-end product parity over the real protocol; **no SQLite**, so it survives the move as-is, while `scripts/parity-check.js` (the row comparer) must be repointed at PG.--- `test/calls.test.js`, `test/dm-inbox.test.js`, `test/network-blocks.test.js`, `test/sounds-tts.test.js`, `test/vip-badge.test.js`, `test/ws-protocol.test.js`, `test/rest.test.js`, `test/events-consumer.test.js` — all boot Chat and touch the DB through the harness.----**Harness work:** `test/helpers.js:288` sets `CHAT_DB_PATH` per run; `:338` hands the test the **db** module (the live module is `h.ctx` at :339); `:36` `sqliteNow()` (also exposed as `h.sqliteNow` :440); **137 `h.db.*` uses across 21 test files** become `await h.db.*`. Model: `OpenVibe.Media/test/helpers/pg-preload.mjs:1-20` — a `node --import` preload giving every test process one migrated PGlite (or, with **`MEDIA_TEST_STORE=pg`**, the containers) — plus `OpenVibe.Media/package.json:12` a `test:pg` script. `createTestDb`/`createTestValkey` come from `openvibe-sdk/testing` (`src/testing/db.js:26,85`), not from a Media `test/helpers/db.js`.----**Valkey:** the only in-process shared state is the per-actor rate limiter (`server/net/actor-limits.js:21-22` "Counters live in this process: a restart forgets them"). Move it exactly as Media did: `createValkeyLimitStore` behind `useValkey()` (`OpenVibe.Media/server/actor-limits.js:58-62`), `VALKEY_URL`/`VALKEY_PREFIX` in config (`OpenVibe.Media/server/config.js:32`), a `/ready` check that skips when unset (`OpenVibe.Media/server/observability.js:132`). The **history-store 2 s page memo** (`history-store.js:85-94`) and the `live-context` caches stay in-process: they are latency caches whose whole design is "stale by at most 2 s". Also in-process but out of scope: the WebSocket flood control and the audio-queue timers.----**Environment note (not a code failure):** `npm test` in this checkout fails 36/40 files at `new Database(...)` — `node_modules/better-sqlite3/build/Release/better_sqlite3.node` was compiled for `NODE_MODULE_VERSION 127` and this runtime is **137** (node v24.19.0, checked live). A `npm rebuild better-sqlite3` fixes it; installs are denied in this job, so the suite cannot be run green here. The 4 that pass are the ones that never open the database.------## 7. One-cutover data plan with count verification----**Target:** one PostgreSQL database (`chat`), one import, one cutover, the six staged tables still at `live`.----0. **Rehearse on a copy.** `sqlite3 /var/lib/openvibe-chat/chat.db ".backup /tmp/chat-pg-source.db"`, then `importSqlite({ sqlite: '/tmp/chat-pg-source.db', db: ownerDb, truncate: true })` (`openvibe-sdk/db`, `src/db/sqlite-import.js:1-19`) into a PGlite copy of the target schema. `importSqlite` verifies **row count and a canonicalised per-row checksum per table, walked in PK order** (:17-19) and refuses on a column mismatch unless `dropColumns` lists it. Record its report; that report is the baseline for step 6.--1. **Generate the migration.** `openvibe-sdk/tools/asyncify/sqlite-schema-to-pg.js` (README `:56-58`) over `server/db/schema.sql` + `server/rooms/rooms.js:86-132` **+ the three runtime-created tables** (`token_revocations`, `network_blocks`, `account_data_events`) → `migrations/0001_initial.sql`, then take Media's prelude verbatim (`OpenVibe.Media/migrations/0001_initial.sql:6-30`) for `ov_ts`, `ov_now`, `ov_now_iso`, `datetime`, `julianday`, `json_valid`, `json_extract`, `json_type`, `instr` — Chat's stored timestamps are SQLite `YYYY-MM-DD HH:MM:SS` text and its queries use `datetime(...)` and `CURRENT_TIMESTAMP` (`history-store.js:40-41,54-55,168`, `live-context.js:419,428`, `rooms.js:205`). The converter refuses any trigger it does not understand, and Chat's `installMirrorTriggers` TEMP triggers are not in `schema.sql` at all, so they never reach it.--2. **Dialect fixes by hand** (`tools/asyncify/README.md:34-35,38`): `INSERT OR IGNORE` → `ON CONFLICT DO NOTHING` (`database.js:911,1159`, `rooms.js`), `INSERT OR REPLACE` → `ON CONFLICT … DO UPDATE` (`database.js:1188`, `account-data.js:187`), `COLLATE NOCASE` → `lower()` (`live-context.js:235-236`), `LIKE` → `ILIKE` (`database.js:542`), `IS ?` → `IS NOT DISTINCT FROM` (`database.js:1226`), `PRAGMA table_info` → `information_schema.columns` (`database.js:128,878`, `account-data.js:34`, `subject-merge.js:18`, `rooms.js:134`), `sqlite_master` → `information_schema.tables`, `rowid` (`database.js:534`, `account-data.js:84`, `import-from-live.js:244`) → an identity column, `sqlite_sequence` (`import-from-live.js:279-281`) → `setval(pg_get_serial_sequence(...))`, scalar `MAX(last_read_id, ?)` → `GREATEST` (`rooms.js:348`), `strftime(...)` default in `account_data_events` → `ov_now_iso()`, `lastInsertRowid`/`.changes` → `RETURNING`/`rowCount` (41 lines, §2), the dynamic table interpolation in `account-data.js`/`subject-merge.js`/`live-mirror.js`, and the partial index `idx_calls_open` (`schema.sql:518`) as a real partial index.--3. **Convert the code** with `tools/asyncify/sqlfix.py` then `asyncify.js` over `server/**`, `scripts/**` and `test/*.test.js`; fix the `MANUAL` array callbacks by hand — the ones visible are `live-context.js:218-224` (already a `for` loop), `live-context.js:755-756`, `audio-queue.js:311-316`, and `bridge/live-bridge.js:190` (`Array.isArray(value) ? value.map(...)` becomes `for…of` + `Promise.all`).--4. **Boot async.** `server/index.js:30` `db.initDb()` → `await db.initDb(cfg)` shaped as `openDb`/`initDb`/`getDb` in `OpenVibe.Media/server/db/database.js:28-61`; production requires `DATABASE_URL` (owner role migrates with `DATABASE_DIRECT_URL`, runtime DML-only through PgBouncer); dev falls back to an embedded PGlite in `data/pglite`. `chatServer.init` (`:37`), `callServer.init` (:38) and `createApp` (`:43`) stay after the first `await`.--5. **Rebuild the mirror capture without TEMP triggers** (§5) — the single non-mechanical change. Append to `live_mirror_outbox` in the write transactions (`database.js` writers, `_staged`), and keep the relay as it is.--6. **Count verification at cutover** (all against the *same* source snapshot, all with the service drained):-- - Before: `SELECT COUNT(*)` per table from the SQLite file via the same query list `scripts/parity-check.js` prints (its `tableParity` :35-40 walks `STAGED_KEYS` and compares counts and `sliceHash`).-- - After import: `importSqlite`'s per-table count + checksum on the PostgreSQL side, compared to the same numbers from the SQLite snapshot (the importer does this internally — make the report land in `import_runs`, `schema.sql:455-462`).-- - Sequences: assert `last_value` of every identity ≥ the SQLite `sqlite_sequence` high-water mark, so a post-cutover id can never collide with an imported row. (Chat's own importer already reasons this way: `import-from-live.js:275-283` takes `max(liveMax + headroom, chatMax)` and writes `sqlite_sequence` — on PostgreSQL that is `setval`, and the `+ 10000` headroom becomes a `setval` above the imported max.)-- - After cutover: `GET /ready` (`server/app.js:118-154`) must be `ready` with `db.detail.max_message_id` equal to the pre-cutover `SELECT MAX(id) FROM chat_messages`, `table_authority` all six `live` (:149), `mirror.pending` 0 (:134).-- - Spot parity: `scripts/parity-check.js` (repointed at PG) on the six staged tables; the same command the plan already runs at `docs/staged-tables-cutover.md` step 2.-- - Then run the six flips **unchanged** (`docs/staged-tables-cutover.md` §4, calendar :76-87).--7. **Backups.** `scripts/import-from-live.js:10-12` already takes a `VACUUM INTO` copy before writing; the same applies: the SQLite file is retained, read-only, for the rollback window; `docs/cutover.md` §Rollback (:174) plus `scripts/mirror-flush.js` cover the staged tables.------## 8. Risks----1. **Message ordering.** `chat_messages.id` is the client splice cursor (`history-store.js:105-111`) and `deploy-notice.js:62` reads "the newest row in ANY room" by `ORDER BY id DESC`. A PostgreSQL identity preserves insert order; but a **cache-pool sequence gap** is invisible to `delta()` (which only needs `id > afterId`) while `readPage`'s `ORDER BY timestamp DESC, id DESC` can reorder rows that share a second — `timestamp` is second-precision text in SQLite. Give `chat_messages.timestamp` sub-second precision in the migration, or make the page order `id DESC` alone.--2. **Ids.** 41 lines read `lastInsertRowid`; several already tolerate a different shape (`deploy-notice.js:76` `res.lastInsertRowid || res.lastID || res.id`; `live-bridge.js:199-202` and `database.js:896-897` both sniff `{changes, lastInsertRowid}`). `sounds-routes.js:285` puts the raw `result.lastInsertRowid` into an HTTP body. `_staged` and the bridge `plain()` must keep returning *something* Live's `chat-tables.js:write()` applies — Live reads the id from the answer, so `RETURNING id` must be shaped back into that field until C-01…C-03 retire the mirror.--3. **TEMP triggers.** `installMirrorTriggers()` (`database.js:143-161`) is per-connection, per-temp-schema, and gated on `main.table_authority`. None of that exists on PostgreSQL; a naive port loses the mirror silently — Live's copy goes stale and nobody notices until a rollback.--4. **Transactions across statements.** `db.transaction(fn)()` is synchronous; the ambient `db.tx` in `openvibe-sdk/db` is async and retries 40001/40P01. `audio-queue.js:187-203` and `live-bridge.js:217-223` read-then-write; under `READ COMMITTED` with two chat processes (or a bridge caller) a retry can double-count `bridge_applied` unless the read and the insert are in the same retryable transaction. `bridge_applied`'s insert-on-conflict is the idempotency guarantee — keep it, and consider `SERIALIZABLE` for the `enqueue` dedupe check (`audio-queue.js:188`).--5. **The audio-queue race the plan mentions.** `audio-queue.js:209-241` `pump()` reads the next queued row, increments `attempts` (already an atomic `UPDATE … attempts = attempts + 1` at :221), awaits the synth (`perform` at :225), then transitions to `playing` and schedules the finish timer. On SQLite the whole read-modify-write is synchronous and single-process. On PostgreSQL, with `await` inside the transaction and a second process (or a retry), two `pump()` invocations can both read the same `state='queued'` row and both play it — a duplicate TTS clip. The existing test tolerance (`test/audio-queue.test.js:237` restart case; the flake noted in commit 4c517a5) becomes a much wider window once the round trip is added. Fix by making the claim atomic, not by loosening the test: `UPDATE audio_requests SET state='playing', attempts=attempts+1 WHERE id=$1 AND state='queued' RETURNING *` inside the transaction, and treat zero rows as "someone else took it". `enqueue`'s dedupe and its per-room/per-requester caps (`audio-queue.js:188-201`) need the same treatment (`ON CONFLICT` on a unique `(room, dedupe_key)`, and the counts inside the tx).--6. **Read latency in the page path.** `GLOBAL_SELECT` (`history-store.js:27-41`) is a five-join aggregate filtered on `message_type IN (…) AND is_deleted = 0 AND (auto_delete_at IS NULL OR datetime(...) > CURRENT_TIMESTAMP)` ordered by `timestamp, id`. On SQLite this ran on a local file; on PostgreSQL it is a remote query whose plan depends on `idx_chat_ts_deleted` (`schema.sql:51`) and `idx_chat_channel_user_ts` (`:48`) surviving the conversion. Measure `page('global')` p95 before and after; if it regresses, add a partial index on `(timestamp DESC, id DESC) WHERE is_deleted = 0` or move the auto-delete filter to a retention sweep (there is already `deleteExpiredChatMessages`, `database.js:400-433`, on a timer) so the read is a plain range.--7. **PgBouncer in transaction mode forbids session state** (`src/db/index.js:25-26`): no `SET`, no `LISTEN`, no advisory locks held across transactions. Chat's pragmas (`database.js:67-73`) disappear; any `statement_timeout`/`lock_timeout` must come from the role (as `createTestDb` does, `openvibe-sdk/src/testing/db.js:49-50`).--8. **`ctx_*` projection write amplification.** `live-context.js:212-225` upserts every Live page inside one transaction; on PostgreSQL the transaction is fine, but `SCHEDULE` (`:760-773`) runs 9 steps every 10 s–30 min against a remote server. It is already off the message path, so this is a load question, not a latency one.--9. **Valkey only fixes the rate limiter.** Making the limiter shared changes the numbers people see after a deploy (a restart no longer resets the budget) — that is the point, but it is a user-visible change and needs a line in the release note.--10. **The six staged tables straddle two engines if T3 lands mid-flip.** `handOver()` (`live-bridge.js:128-148`) drains `live_mirror_outbox` for one table; if that table's copy is in SQLite and the relay reads a PostgreSQL connection, the flip silently no-ops. Sequence the work so **all** tables move to PostgreSQL before the first flip (see §7), and keep `docs/staged-tables-cutover.md` as the authority for the flip itself.--11. **The N-1 harness** replays the previous release's SQL (from `test/fixtures/n-1`) against the current schema, deriving the old schema from `sqlite_master` (`harness.js:731,774`); after the move there is no `sqlite_master`, and the previous release's SQL is SQLite dialect. The recording format must change (record calls, not a schema) or the mixed-version guarantee CI runs is lost.--12. **A version/dependency prerequisite is missing from the draft.** Chat's `openvibe-sdk` pin v0.12.0 has no `db`, no `src/testing` helpers, and no `tools`; `pg`/`pglite`/`iovalkey` are not installed. Landing the plan as written cannot even import its data layer. Bump the SDK (≥0.18.0; ideally 0.22.0) and add the three drivers as step 0, and treat the SDK's own release notes as the contract for `createDb`/`importSqlite`/`asyncify`.--13. **Dynamic SQL is a real dialect hazard, not a mechanical one.** `account-data.js:34,84,106-153`, `subject-merge.js:18,35,47-70`, `live-mirror.js:50` and `applyStagedChanges` (`database.js:1185-1188`) all interpolate table and column names into SQL text. The asyncify tools leave those alone, and `openvibe-sdk/db`'s safe path (`sql`, `sql.ident`) is a tagged template — so these are hand conversions with no codemod behind them, and they are the places where a "converted" service silently changes behaviour.------## 9. Step-by-step plan with tests----Each step is one commit; `npm test` (and `npm run test:pg` once it exists) after every one.----0. **Prerequisites.** Raise `openvibe-sdk` (`package.json:32`) to a version that ships `openvibe-sdk/db` and ambient transactions (≥0.18.0), add `pg`, `@electric-sql/pglite`, `iovalkey`, and vendor/checkout `tools/asyncify` from the SDK repo. **Test:** `node -e "require('openvibe-sdk/db')"` loads; the existing suite still passes on SQLite.--1. **Migrations scaffold.** `migrations/0001_initial.sql` from `sqlite-schema-to-pg.js` over `schema.sql` + `rooms.js:86-132` + the three runtime tables, plus Media's SQLite-compat prelude. `server/config.js` gains `db: { url, directUrl }` and `valkey: { url, prefix }`. Boot stays SQLite. **Test:** `test/ready.test.js` unchanged; a new `test/pg-schema.test.js` that opens a PGlite, migrates, and asserts every one of the 35 tables and 39 indexes (from a list, not from `sqlite_master`) exists.--2. **`openDb`/`initDb`/`getDb` + `test/helpers/pg-preload.mjs`.** Media's `server/db/database.js:28-61` verbatim in shape; `CHAT_DB_PATH` still works as the SQLite path behind a `CHAT_STORE=sqlite|pglite|pg` switch for the transition. **Test:** every existing test file passes on PGlite; add `test/pg-boot.test.js` asserting `/ready`'s `db` check is `ok` on both stores.--3. **Dialect fixes in SQL only, SQLite still serving.** `sqlfix.py` rules, `lastInsertRowid` → `RETURNING` behind a helper that returns `{ changes, lastInsertRowid }` on both engines so no caller changes shape — including `sounds-routes.js:285`, which puts it in an HTTP body. **Test:** the whole suite on PGlite; `test/import.test.js` and `test/restart-resume.test.js` ported to read through the same abstraction.--4. **Asyncify.** `asyncify.js` over `server/**`, `scripts/**`, `test/*.test.js`; the `MANUAL` array callbacks fixed by hand, and the dynamic-SQL sites of risk 13 rewritten. **Test:** the whole suite, no test file changed in meaning; `test/bridge.test.js` and `test/staged-tables.test.js` are the sensitive ones (their HTTP bodies must not change).--5. **History store on PostgreSQL.** `history-store.js` async, `readPage` ordered by `id` (or a new `(timestamp, id)` index with sub-second `timestamp`). **Test:** a new `test/history-latency.test.js` measuring `page('global')` and `delta` over 200 k imported rows on PGlite, asserting the p95 and the plan (`EXPLAIN` shows an index scan, not a seq scan); `test/rest.test.js` covers the routes.--6. **Audio queue claim atomicity** (risk 5). **Test:** `test/audio-queue.test.js` unchanged **plus** a new case that runs two `pump()` loops concurrently over the same room on a real PG connection and asserts exactly one play per request; the restart case gets an `await` between the claim and the synth so the window is deterministic instead of load-dependent.--7. **Mirror capture without TEMP triggers.** Appends to `live_mirror_outbox` in the write transactions; `installMirrorTriggers` deleted; `scripts/mirror-flush.js` and `scripts/table-authority.js` repointed. **Test:** `test/outbox-mirror.test.js` and the mirror half of `test/staged-tables.test.js` pass on PGlite, including "Live's changes never come back" and "pending 0 after a flush".--8. **Live-side staged sync deletion, part 1.** With every table still `live`: remove `stagedSlice`/`sliceHash`/dual-read from both sides, keep `applyStagedChanges` + the `stagedApply` relay, add the PG identity key of the capture. **Test:** `test/staged-tables.test.js` loses the dual-read assertions (:99) and gains a PG-specific capture test; Live's `chat-tables-sync.js` relay interval and pause behaviour unchanged.--9. **The data move and the cutover**, run once, in the order of `docs/staged-tables-cutover.md` §0–§3: import (with the counts/checksums of §7), parity, dual-read for one release. **Test:** `scripts/parity-check.js` on PG; `/ready` `table_authority` all six `live`.--10. **The six flips**, one per day, unchanged procedure. **Test:** `test/staged-tables.test.js` extended to cover the flip-back with a non-empty mirror queue (`live-bridge.js:138-145`) on PG.--11. **Delete the bridge and mirror code** (C-01…C-03, 2026-11-06; the staged capture not before 2026-12-18). **Test:** the suite with `applyStagedChanges`, `sliceHash`, `stagedSlice`, the `stagedApply`/`stagedSlice` bridge ops and Live's `chat-tables-sync.js` removed; `docs/staged-tables-cutover.md` gains a closing section.--12. **Valkey for the rate limiter.** Media's `createValkeyLimitStore` + `useValkey` + `VALKEY_URL`/`VALKEY_PREFIX` + a `/ready` check that skips when unset. **Test:** `test/actor-limits.test.js` extended with two handles sharing one budget against `createTestValkey`, and a skip line when the Valkey URL is absent.--13. **N-1 harness on PostgreSQL** (risk 11). **Test:** `test/n-1.test.js` replays the recorded previous-release calls against the migrated schema and reports the same `MANUAL` cases.--14. **Drop better-sqlite3 from the service.** `package.json:24` loses it; `CHAT_DB_PATH` becomes `DATABASE_URL`. **Test:** nothing in `server/` requires it — assert that in `test/security-secrets.test.js` or a new guard test.------## Open questions for Opus----1. Does T3 land **before** the first staged flip (my recommendation, §7) or is there schedule pressure that forces a flip first? If forced, which of the six goes first and does `handOver()` get a two-engine mode?--2. Is the mirror allowed to become an explicit `live_mirror_outbox` append in every write transaction, or must it stay a database trigger (on PostgreSQL a real trigger gated by a session GUC, and then a per-transaction `SET LOCAL`, which `asyncify.js` will not insert for us)?--3. Should `chat_messages.timestamp` gain sub-second precision in the migration, or should `readPage` order by `id` alone? This decides whether the global page keeps its `idx_chat_ts_deleted`.--4. The audio-queue claim: is an atomic `UPDATE … WHERE state='queued' RETURNING *` acceptable, or does the queue need a lease (a `claimed_until` column) because a synth can outlive `statement_timeout = 15 s` on the runtime role?--5. Which `openvibe-sdk` version is the target (HEAD is v0.22.0; the data layer assumes ≥0.18.0), and does that bump ride with the move or land first on its own?--6. Is the `MAX(last_read_id, ?)` in `rooms.js:348` the only scalar-`MAX` site? I checked every `MAX(` in `server/rooms/rooms.js` and the other two (`SELECT MAX(id) … room_messages` at :241 and :346) are aggregates, so yes — but a repo-wide `MAX(` sweep has not been done.--7. Does Valkey also take the history-store memo, or does that stay a 2 s in-process cache (my assumption: it stays)?--8. `test/n-1/*` records the previous release's SQL and derives its schema from `sqlite_master` (from `test/fixtures/n-1`). Is the accepted replacement "record the calls and replay them against the migrated schema" (a rewrite of ~1000 lines of harness), or is the N-1 guarantee allowed to lapse for one release while the engine moves?--9. `server/chat/account-data.js:34,84,96` and `subject-merge.js:18,35` interpolate table and column names and read `PRAGMA table_info`; account export orders by `rowid`. Are dedicated, static queries acceptable there, or must account export keep the "most recent N by insertion order" semantics exactly?--10. The `relay_users` table has no id column — its key is `(platform, username)` and Chat uses `rowid AS id` (`database.js:534`). Does the PG migration give it a real identity column and keep the rowid-shaped answer, or does `CHAT_TABLES.relay_users` change?--11. Who runs the production import: is a Chat-maintained `scripts/import-sqlite-to-pg.js` (wrapping `openvibe-sdk/db.importSqlite`) acceptable, or does the move use `OpenVibe.Host roles/data` tooling like Media's `add-service.sh`?------*Method note: this is a read-only job. Nothing in `/home/workstation/OpenVibers/OpenVibe.Chat` was edited (working tree clean). The brief under review is `/home/workstation/openvibe/agents/jobs/brief-chat-pg.out.a3.report.md` (249 lines). Every `file:line` in the Corrections table was re-checked against the code, against `openvibe-sdk` HEAD (v0.22.0) and `openvibe-media`, and against `openvibe-live`'s `server/chat/chat-tables{,-sync}.js` and `server/internal/routes.js`. The `npm test` ABI failure was reproduced live (`process.versions.modules` = 137 vs a 127 build).*------The report is written to `/home/workstation/openvibe/agents/jobs/check-brief-chat-pg.out.a2.report.md`.----I re-verified every `file:line`, count and "exists / does not exist" claim in the draft against the code, SDK HEAD v0.22.0, Media and Live. The Corrections table has ~50 rows. The blocking error: Chat pins `openvibe-sdk` **v0.12.0**, which has no `db` module, no `tools/asyncify` and no `src/testing` — those arrived in 0.14.0 and 0.18.0 — and `pg`/`pglite`/`iovalkey` are not dependencies at all. Also corrected: the draft invents a Media `test/helpers/db.js` that does not exist, claims ~170 call sites when there are 219, and misses `network_blocks`, `account_data_events` and `token_revocations` as runtime-created tables (four schema owners, not two). I added what the draft missed: every SQLite call-site group, the 34 transaction callbacks, and the 13 test cases of the staged-tables contract.\\ No newline at end of file@@@ -9,7 +9,6 @@"dev": "NODE_ENV=development node server/index.js","test": "node test/run.js","test:pg": "OV_TEST_STORE=pg node test/run.js",-- "import-from-live": "node scripts/import-from-live.js","n-1:record": "node scripts/n-1-record.js"},"keywords": [ddeleted file mode 100644@@@ -1,295 +0,0 @@--#!/usr/bin/env node--/**-- * Copy OpenVibe.Live's chat tables into OpenVibe.Chat's database.-- *-- * node scripts/import-from-live.js --live-db <copy of live.db> [--apply [--backup <path> | --no-backup]]-- * [--headroom <n>] [--tables a,b] [--no-projections]-- *-- * Chat's database is the service's (DATABASE_URL / DATABASE_DIRECT_URL, or the development PGlite); Live's-- * snapshot is read with node:sqlite (scripts/lib/sqlite.js).-- *-- * Run it against a SNAPSHOT (sqlite3 live.db ".backup /tmp/live-snapshot.db"), never the live file.-- *-- * - A dry run unless --apply: the report says what a run would do and nothing is written. --apply-- * first copies the rows of every table it may write (the twelve chat tables, the ctx_* projections,-- * import_hold, import_runs) to a JSON file (./data/chat-pre-import-<time>.json, or --backup <path>)-- * and says where in the report. A whole-database backup is Host's (pg_dump).-- *-- * - Idempotent: a row already in Chat is recognised by its primary key and never written twice;-- * running it again after the cutover copies only what Live wrote since the first run.-- * - Ids are kept (a message, DM or sound keeps its Live id). Chat's own id sequences start-- * `--headroom` (default 10000) above Live's highest id (setval), so rows Live writes between the-- * first import and the flag flip still fit below and a second pass can bring them over.-- * - Nothing it writes is queued for Live's read mirror (ov.mirror_skip) or announced to Events.-- * - Never drops a row. What cannot be represented goes to import_hold with the reason:-- * an id already used by a DIFFERENT row, a constraint the row breaks, rows of Live's-- * transient *_new migration tables.-- * - Chat's tables: an existing Chat row always wins (after the cutover Chat is the authority and may-- * have edited it). The six tables Chat owns (channel moderators/settings, emotes, user tags,-- * chat-AI summaries/timeline; C-04 done) are not imported — Chat is their only writer.-- * - New columns: *subject_id is filled from Live's linked_accounts (the Network subject).-- * - media_requests / media_request_settings stay in Live (decision: docs/cutover.md) and are-- * only reported.-- * - Also seeds the ctx_* projections (users, streams, slots, channels) so a rehearsal can read-- * history before Chat's first sync (--no-projections to skip).-- *-- * Prints counts per table as JSON and records the run in import_runs. Exit code 1 when Live has-- * columns Chat does not know (schema drift: fix the schema first, nothing is written).-- */--'use strict';----const fs = require('fs');--const path = require('path');--const { openSqlite } = require('./lib/sqlite');----function parseArgs(argv) {-- const out = { dryRun: true, backup: true, headroom: 10000, projections: true, tables: null };-- for (let i = 0; i < argv.length; i++) {-- const a = argv[i];-- if (a === '--live-db') out.liveDb = argv[++i];-- else if (a === '--dry-run') out.dryRun = true;-- else if (a === '--apply') out.dryRun = false;-- else if (a === '--backup') out.backupPath = argv[++i];-- else if (a === '--no-backup') out.backup = false;-- else if (a === '--headroom') out.headroom = Math.max(0, parseInt(argv[++i], 10) || 0);-- else if (a === '--tables') out.tables = String(argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);-- else if (a === '--no-projections') out.projections = false;-- else if (a === '--help' || a === '-h') out.help = true;-- else throw new Error(`unknown argument ${a}`);-- }-- return out;--}----// Columns Live keeps for itself on a table Chat owns (not moved; they stay in Live's copy).--const LIVE_ONLY_COLUMNS = {-- channel_sounds: ['media_url', 'media_asset_id'],--};--// Columns that never change once a row exists: equal = the same row, different = an id conflict.--const IDENTITY = {-- chat_messages: ['stream_id', 'user_id', 'anon_id', 'message', 'timestamp'],-- dm_conversations: ['created_by', 'created_at'],-- dm_participants: ['conversation_id', 'user_id'],-- dm_messages: ['conversation_id', 'sender_id', 'message', 'created_at'],-- dm_blocks: ['blocker_id', 'blocked_id'],-- channel_sounds: ['channel_owner_id', 'url', 'created_at'],-- hidden_relay_users: ['platform', 'external_username', 'created_at'],-- pending_ip_messages: ['channel_id', 'ip_address', 'message', 'created_at'],-- moderation_actions: ['action_type', 'actor_user_id', 'created_at'],--};--// Subject columns filled from the author's Live user id.--const SUBJECT_COLUMNS = {-- chat_messages: [['subject_id', 'user_id']],-- dm_messages: [['sender_subject_id', 'sender_id']],-- dm_participants: [['subject_id', 'user_id']],-- dm_blocks: [['blocker_subject_id', 'blocker_id']],-- moderation_actions: [['actor_subject_id', 'actor_user_id']],-- channel_sounds: [['created_by_subject_id', 'created_by']],--};--const TRANSIENT = ['chat_messages_new', 'emotes_new', 'channel_sounds_new'];--const NOT_MOVED = ['media_requests', 'media_request_settings'];--const PROJECTIONS = ['ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels'];--class Rollback extends Error {}----// Table and column names come from db.CHAT_TABLES and the two schemas, never from input; still, only plain names.--const ident = (n) => { if (!/^[a-z_][a-z0-9_]*$/.test(n)) throw new Error(`unexpected identifier ${n}`); return n; };--// A value read back from PostgreSQL equals Live's: numbers and text compare as text where a column changed type--// (chat_messages.deleted_by, moderation_actions.scope_id).--const same = (a, b) => a === b || (a == null && b == null) || (a != null && b != null && String(a) === String(b));----/** Copy the rows of every table this run may write, before it writes (a JSON file; the whole database is pg_dump's). */--async function backupChat(chat, tables, target) {-- const file = path.resolve(target || path.join('data', `chat-pre-import-${new Date().toISOString().replace(/[:.]/g, '-')}.json`));-- if (fs.existsSync(file)) throw new Error(`backup ${file} already exists`);-- const out = { taken_at: new Date().toISOString(), tables: {} };-- for (const t of tables) out.tables[t] = await chat.prepare(`SELECT * FROM ${ident(t)}`).all();-- fs.mkdirSync(path.dirname(file), { recursive: true });-- fs.writeFileSync(file, JSON.stringify(out));-- return file;--}----async function run(opts) {-- if (!opts.liveDb) throw new Error('--live-db <snapshot> is required');-- const db = require('../server/db/database');-- await db.initDb();-- const chat = db.getDb();-- const live = openSqlite(path.resolve(opts.liveDb));-- // Rows written here came from Live: never queued back into Live's read mirror.-- const noMirror = () => chat.query("SET LOCAL ov.mirror_skip = '1'");---- const liveTables = new Set(live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));-- const colsOfLive = (t) => live.prepare(`PRAGMA table_info(${ident(t)})`).all().map((c) => c.name);-- const colsOfChat = async (t) => (await chat.prepare(`SELECT column_name FROM information_schema.columns-- WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position`).all(t)).map((c) => c.column_name);---- // The six tables Chat owns (C-04 done) are not imported: Chat is their only writer.-- const plan = [-- ...Object.entries(db.CHAT_TABLES).map(([t, pk]) => ({ table: t, pk: t === 'relay_users' ? ['platform', 'username'] : pk })),-- ].filter((p) => !opts.tables || opts.tables.includes(p.table));---- // Schema drift check first: nothing is written when Live has columns Chat cannot hold. (relay_users' id is-- // Chat's own identity column: Live's rows have none.)-- const drift = [];-- for (const p of plan) {-- if (!liveTables.has(p.table)) continue;-- const chatCols = new Set(await colsOfChat(p.table));-- const extra = colsOfLive(p.table).filter((c) => !chatCols.has(c) && !(LIVE_ONLY_COLUMNS[p.table] || []).includes(c));-- if (extra.length) drift.push(`${p.table}: ${extra.join(', ')}`);-- }-- if (drift.length) {-- live.close();-- const err = new Error(`Live has columns Chat does not know — add them in a migration first:\n ${drift.join('\n ')}`);-- err.code = 'SCHEMA_DRIFT';-- throw err;-- }---- // Network subjects by Live user id (newest link wins, as Live resolves them).-- const subjects = new Map();-- if (liveTables.has('linked_accounts') && colsOfLive('linked_accounts').includes('subject_id')) {-- for (const r of live.prepare("SELECT user_id, subject_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NOT NULL ORDER BY id").iterate()) subjects.set(r.user_id, r.subject_id);-- }---- const holdStmt = chat.prepare('INSERT INTO import_hold (source_table, source_pk, reason, row_json) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING');-- const hold = async (table, pkObj, reason, row) => (await holdStmt.run(table, JSON.stringify(pkObj), String(reason).slice(0, 300), JSON.stringify(row))).changes;---- const report = { live_db: path.resolve(opts.liveDb), dry_run: !!opts.dryRun, backup: null, tables: {} };-- if (!opts.dryRun && opts.backup) report.backup = await backupChat(chat, [...Object.keys(db.CHAT_TABLES), ...PROJECTIONS, 'import_hold', 'import_runs'], opts.backupPath);---- for (const p of plan) {-- const r = { live: 0, inserted: 0, identical: 0, chat_kept: 0, held: 0 };-- report.tables[p.table] = r;-- if (!liveTables.has(p.table)) { r.missing_in_live = true; continue; }-- const chatCols = new Set(await colsOfChat(p.table));-- const cols = colsOfLive(p.table).filter((c) => chatCols.has(c)).map(ident);-- const subjectCols = (SUBJECT_COLUMNS[p.table] || []).filter(([sc]) => chatCols.has(sc));-- const insertCols = cols.concat(subjectCols.map(([sc]) => ident(sc)));-- const where = p.pk.map((k) => `${ident(k)} = ?`).join(' AND ');-- const getChat = chat.prepare(`SELECT * FROM ${ident(p.table)} WHERE ${where}`);-- const ins = chat.prepare(`INSERT INTO ${ident(p.table)} (${insertCols.join(', ')}) VALUES (${insertCols.map(() => '?').join(', ')})`);-- const identity = IDENTITY[p.table] || null;-- const orderBy = p.pk.map(ident).join(', ');---- const apply = async () => {-- for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${ident(p.table)} ORDER BY ${orderBy}`).iterate()) {-- r.live++;-- const pkVals = p.pk.map((k) => row[k]);-- const pkObj = Object.fromEntries(p.pk.map((k) => [k, row[k]]));-- const existing = await getChat.get(...pkVals);-- if (!existing) {-- // A savepoint per row: a row that breaks a constraint is held, the rest of the table still goes.-- try {-- await chat.tx(() => ins.run(...cols.map((c) => row[c]), ...subjectCols.map(([, uidCol]) => subjects.get(row[uidCol]) || null)));-- r.inserted++;-- } catch (err) {-- r.held += await hold(p.table, pkObj, `insert failed: ${err.message}`, row);-- }-- continue;-- }-- if (cols.every((c) => same(existing[c], row[c]))) { r.identical++; continue; }-- if (identity && !identity.every((c) => !cols.includes(c) || same(existing[c], row[c]))) {-- // Same id, different row: Chat's stays, Live's is held for a person to look at.-- r.held += await hold(p.table, pkObj, 'id already used by a different row in chat', row);-- continue;-- }-- r.chat_kept++;-- }-- };---- try {-- await chat.tx(async () => { await noMirror(); await apply(); if (opts.dryRun) throw new Rollback(); });-- } catch (err) {-- if (!(err instanceof Rollback)) throw err;-- }-- }---- // Transient migration tables (Live rebuilds a table through <name>_new): never expected to-- // hold rows; if a snapshot caught one mid-rebuild, keep every row for review.-- for (const t of TRANSIENT) {-- if (!liveTables.has(t)) continue;-- const r = { live: 0, held: 0 };-- report.tables[t] = r;-- try {-- await chat.tx(async () => {-- const pkCol = colsOfLive(t).includes('id') ? 'id' : 'rowid';-- for (const row of live.prepare(`SELECT rowid AS _rowid, * FROM ${ident(t)}`).iterate()) {-- r.live++;-- r.held += await hold(t, { [pkCol]: pkCol === 'id' ? row.id : row._rowid }, 'row of a transient migration table (Live rebuilds tables through <name>_new)', row);-- }-- if (opts.dryRun) throw new Rollback();-- });-- } catch (err) { if (!(err instanceof Rollback)) throw err; }-- }-- for (const t of NOT_MOVED) {-- if (liveTables.has(t)) report.tables[t] = { live: live.prepare(`SELECT COUNT(*) AS n FROM ${ident(t)}`).get().n, not_moved: 'stays in Live (media queue is Live-owned in W6)' };-- }---- if (!opts.dryRun) {-- // Chat's new ids start above Live's highest id plus headroom (never lowered): the identity's sequence.-- await chat.tx(async () => {-- for (const [t] of Object.entries(db.CHAT_TABLES)) {-- // (only the tables this run imported: --tables leaves the others as they are)-- if (!report.tables[t] || !liveTables.has(t) || !colsOfLive(t).includes('id')) continue;-- const liveMax = live.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${ident(t)}`).get().m;-- const chatMax = (await chat.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${ident(t)}`).get()).m;-- const seq = (await chat.prepare("SELECT pg_get_serial_sequence(?, 'id') AS s").get(t)).s;-- const cur = Number((await chat.prepare('SELECT pg_sequence_last_value(?::regclass) AS v').get(seq)).v) || 0;-- const target = Math.max(liveMax + opts.headroom, chatMax, cur);-- if (target > cur) await chat.prepare('SELECT setval(?::regclass, ?)').get(seq, target);-- report.tables[t].next_id = target + 1;-- }-- });---- if (opts.projections) {-- const seed = async (table, sql, cols) => {-- if (!liveTables.has(sql.table)) return 0;-- const have = new Set(colsOfLive(sql.table));-- const pick = cols.filter((c) => have.has(c.from || c.to));-- const st = chat.prepare(`INSERT INTO ${ident(table)} (${pick.map((c) => ident(c.to)).join(', ')}, synced_at) VALUES (${pick.map(() => '?').join(', ')}, 0)-- ON CONFLICT(id) DO NOTHING`);-- let n = 0;-- await chat.tx(async () => {-- for (const row of live.prepare(`SELECT ${pick.map((c) => ident(c.from || c.to)).join(', ')} FROM ${ident(sql.table)}`).iterate()) {-- n += (await st.run(...pick.map((c) => (c.to === 'subject_id' ? (subjects.get(row.id) || null) : row[c.from || c.to])))).changes;-- }-- });-- return n;-- };-- const c = (to, from) => ({ to, from });-- report.projections = {-- ctx_users: await seed('ctx_users', { table: 'users' }, [c('id'), c('username'), c('display_name'), c('avatar_url'), c('profile_color'), c('role'), c('is_banned'), c('ban_reason'), c('is_owner'), c('created_at'), c('subject_id', 'id')]),-- ctx_streams: await seed('ctx_streams', { table: 'streams' }, [c('id'), c('user_id'), c('channel_id'), c('managed_stream_id'), c('title'), c('is_live'), c('started_at'), c('ended_at'), c('created_at')]),-- ctx_managed_streams: await seed('ctx_managed_streams', { table: 'managed_streams' }, [c('id'), c('user_id'), c('slug'), c('title'), c('sort_order'), c('created_at')]),-- ctx_channels: await seed('ctx_channels', { table: 'channels' }, [c('id'), c('user_id'), c('title')]),-- };-- }-- await chat.prepare('INSERT INTO import_runs (live_db, dry_run, counts, finished_at) VALUES (?, 0, ?, ov_now())').run(report.live_db, JSON.stringify(report.tables));-- }-- report.held_total = (await chat.prepare('SELECT COUNT(*) AS n FROM import_hold').get()).n;-- live.close();-- return report;--}----const USAGE = 'usage: node scripts/import-from-live.js --live-db <snapshot> [--apply [--backup <path> | --no-backup]] [--headroom <n>] [--tables a,b] [--no-projections]';----/** The command line: → { code, stdout, stderr } (exit 0; 1 schema drift; 2 usage; 3 failure). */--async function cli(argv) {-- let opts;-- try { opts = parseArgs(argv); } catch (err) { return { code: 2, stdout: '', stderr: `${err.message}\n` }; }-- if (opts.help || !opts.liveDb) return { code: opts.help ? 0 : 2, stdout: `${USAGE}\n`, stderr: '' };-- try {-- const report = await run(opts);-- return { code: 0, stdout: `${JSON.stringify(report, null, 2)}\n`, stderr: '' };-- } catch (err) {-- return { code: err.code === 'SCHEMA_DRIFT' ? 1 : 3, stdout: '', stderr: `${err.message}\n` };-- }--}----if (require.main === module) {-- cli(process.argv.slice(2)).then(async (r) => {-- process.stdout.write(r.stdout);-- process.stderr.write(r.stderr);-- try { await require('../server/db/database').close(); } catch { /* */ }-- process.exit(r.code);-- });--}----module.exports = { run, cli, parseArgs, IDENTITY, SUBJECT_COLUMNS, LIVE_ONLY_COLUMNS };ddeleted file mode 100644@@@ -1,89 +0,0 @@--#!/usr/bin/env node--/**-- * The one-time move of Chat's SQLite data into its PostgreSQL schema (plan T3, decision 10).-- *-- * node scripts/import-sqlite-to-pg.js --sqlite /var/lib/openvibe-chat/chat.db \-- * --url "$DATABASE_DIRECT_URL" # the owner role, a direct connection-- * node scripts/import-sqlite-to-pg.js --sqlite ./data/chat.db --pglite ./data/pglite # a local dry run-- *-- * It wraps openvibe-sdk/db importSqlite: parents first, batched inserts, identity columns keep their-- * ids and their sequences move past the maximum, and every table is verified (row count + a checksum-- * walked in primary-key order). The per-table report is written to import_runs and printed. The-- * session runs with ov.mirror_skip = '1', so the rows (which came from Live) are never queued back-- * into Live's read mirror.-- */--'use strict';----const path = require('path');--const { createDb, importSqlite } = require('openvibe-sdk/db');--const { openSqlite } = require('./lib/sqlite');----const MIGRATIONS = path.join(__dirname, '..', 'migrations');----function usage(msg) {-- if (msg) console.error(`import-sqlite-to-pg: ${msg}`);-- console.error('usage: import-sqlite-to-pg --sqlite <file> (--url <postgres-url> | --pglite <dir>) [--truncate] [--no-verify]');-- process.exit(2);--}----async function main(argv) {-- const args = { sqlite: '', url: '', pglite: '', truncate: false, verify: true };-- for (let i = 0; i < argv.length; i++) {-- const a = argv[i];-- if (a === '--sqlite') args.sqlite = argv[++i];-- else if (a === '--url') args.url = argv[++i];-- else if (a === '--pglite') args.pglite = argv[++i];-- else if (a === '--truncate') args.truncate = true;-- else if (a === '--no-verify') args.verify = false;-- else usage(`unknown argument ${a}`);-- }-- if (!args.sqlite) usage('--sqlite is required');-- if (!args.url && !args.pglite) args.url = process.env.DATABASE_DIRECT_URL;-- if (!args.url && !args.pglite) usage('--url, --pglite or DATABASE_DIRECT_URL is required');---- // The owner handle (or an embedded PGlite for a dry run). One connection, so the session setting below holds.-- const db = args.pglite-- ? createDb({ pglite: args.pglite, service: 'chat-import', max: 1 })-- : createDb({ url: args.url, service: 'chat-import', max: 1 });-- try {-- // The schema first (the owner applies migrations/; a no-op where the release already did).-- await db.migrate({ dir: MIGRATIONS, log: { log() {}, info() {}, warn: console.warn, error: console.error } });-- // The import's rows came from Live: they must not queue in the read mirror (decision 1).-- await db.query(`SET ov.mirror_skip = '1'`);---- const started = new Date().toISOString().replace('T', ' ').slice(0, 19);-- // Read with node:sqlite (scripts/lib/sqlite.js): better-sqlite3 is no longer a dependency (decision 11).-- const source = openSqlite(args.sqlite);-- let report;-- try { report = await importSqlite({ sqlite: source, db, truncate: args.truncate, verify: args.verify }); } finally { source.close(); }---- // Statistics for the serving role (plan T3, decision 2): the importer is the owner, and the runtime-- // role is DML-only, so its ANALYZE is silently skipped and it would have no column statistics until-- // autovacuum runs. Without them the planner mis-estimates the history page and picks a bitmap scan-- // + Sort (p95 ≈ 280 ms at 60k rows); with them it is an Index Scan Backward (p95 ≈ 2 ms). See-- // test/history-latency.test.js.-- await db.query('ANALYZE');---- // The per-table report, one line each, in the same shape import_runs stores it.-- for (const t of report.tables) {-- console.log(`[import] ${t.table.padEnd(28)} ${String(t.rows).padStart(8)} rows${t.checksum ? ` checksum ${t.checksum}` : ''} (${t.ms} ms)`);-- }-- for (const p of report.problems) console.error(`[import] PROBLEM ${p.table}: ${p.problem}`);-- console.log(`[import] ${report.tables.length} tables, ${report.tables.reduce((n, t) => n + t.rows, 0)} rows, ${report.ok ? 'ok' : 'FAILED'}`);---- // Record the run (best effort: a failure here does not change the import's verdict).-- try {-- await db.prepare(`INSERT INTO import_runs (live_db, dry_run, counts, started_at, finished_at) VALUES (?, ?, ?, ?, ?)`)-- .run(args.sqlite, args.pglite ? 1 : 0, JSON.stringify({ ok: report.ok, tables: report.tables, problems: report.problems }), started, new Date().toISOString().replace('T', ' ').slice(0, 19));-- } catch (err) { console.warn(`[import] could not record the run: ${err.message}`); }---- await db.query(`SET ov.mirror_skip = '0'`).catch(() => {});-- if (!report.ok) process.exitCode = 1;-- } finally {-- await db.close().catch(() => {});-- }--}----if (require.main === module) main(process.argv.slice(2)).catch((err) => { console.error(`[import] ${err && err.stack || err}`); process.exit(1); });----module.exports = { main };ddeleted file mode 100644@@@ -1,56 +0,0 @@--'use strict';--/**-- * Read (or, for a test fixture, write) a SQLite file without better-sqlite3 (plan T3 decision 11: the service no-- * longer depends on it). node:sqlite (Node 22) behind the better-sqlite3 shape the one-time tools use:-- * prepare(sql).all/get/run/iterate, exec, pragma, close — so openvibe-sdk/db importSqlite takes it as its `sqlite`-- * source. BLOBs come back as Buffers, as they did.-- *-- * const { openSqlite } = require('./lib/sqlite');-- * const live = openSqlite('/tmp/live-snapshot.db'); // read-only, must exist-- * const fixture = openSqlite(file, { readonly: false, create: true });-- */--const fs = require('fs');----let quieted = false;--function quietExperimentalWarning() {-- // node:sqlite prints an ExperimentalWarning on first use in Node 22; the tools' output is their report.-- if (quieted) return;-- quieted = true;-- const emit = process.emitWarning;-- process.emitWarning = function (warning, ...rest) {-- const type = typeof rest[0] === 'string' ? rest[0] : rest[0] && rest[0].type;-- if (type === 'ExperimentalWarning' && /SQLite/i.test(String(warning && warning.message ? warning.message : warning))) return;-- return emit.call(process, warning, ...rest);-- };--}----function fixRow(row) {-- if (!row) return row;-- const out = {};-- for (const [k, v] of Object.entries(row)) out[k] = v instanceof Uint8Array && !Buffer.isBuffer(v) ? Buffer.from(v) : v;-- return out;--}----function openSqlite(file, { readonly = true, create = false } = {}) {-- if (!create && !fs.existsSync(file)) throw new Error(`${file}: no such SQLite file`);-- quietExperimentalWarning();-- const { DatabaseSync } = require('node:sqlite');-- const d = new DatabaseSync(file, { readOnly: readonly });-- return {-- name: file,-- prepare(sql) {-- const st = d.prepare(sql);-- return {-- all: (...a) => st.all(...a).map(fixRow),-- get: (...a) => fixRow(st.get(...a)),-- run: (...a) => { const r = st.run(...a); return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) }; },-- *iterate(...a) { for (const r of st.iterate(...a)) yield fixRow(r); },-- };-- },-- exec: (sql) => d.exec(sql),-- pragma: (p) => d.exec(`PRAGMA ${p}`),-- close: () => d.close(),-- };--}----module.exports = { openSqlite };ddeleted file mode 100644@@@ -1,82 +0,0 @@--#!/usr/bin/env node--/**-- * Move the chat preferences Live kept (user_preferences.chat_settings) into the OpenVibe.Network user-- * module chat.preferences, which Chat owns. One-off and idempotent; logic in server/prefs/from-live.js.-- *-- * node scripts/migrate-chat-preferences.js --live-db <live.db> # dry run (default)-- * node scripts/migrate-chat-preferences.js --live-db <live.db> --apply --backup <file> # write-- * node scripts/migrate-chat-preferences.js --rollback <file> [--apply] # undo a run-- *-- * Options:-- * --live-db <file> Live's database (a snapshot is best: sqlite3 live.db ".backup /tmp/live.db"), opened read-only-- * --apply write; without it nothing is written and the report says what would be-- * --backup <file> required with --apply; must not exist. Written (0600) and read back before any-- * write: every record the run will create (none existed before) and its data; the-- * revision each creation got is added when the run ends. --rollback takes it.-- * --rollback <file> delete the records that run created, each only while still at the revision it-- * got (a record the person changed since is kept). Dry run unless --apply.-- *-- * Reads the same environment as the server (.env / /etc/openvibe/chat.env): OV_NETWORK_INTERNAL_URL,-- * OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET. Chat's principal needs network.modules.read and-- * network.modules.write on chat.preferences (Network default grants). Every write goes through Network,-- * which validates it, gives it a revision and emits network.module.updated.-- *-- * Only choices move (a value equal to Live's default is not one): showTimestamps, compactMode, fontSize,-- * showBadges. Prints a JSON report. Exit codes: 0 done (or dry run), 2 refused (bad options), 1 failed.-- */--'use strict';----const fs = require('fs');----function usage(msg, code = msg ? 2 : 0) {-- if (msg) console.error(msg);-- console.error('usage: node scripts/migrate-chat-preferences.js --live-db <live.db> [--apply --backup <file>]\n'-- + ' node scripts/migrate-chat-preferences.js --rollback <file> [--apply]');-- process.exit(code);--}----function parseArgs(argv) {-- const out = { liveDb: null, apply: false, backup: null, rollback: null };-- const value = (i, name) => { const v = argv[i]; if (!v || v.startsWith('--')) usage(`${name} needs a value`); return v; };-- for (let i = 0; i < argv.length; i++) {-- const a = argv[i];-- if (a === '--live-db') out.liveDb = value(++i, a);-- else if (a === '--backup') out.backup = value(++i, a);-- else if (a === '--rollback') out.rollback = value(++i, a);-- else if (a === '--apply') out.apply = true;-- else if (a === '--dry-run') out.apply = false;-- else if (a === '-h' || a === '--help') usage();-- else usage(`unknown argument ${a}`);-- }-- if (out.rollback) {-- if (out.liveDb || out.backup) usage('--rollback takes only the backup file (and --apply)');-- if (!fs.existsSync(out.rollback)) usage(`no backup at ${out.rollback}`);-- return out;-- }-- if (!out.liveDb) usage('missing --live-db');-- if (!fs.existsSync(out.liveDb)) usage(`no Live database at ${out.liveDb}`);-- if (out.apply && !out.backup) usage('--apply needs --backup <file>: what the run creates is recorded there first');-- if (out.backup && fs.existsSync(out.backup)) usage(`${out.backup} exists already; pick a new backup file`);-- return out;--}----async function main() {-- const args = parseArgs(process.argv.slice(2));-- const config = require('../server/config');-- if (!config.oauth.clientSecret) usage('OV_OAUTH_CLIENT_SECRET is not set (Chat\'s service principal writes the records)');-- const migration = require('../server/prefs/from-live');-- if (args.rollback) {-- const report = await migration.rollback(args.rollback, { apply: args.apply });-- console.log(JSON.stringify(report, null, 2));-- return;-- }-- const { openSqlite } = require('./lib/sqlite');-- const liveDb = openSqlite(args.liveDb);-- let planned;-- try { planned = await migration.plan(liveDb); } finally { liveDb.close(); }-- const report = await migration.migrate(planned, { apply: args.apply, backup: args.backup, liveDbPath: args.liveDb, log: (m) => console.error(m) });-- console.log(JSON.stringify(report, null, 2));-- if (!args.apply) console.error('dry run: nothing written (add --apply --backup <file> to write)');--}----main().catch((err) => { console.error(`migrate-chat-preferences failed: ${err.message}`); process.exit(1); });@@@ -13,17 +13,13 @@* them) is replayed against it; a service with sockets also replays the messages its clients send.* Writes test/fixtures/n-1/client.json and worker.json; commit them.*-- * A release on PostgreSQL (plan T3: migrations/, openvibe-sdk/db) boots on a PGlite directory of its own; its-- * schema is recorded as the migrations it applied (name and text: append-only, so the test rebuilds exactly that-- * database), and its statements as PostgreSQL received them. A release on SQLite is recorded as before (that needs-- * better-sqlite3 in node_modules, which this checkout no longer installs).++ * The release boots on PGlite; the recorder stores its migrations and PostgreSQL statements.*/const fs = require('fs');const os = require('os');const path = require('path');const h = require('../test/n-1/harness');const svc = require('../test/n-1/service');--const { onPostgres } = require('../test/n-1/engine');const ROOT = path.join(__dirname, '..');const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');@@@ -32,15 +28,14 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');const ref = process.argv[2] || 'HEAD';const wt = h.worktree(ROOT, ref);const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-record-'));-- const pg = onPostgres(wt.dir);-- const dbPath = pg ? path.join(tmp, 'db', 'pglite') : path.join(tmp, 'db', `${svc.service}.db`);++ const dbPath = path.join(tmp, 'db', 'pglite');const dataDir = path.join(tmp, 'data');const sqlOut = path.join(tmp, 'sql.json');fs.mkdirSync(path.dirname(dbPath), { recursive: true });fs.mkdirSync(dataDir, { recursive: true });let server = null;try {-- console.log(`n-1:record: ${svc.service} at ${wt.sha.slice(0, 12)} (${ref}), on ${pg ? 'PostgreSQL' : 'SQLite'}`);++ console.log(`n-1:record: ${svc.service} at ${wt.sha.slice(0, 12)} (${ref}), on PostgreSQL`);// 1. The client: its calls, and the names it reads.const files = svc.clientFiles(wt.dir);@@@ -108,7 +103,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };const clients = svc.clientReleases ? svc.clientReleases() : null;const writeClient = () => fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);-- if (pg) {++ {const { createDb } = require('openvibe-sdk/db');const db = createDb({ pglite: dbPath, service: 'n-1-record', log: { warn() {}, error: console.error } });try {@@@ -131,28 +126,6 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');} finally { await db.close().catch(() => {}); }return;}-- const Database = require('better-sqlite3');-- const db = new Database(dbPath);-- const schema = h.schemaDDL(db);-- const ledger = {};-- for (const t of svc.ledgerTables || []) {-- try { ledger[t] = db.prepare(`SELECT * FROM "${t}"`).all(); } catch { /* not there */ }-- }-- const userVersion = db.pragma('user_version', { simple: true });-- const serverFiles = h.readTree(wt.dir, svc.sqlDirs);-- // What N-1 creates itself on first use or at a full boot: in production it exists before N deploys.-- const lazy = h.lazyDDL(serverFiles, db);-- const ran = fs.existsSync(sqlOut) ? JSON.parse(fs.readFileSync(sqlOut, 'utf8')) : [];-- const candidates = [...new Set([...ran.map(h.normalizeSql).filter((s) => /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i.test(s)), ...h.sqlLiterals(serverFiles)])].sort();-- const failing = new Set(h.prepareProblems(db, candidates).map((p) => p.sql));-- const statements = candidates.filter((s) => !failing.has(s));-- db.close();-- console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${schema.length} schema objects, ${lazy.length} created on first use`);---- fs.mkdirSync(OUT, { recursive: true });-- writeClient();-- fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);-- console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);} finally {if (server) await server.close();wt.remove();ddeleted file mode 100644@@@ -1,77 +0,0 @@--#!/usr/bin/env node--/**-- * Compare what Live and Chat answer for the same chat reads (cutover rehearsal and flip checks).-- *-- * node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 \-- * [--before "2026-09-22 10:00:00"] [--stream 123]... [--channel 45]... [--token <jwt>]-- *-- * --before pins history pages to messages older than the snapshot, so rows written since do not-- * count as differences. --token (a Network JWT of a test account) adds the DM routes. Prints one-- * line per path (same / DIFFERENT with the first differing keys) and exits 1 on any difference.-- * Fields that legitimately move between two reads (latest_id of a room that grew, request ids)-- * are ignored.-- */--'use strict';----function parseArgs(argv) {-- const o = { streams: [], channels: [] };-- for (let i = 0; i < argv.length; i++) {-- const a = argv[i], v = argv[i + 1];-- if (a === '--live') { o.live = v; i++; } else if (a === '--chat') { o.chat = v; i++; } else if (a === '--before') { o.before = v; i++; } else if (a === '--stream') { o.streams.push(v); i++; } else if (a === '--channel') { o.channels.push(v); i++; } else if (a === '--token') { o.token = v; i++; } else throw new Error(`unknown argument ${a}`);-- }-- if (!o.live || !o.chat) throw new Error('--live and --chat are required');-- return o;--}----const IGNORE = new Set(['request_id', 'trace_id', 'latest_id']);--function diff(a, b, at = '', out = []) {-- if (out.length > 5) return out;-- if (typeof a !== typeof b || Array.isArray(a) !== Array.isArray(b)) { out.push(`${at || '/'}: ${JSON.stringify(a)?.slice(0, 60)} ≠ ${JSON.stringify(b)?.slice(0, 60)}`); return out; }-- if (Array.isArray(a)) {-- if (a.length !== b.length) out.push(`${at}: length ${a.length} ≠ ${b.length}`);-- for (let i = 0; i < Math.min(a.length, b.length); i++) diff(a[i], b[i], `${at}[${i}]`, out);-- return out;-- }-- if (a && typeof a === 'object') {-- for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { if (!IGNORE.has(k)) diff(a[k], b[k], `${at}.${k}`, out); }-- return out;-- }-- if (a !== b) out.push(`${at}: ${JSON.stringify(a)} ≠ ${JSON.stringify(b)}`);-- return out;--}----async function get(base, p, token) {-- const res = await fetch(`${base.replace(/\/+$/, '')}${p}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });-- let body = null; try { body = await res.json(); } catch { /* */ }-- return { status: res.status, body };--}----async function main() {-- const o = parseArgs(process.argv.slice(2));-- const before = o.before ? `&before=${encodeURIComponent(o.before)}` : '';-- const paths = [-- `/api/chat/global/history?limit=200${before}`,-- '/api/chat/filters/friendly',-- '/api/chat/gif/providers',-- '/api/tts/settings',-- '/api/tts/voices',-- ...o.streams.map((s) => `/api/chat/${s}/history?limit=200${before}`),-- ...o.streams.map((s) => `/api/sounds/all/${s}`),-- ...o.channels.map((c) => `/api/chat/channel/${c}/history?limit=200${before}`),-- ...o.channels.map((c) => `/api/sounds/channel/${c}`),-- ];-- const authed = o.token ? ['/api/dm/conversations', '/api/dm/unread', '/api/dm/blocks'] : [];-- let bad = 0;-- for (const p of [...paths, ...authed]) {-- const tok = authed.includes(p) ? o.token : null;-- const [a, b] = await Promise.all([get(o.live, p, tok), get(o.chat, p, tok)]);-- const d = a.status !== b.status ? [`status ${a.status} ≠ ${b.status}`] : diff(a.body, b.body);-- if (d.length) { bad++; console.log(`DIFFERENT ${p}\n ${d.join('\n ')}`); } else console.log(`same ${p}`);-- }-- console.log(bad ? `\n${bad} path(s) differ` : '\nall paths answer the same');-- process.exit(bad ? 1 : 0);--}----if (require.main === module) main().catch((err) => { console.error(err.message); process.exit(2); });----module.exports = { parseArgs };@@@ -2,7 +2,7 @@* OpenVibe.Chat — HTTP app and WebSocket upgrade handling.** Browsers reach Chat on Live's origin: nginx sends /ws/chat and the chat REST prefixes-- * (/api/chat/, /api/dm/, /api/tts/, /api/sounds) here (docs/cutover.md), and at the calls cutover++ * (/api/chat/, /api/dm/, /api/tts/, /api/sounds) here, and at the calls cutover* /ws/call and the call routes under /api/streams (docs/calls-cutover.md), so the guards Live ran* in front of those routes run here too, with Live's values: credentialed CORS for an explicit* list of origins (never a wildcard), the /api rate limit, IP/network bans (admins exempt) and the ov_banned cookie, the@@@ -191,5 +191,5 @@ module.exports = {maxPerUploaderPerChannel: int(process.env.MAX_SOUNDS_PER_UPLOADER_PER_CHANNEL, 10),},// Where Chat keeps its own caches (101soundboards clips, TTS preview clips).-- cacheDir: process.env.CHAT_CACHE_DIR || path.join(path.dirname(process.env.CHAT_DB_PATH || './data/chat.db'), 'cache'),++ cacheDir: process.env.CHAT_CACHE_DIR || path.join('data', 'cache'),};@@@ -85,8 +85,7 @@ function getDb() {/** Tests and tools: use this handle as the process-wide database (null forgets it). */function setDb(handle) { db = handle; _stmts.clear(); }--// An INSERT into a table with an `id` column returns it, so run() answers { changes, lastInsertRowid } as--// better-sqlite3 did and no caller changes shape (decision: RETURNING in place of lastInsertRowid).++// An INSERT into a table with an `id` column returns it, preserving the run() result shape.const INSERT_INTO = /^\s*INSERT\s+INTO\s+([a-z_]+)/i;function withReturning(sql) {if (/\bRETURNING\b/i.test(sql)) return sql;@@@ -104,7 +103,7 @@ function stmt(sql) {return s;}--/** → { changes, lastInsertRowid }, better-sqlite3's shape (callers pass it on, some into answers). */++/** → { changes, lastInsertRowid } (callers pass it on, some into answers). */async function run(sql, params = []) {const r = await stmt(sql).run(...(Array.isArray(params) ? params : [params]));return { changes: r.changes, lastInsertRowid: r.lastInsertRowid };ddeleted file mode 100644@@@ -1,152 +0,0 @@--/**-- * One-off migration of the chat preferences Live kept server-side into the Network user module-- * chat.preferences (scripts/migrate-chat-preferences.js is the command line).-- *-- * Source: Live's user_preferences.chat_settings — the browser's whole chatSettings object, synced by-- * public/js/chat.js through PUT /api/auth/preferences — and the person's Network subject from Live's-- * linked_accounts (as scripts/import-from-live.js does). Live's database is opened read-only.-- *-- * What moves (chat.preferences v1 has five fields; the rest of chatSettings has no field yet):-- * showTimestamps true → timestamps: true-- * compactMode true → compact: true-- * fontSize small | large → font_scale 0.88 | 1.18 (Live's .chat-msg 0.75rem / 1rem over its 0.85rem)-- * showBadges false → show_badges: false-- * Only choices move: a value equal to Live's default (CHAT_SETTINGS_DEFAULTS) is not written, and a-- * person with nothing but defaults gets no record.-- *-- * Writes go through Network with Chat's service token (never into Network's database), create-only-- * (If-Match: 0): a person who already has a record keeps it, so a second run changes nothing. Before-- * writing, the backup file records what each target looked like (always "no record" for what gets-- * created) and what will be written; after, the revision each creation got. rollback() deletes the-- * records a run created, only while each is still at that revision (If-Match).-- */--'use strict';----const fs = require('fs');--const path = require('path');--const { modules } = require('openvibe-contracts');--const client = require('./network-modules');----const NAMESPACE = 'chat.preferences';--const FONT_SCALE = Object.freeze({ small: 0.88, large: 1.18 });--const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;----/** Live's chatSettings → chat.preferences data (choices only). */--function fromLiveChatSettings(settings) {-- const s = settings && typeof settings === 'object' && !Array.isArray(settings) ? settings : {};-- const out = {};-- if (s.showTimestamps === true) out.timestamps = true;-- if (s.compactMode === true) out.compact = true;-- if (Object.prototype.hasOwnProperty.call(FONT_SCALE, s.fontSize)) out.font_scale = FONT_SCALE[s.fontSize];-- if (s.showBadges === false) out.show_badges = false;-- return out;--}----/** Read Live's rows and decide what each becomes. Returns { rows: [...], counts }. No network. */--function plan(liveDb) {-- const has = (t) => !!liveDb.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t);-- if (!has('user_preferences')) throw new Error('Live database has no user_preferences table');-- const laCols = has('linked_accounts') ? liveDb.prepare('PRAGMA table_info(linked_accounts)').all().map((c) => c.name) : [];-- const subjectSql = laCols.includes('subject_id')-- ? `(SELECT la.subject_id FROM linked_accounts la WHERE la.user_id = up.user_id AND la.subject_id IS NOT NULL-- ORDER BY (la.service = 'network') DESC, la.id LIMIT 1)`-- : 'NULL';-- const rows = liveDb.prepare(`SELECT up.user_id, up.chat_settings, up.updated_at, ${subjectSql} AS subject_id-- FROM user_preferences up ORDER BY up.user_id`).all();-- const counts = { live_rows: rows.length, unreadable: 0, no_subject: 0, defaults_only: 0, invalid: 0, candidates: 0, fields: {} };-- const out = [];-- for (const r of rows) {-- let settings;-- try { settings = JSON.parse(r.chat_settings || '{}'); } catch { counts.unreadable++; continue; }-- if (!USER_SUBJECT.test(String(r.subject_id || ''))) { counts.no_subject++; continue; }-- const data = fromLiveChatSettings(settings);-- if (!Object.keys(data).length) { counts.defaults_only++; continue; }-- if (!modules.validateData(NAMESPACE, data).valid) { counts.invalid++; continue; }-- for (const k of Object.keys(data)) counts.fields[k] = (counts.fields[k] || 0) + 1;-- counts.candidates++;-- out.push({ live_user_id: r.user_id, subject: r.subject_id, live_updated_at: r.updated_at || null, data });-- }-- return { rows: out, counts };--}----/** The current record of each candidate on Network: null (none), the record, or throws when unreachable. */--async function current(subject) {-- const r = await client.request('GET', NAMESPACE, subject);-- if (r.status === 200 && r.body) return { revision: r.body.revision, data: r.body.data, updated_by: r.body.updated_by || null };-- if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return null;-- throw new Error(`reading ${subject} on Network: ${r.status}${r.error ? ` ${r.error}` : ''}${r.body && r.body.code ? ` ${r.body.code}` : ''}`);--}----function writeJsonAtomic(file, value) {-- const tmp = `${file}.tmp-${process.pid}`;-- fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });-- fs.renameSync(tmp, file);--}----/**-- * Check every candidate against Network; with apply, write the backup first, then create the records.-- * Returns the report. `backup` must name a file that does not exist yet (required with apply).-- */--async function migrate(planned, { apply = false, backup = null, liveDbPath = null, log = () => {} } = {}) {-- if (apply && !backup) throw new Error('--apply needs --backup <file>');-- const report = { mode: apply ? 'apply' : 'dry-run', ...planned.counts, exists: 0, would_create: 0, created: 0, raced: 0, refused: 0 };-- const targets = [];-- for (const row of planned.rows) {-- const before = await current(row.subject);-- if (before) { report.exists++; continue; }-- targets.push({ ...row, before: null });-- }-- report.would_create = targets.length;-- if (!apply) return report;---- const file = path.resolve(backup);-- if (fs.existsSync(file)) throw new Error(`${file} exists already; pick a new backup file`);-- const doc = { kind: 'openvibe-chat/chat-preferences-migration', version: 1, namespace: NAMESPACE, created_at: new Date().toISOString(),-- live_db: liveDbPath, targets: targets.map((t) => ({ live_user_id: t.live_user_id, subject: t.subject, before: null, data: t.data })), results: null };-- writeJsonAtomic(file, doc);-- const check = JSON.parse(fs.readFileSync(file, 'utf8'));-- if (!check || !Array.isArray(check.targets) || check.targets.length !== targets.length) throw new Error(`backup ${file} did not read back; nothing written`);-- log(`backup written: ${file} (${targets.length} target(s))`);---- const results = [];-- for (const t of targets) {-- const r = await client.request('PUT', NAMESPACE, t.subject, { data: t.data, revision: 0 });-- if (r.status === 201 && r.body) { report.created++; results.push({ subject: t.subject, outcome: 'created', revision: r.body.revision }); }-- else if (r.status === 412) { report.raced++; results.push({ subject: t.subject, outcome: 'exists' }); }-- else if (r.status === 0) {-- doc.results = results; writeJsonAtomic(file, doc);-- throw new Error(`Network became unreachable after ${results.length} write(s): ${r.error}; the backup lists what was done, re-run to continue`);-- } else { report.refused++; results.push({ subject: t.subject, outcome: 'refused', status: r.status, code: (r.body && r.body.code) || null }); }-- }-- doc.results = results;-- doc.finished_at = new Date().toISOString();-- writeJsonAtomic(file, doc);-- return report;--}----/**-- * Undo a run from its backup: delete each record it created, only while still at the revision it got-- * (for a run that stopped before recording results: only while the data is exactly what it wrote).-- */--async function rollback(file, { apply = false } = {}) {-- const doc = JSON.parse(fs.readFileSync(file, 'utf8'));-- if (!doc || doc.kind !== 'openvibe-chat/chat-preferences-migration' || !Array.isArray(doc.targets)) throw new Error(`${file} is not a chat-preferences migration backup`);-- const byResult = new Map((doc.results || []).map((r) => [r.subject, r]));-- const report = { mode: apply ? 'apply' : 'dry-run', targets: doc.targets.length, deleted: 0, would_delete: 0, gone: 0, changed_since: 0, not_created: 0 };-- for (const t of doc.targets) {-- const res = byResult.get(t.subject);-- if (res && res.outcome !== 'created') { report.not_created++; continue; }-- const cur = await current(t.subject);-- if (!cur) { report.gone++; continue; }-- const ours = res ? cur.revision === res.revision : JSON.stringify(cur.data) === JSON.stringify(t.data) && cur.updated_by === 'svc:chat';-- if (!ours) { report.changed_since++; continue; }-- if (!apply) { report.would_delete++; continue; }-- const r = await client.request('DELETE', NAMESPACE, t.subject, { revision: cur.revision });-- if (r.status === 204) report.deleted++;-- else if (r.status === 412 || r.status === 404) report.changed_since++;-- else throw new Error(`deleting ${t.subject}: Network answered ${r.status}${r.error ? ` ${r.error}` : ''}`);-- }-- return report;--}----module.exports = { NAMESPACE, FONT_SCALE, fromLiveChatSettings, plan, migrate, rollback };ddeleted file mode 100644@@@ -1,26 +0,0 @@--'use strict';--/**-- * The T3 closeout record must exist, keep its checks, and the README must keep describing the-- * database as PostgreSQL (plan T3; a silent re-draft would lose the evidence). Fails loudly if either regresses.-- */--const assert = require('assert');--const fs = require('fs');--const path = require('path');--const { suite } = require('./helpers');----const t = suite('cutover-evidence');--const ROOT = path.join(__dirname, '..');----t('docs/cutover-evidence-t3.md records the T3 checks', () => {-- const md = fs.readFileSync(path.join(ROOT, 'docs', 'cutover-evidence-t3.md'), 'utf8');-- for (const key of ['health chat', 'DATABASE_URL', 'Import and row parity', 'Valkey ACL', 'Reproduce']) {-- assert.ok(md.includes(key), `docs/cutover-evidence-t3.md is missing "${key}"`);-- }--});----t('README.md describes Chat\'s database as PostgreSQL', () => {-- const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8');-- assert.ok(readme.includes('Own database on PostgreSQL'), 'README.md no longer says "Own database on PostgreSQL"');--});----t.run();ddeleted file mode 100644@@@ -1,373 +0,0 @@--CREATE TABLE "chat_messages" (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- stream_id INTEGER,-- user_id INTEGER, -- NULL for anon-- anon_id TEXT, -- 'anon12345' format-- username TEXT,-- message TEXT NOT NULL,-- message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip')),-- metadata TEXT, -- JSON sidecar for rich events (donation/goal-reached)-- is_global INTEGER DEFAULT 0,-- is_deleted INTEGER DEFAULT 0,-- is_filtered INTEGER DEFAULT 0,-- reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL,-- source_platform TEXT,-- deleted_by INTEGER,-- deleted_at DATETIME,-- auto_delete_at DATETIME,-- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, channel_user_id INTEGER,-- FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL--);--CREATE INDEX idx_chat_stream_id ON chat_messages(stream_id);--CREATE INDEX idx_chat_timestamp ON chat_messages(timestamp);--CREATE INDEX idx_chat_user_id ON chat_messages(user_id);--CREATE INDEX idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp);--CREATE INDEX idx_chat_stream_ts ON chat_messages(stream_id, timestamp);--CREATE INDEX idx_chat_autodelete ON chat_messages(auto_delete_at);--CREATE INDEX idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted);--CREATE TABLE dm_conversations (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- name TEXT,-- is_group INTEGER DEFAULT 0,-- created_by INTEGER NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE-- );--CREATE TABLE dm_participants (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- conversation_id INTEGER NOT NULL,-- user_id INTEGER NOT NULL,-- last_read_at DATETIME DEFAULT '1970-01-01 00:00:00',-- joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(conversation_id, user_id),-- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );--CREATE INDEX idx_dm_participants_conv ON dm_participants(conversation_id);--CREATE INDEX idx_dm_participants_user ON dm_participants(user_id);--CREATE TABLE dm_messages (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- conversation_id INTEGER NOT NULL,-- sender_id INTEGER NOT NULL,-- message TEXT NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,-- FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE-- );--CREATE INDEX idx_dm_messages_conv ON dm_messages(conversation_id, created_at);--CREATE INDEX idx_dm_messages_sender ON dm_messages(sender_id);--CREATE TABLE dm_blocks (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- blocker_id INTEGER NOT NULL,-- blocked_id INTEGER NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(blocker_id, blocked_id),-- FOREIGN KEY (blocker_id) REFERENCES users(id) ON DELETE CASCADE,-- FOREIGN KEY (blocked_id) REFERENCES users(id) ON DELETE CASCADE-- );--CREATE INDEX idx_dm_blocks_blocker ON dm_blocks(blocker_id);--CREATE INDEX idx_dm_blocks_blocked ON dm_blocks(blocked_id);--CREATE TABLE tts_voice_overrides (-- identity_key TEXT PRIMARY KEY,-- voice TEXT,-- pitch INTEGER,-- speed INTEGER,-- gap INTEGER DEFAULT 0,-- set_by INTEGER,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP-- );--CREATE TABLE "emotes" (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- code TEXT NOT NULL,-- url TEXT NOT NULL,-- animated INTEGER DEFAULT 0,-- width INTEGER DEFAULT 28,-- height INTEGER DEFAULT 28,-- is_global INTEGER DEFAULT 0,-- is_approved INTEGER DEFAULT 1,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- channel_owner_id INTEGER,-- size INTEGER DEFAULT 100,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );--CREATE INDEX idx_emotes_user ON emotes(user_id);--CREATE INDEX idx_emotes_global ON emotes(is_global);--CREATE INDEX idx_emotes_code ON emotes(code);--CREATE INDEX idx_emotes_channel_owner ON emotes(channel_owner_id);--CREATE UNIQUE INDEX idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code);--CREATE TABLE channel_sounds (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- channel_owner_id INTEGER NOT NULL, -- streamer whose channel this sound belongs to-- command TEXT NOT NULL, -- trigger word (without leading '!'), lowercased-- url TEXT NOT NULL, -- served path of the audio file on disk-- mime TEXT DEFAULT 'audio/mpeg',-- duration_seconds REAL DEFAULT 0,-- created_by INTEGER, -- uploader user id (NULL if removed user)-- created_by_name TEXT DEFAULT '',-- is_approved INTEGER DEFAULT 1,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP, emote_code TEXT DEFAULT '',-- -- Multiple sounds may share a command; playback picks one at random.-- FOREIGN KEY (channel_owner_id) REFERENCES users(id) ON DELETE CASCADE--);--CREATE INDEX idx_channel_sounds_owner ON channel_sounds(channel_owner_id);--CREATE INDEX idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);--CREATE TABLE media_requests (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- streamer_id INTEGER NOT NULL,-- stream_id INTEGER,-- user_id INTEGER NOT NULL,-- username TEXT NOT NULL,-- input TEXT NOT NULL,-- canonical_url TEXT NOT NULL,-- embed_url TEXT,-- provider TEXT NOT NULL CHECK(provider IN ('youtube', 'vimeo', 'audio', 'video')),-- title TEXT NOT NULL,-- thumbnail_url TEXT,-- duration_seconds INTEGER,-- cost INTEGER NOT NULL DEFAULT 25,-- currency TEXT DEFAULT 'opencoins',-- queue_position INTEGER DEFAULT 0,-- status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'playing', 'played', 'skipped', 'removed', 'failed')),-- requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- started_at DATETIME,-- ended_at DATETIME,-- last_error TEXT, stream_url TEXT, download_status TEXT DEFAULT 'none' CHECK(download_status IN ('none','extracting','downloading','ready','failed')), file_path TEXT, playback_position REAL DEFAULT 0, refunded INTEGER DEFAULT 0,-- FOREIGN KEY (streamer_id) REFERENCES users(id) ON DELETE CASCADE,-- FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE SET NULL,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);--CREATE INDEX idx_media_requests_streamer_status ON media_requests(streamer_id, status, queue_position, requested_at);--CREATE INDEX idx_media_requests_user_status ON media_requests(user_id, status, requested_at);--CREATE INDEX idx_media_requests_canonical ON media_requests(streamer_id, canonical_url, status);--CREATE TABLE media_request_settings (-- user_id INTEGER PRIMARY KEY,-- enabled INTEGER DEFAULT 1,-- request_cost INTEGER DEFAULT 25,-- max_per_user INTEGER DEFAULT 3,-- max_duration_seconds INTEGER DEFAULT 600,-- allow_youtube INTEGER DEFAULT 1,-- allow_vimeo INTEGER DEFAULT 1,-- allow_direct_media INTEGER DEFAULT 1,-- auto_advance INTEGER DEFAULT 1,-- currency TEXT DEFAULT 'opencoins' CHECK(currency IN ('free','vibes','opencoins','points')),-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, cost_mode TEXT DEFAULT 'flat' CHECK(cost_mode IN ('flat','per_minute')), cost_per_minute INTEGER DEFAULT 5, allow_live INTEGER DEFAULT 0, download_mode TEXT DEFAULT 'stream' CHECK(download_mode IN ('stream','download')),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE--);--CREATE TABLE moderation_actions (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- scope_type TEXT NOT NULL DEFAULT 'site',-- scope_id INTEGER,-- actor_user_id INTEGER,-- target_user_id INTEGER,-- action_type TEXT NOT NULL,-- details TEXT DEFAULT '{}',-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,-- FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL-- );--CREATE INDEX idx_mod_actions_created ON moderation_actions(created_at DESC);--CREATE INDEX idx_mod_actions_actor ON moderation_actions(actor_user_id);--CREATE INDEX idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);--CREATE TABLE channel_moderators (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- channel_id INTEGER NOT NULL,-- user_id INTEGER NOT NULL,-- added_by INTEGER NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(channel_id, user_id),-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,-- FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL--);--CREATE INDEX idx_channel_mods_channel ON channel_moderators(channel_id);--CREATE INDEX idx_channel_mods_user ON channel_moderators(user_id);--CREATE TABLE channel_moderation_settings (-- channel_id INTEGER PRIMARY KEY,-- slow_mode_seconds INTEGER DEFAULT 0,-- followers_only INTEGER DEFAULT 0,-- emote_only INTEGER DEFAULT 0,-- allow_anonymous INTEGER DEFAULT 1,-- links_allowed INTEGER DEFAULT 1,-- gifs_enabled INTEGER DEFAULT 1,-- account_age_gate_hours INTEGER DEFAULT 0,-- caps_percentage_limit INTEGER DEFAULT 0,-- aggressive_filter INTEGER DEFAULT 0,-- max_message_length INTEGER DEFAULT 500,-- slur_filter_enabled INTEGER DEFAULT 0,-- slur_filter_use_builtin INTEGER DEFAULT 1,-- slur_filter_terms TEXT DEFAULT '',-- slur_filter_regexes TEXT DEFAULT '',-- slur_filter_nudge_message TEXT DEFAULT '',-- slur_filter_disabled_categories TEXT DEFAULT '[]',-- ip_approval_mode INTEGER DEFAULT 0,-- soundboard_enabled INTEGER DEFAULT 1,-- soundboard_allow_pitch INTEGER DEFAULT 1,-- soundboard_allow_speed INTEGER DEFAULT 1,-- soundboard_banned_ids TEXT DEFAULT '',-- viewer_auto_delete_enabled INTEGER DEFAULT 1,-- viewer_delete_all_enabled INTEGER DEFAULT 1,-- custom_emotes_enabled INTEGER DEFAULT 1, -- viewers may upload gif/png emotes for this channel-- custom_sounds_enabled INTEGER DEFAULT 1, -- viewers may upload !sound commands for this channel-- max_sound_seconds INTEGER DEFAULT 10, -- max duration of an uploaded channel sound-- uploads_mods_only INTEGER DEFAULT 0, -- restrict emote/sound uploads to channel mods-- mods_can_edit_about INTEGER DEFAULT 0, -- allow channel mods to edit the streamer's About/panels-- donation_sound_url TEXT, -- on-disk path to the streamer's donation alert sound-- donation_sound_mime TEXT,-- goal_sound_url TEXT, -- optional override sound for goal-reached-- goal_sound_mime TEXT,-- emote_scale INTEGER DEFAULT 100, -- emote display size in chat, percent (50-300)-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, tts_max_length INTEGER DEFAULT 200, sound_min_speed REAL DEFAULT 0.5, sound_max_speed REAL DEFAULT 3.0, sound_min_pitch_cents INTEGER DEFAULT -1200, sound_max_pitch_cents INTEGER DEFAULT 1200, emote_size_min INTEGER DEFAULT 50, emote_size_max INTEGER DEFAULT 200, sounds_mods_only INTEGER DEFAULT 0,-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE--);--CREATE TABLE relay_users (-- platform TEXT NOT NULL,-- username TEXT NOT NULL,-- display_name TEXT,-- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,-- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,-- message_count INTEGER DEFAULT 0,-- PRIMARY KEY (platform, username)-- );--CREATE TABLE hidden_relay_users (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- channel_id INTEGER,-- platform TEXT NOT NULL,-- external_username TEXT NOT NULL,-- action TEXT DEFAULT 'hide' CHECK(action IN ('hide','ban')),-- reason TEXT,-- created_by INTEGER,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,-- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL-- );--CREATE INDEX idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform);--CREATE TABLE pending_ip_messages (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- channel_id INTEGER NOT NULL,-- stream_id INTEGER,-- ip_address TEXT NOT NULL,-- user_id INTEGER,-- anon_id TEXT,-- username TEXT,-- message TEXT NOT NULL,-- status TEXT DEFAULT 'pending' CHECK(status IN ('pending','approved','denied')),-- reviewed_by INTEGER,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,-- FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL-- );--CREATE INDEX idx_pending_ip_channel ON pending_ip_messages(channel_id, status);--CREATE TABLE stream_first_chats (-- chatter_key TEXT NOT NULL,-- channel_user_id INTEGER NOT NULL,-- first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (chatter_key, channel_user_id)-- );--CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id);--CREATE TABLE chat_ai_summaries (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- scope TEXT NOT NULL,-- subject_id INTEGER NOT NULL DEFAULT 0,-- window TEXT NOT NULL,-- overview TEXT DEFAULT '',-- memory_json TEXT DEFAULT '',-- timeline_json TEXT DEFAULT '[]',-- message_count INTEGER DEFAULT 0,-- window_message_count INTEGER DEFAULT 0,-- last_message_id INTEGER DEFAULT 0,-- window_label TEXT DEFAULT '',-- window_start DATETIME,-- window_end DATETIME,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(scope, subject_id, window)-- );--CREATE INDEX idx_chat_ai_scope ON chat_ai_summaries(scope, subject_id, window);--CREATE TABLE chat_timeline_events (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- scope TEXT NOT NULL DEFAULT 'global',-- subject_id INTEGER NOT NULL DEFAULT 0,-- ts DATETIME NOT NULL,-- label TEXT NOT NULL,-- detail TEXT DEFAULT '',-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP-- );--CREATE INDEX idx_chat_tl_scope_ts ON chat_timeline_events(scope, subject_id, ts DESC);--CREATE UNIQUE INDEX idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label);--CREATE TABLE linked_accounts (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- service TEXT NOT NULL,-- service_user_id TEXT NOT NULL,-- service_username TEXT,-- linked_at DATETIME DEFAULT CURRENT_TIMESTAMP, subject_id TEXT,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,-- UNIQUE(service, service_user_id)-- );--CREATE INDEX idx_linked_subject ON linked_accounts(subject_id);--CREATE INDEX idx_linked_service ON linked_accounts(service, service_user_id);--CREATE INDEX idx_linked_user ON linked_accounts(user_id);--CREATE TABLE managed_streams (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- channel_id INTEGER,-- slug TEXT, -- optional unique string ID (must contain non-numeric chars)-- title TEXT DEFAULT 'Untitled Stream',-- description TEXT DEFAULT '',-- category TEXT DEFAULT 'irl',-- tags TEXT DEFAULT '[]',-- protocol TEXT DEFAULT 'webrtc' CHECK(protocol IN ('jsmpeg', 'webrtc', 'rtmp')),-- stream_key TEXT UNIQUE NOT NULL, -- per-stream stable key (reusable across sessions)-- is_nsfw INTEGER DEFAULT 0,-- control_config_id INTEGER,-- sort_order INTEGER DEFAULT 0,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, slot_clip_recording_enabled INTEGER DEFAULT 1, slot_clip_notify_enabled INTEGER DEFAULT 1, slot_powerchat_relay INTEGER DEFAULT 1, slot_powerchat_count_rs_views INTEGER DEFAULT 1, broadcast_settings TEXT DEFAULT '{}', streaming_method TEXT DEFAULT 'browser', browser_mode TEXT DEFAULT 'camera', pip_source_msid INTEGER, pip_defaults TEXT DEFAULT '{}', default_vod_visibility TEXT DEFAULT 'public', default_clip_visibility TEXT DEFAULT 'public', slot_vod_recording_enabled INTEGER DEFAULT 1, weather_zip TEXT DEFAULT NULL, weather_detail TEXT DEFAULT 'basic', weather_show_location INTEGER DEFAULT 0, mic_only_image TEXT DEFAULT NULL,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,-- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,-- FOREIGN KEY (control_config_id) REFERENCES control_configs(id) ON DELETE SET NULL--);--CREATE INDEX idx_managed_streams_user ON managed_streams(user_id);--CREATE INDEX idx_managed_streams_slug ON managed_streams(slug);--CREATE INDEX idx_managed_streams_key ON managed_streams(stream_key);----CREATE TABLE user_tags (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- tag_id TEXT NOT NULL,-- source TEXT DEFAULT 'shop',-- granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(user_id, tag_id)-- );--CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, email TEXT, password_hash TEXT NOT NULL, display_name TEXT, avatar_url TEXT, role TEXT DEFAULT 'user', stream_key TEXT, is_banned INTEGER DEFAULT 0, ban_reason TEXT, profile_color TEXT DEFAULT '#8b5cf6', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_owner INTEGER DEFAULT 0);--CREATE TABLE streams (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, channel_id INTEGER, managed_stream_id INTEGER, title TEXT, is_live INTEGER DEFAULT 0, started_at DATETIME, ended_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);--CREATE TABLE channels (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, title TEXT);@@@ -46,7 +46,7 @@ let db, close, ownerUrl;// On the real containers the serving role is DML-only and its ANALYZE is silently skipped (no column// statistics, so the planner picked a bitmap scan + Sort); the owner runs it here, as autovacuum and--// scripts/import-sqlite-to-pg.js do in production. On PGlite the one role owns everything.++// On PGlite the one role owns everything.async function analyze() {if (!ownerUrl) { await db.query('ANALYZE chat_messages'); return; }const owner = createDb({ url: ownerUrl, service: 'chat-latency-owner', max: 1 });ddeleted file mode 100644@@@ -1,81 +0,0 @@--'use strict';--/**-- * The SQLite → PostgreSQL import (plan T3, decision 10): a SQLite fixture is imported into a migrated-- * PGlite database, the per-table report is checked (rows, no problems), the imported rows are readable-- * and were never queued in the Live mirror, and a post-import insert gets an id past the imported max.-- */--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const { openSqlite } = require('../scripts/lib/sqlite');--const { createDb } = require('openvibe-sdk/db');--const { suite } = require('./helpers');--const { main } = require('../scripts/import-sqlite-to-pg');----const t = suite('import-pg');--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-import-'));--const fixture = path.join(tmp, 'chat.db');--const pgliteDir = path.join(tmp, 'pglite');----async function buildFixture() {-- const d = openSqlite(fixture, { readonly: false, create: true });-- d.exec(`-- CREATE TABLE chat_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, username TEXT, message TEXT NOT NULL, timestamp TEXT DEFAULT CURRENT_TIMESTAMP);-- CREATE TABLE relay_users (platform TEXT NOT NULL, username TEXT NOT NULL, display_name TEXT, message_count INTEGER DEFAULT 0, PRIMARY KEY (platform, username));-- CREATE TABLE rooms (id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, owner_id INTEGER NOT NULL, message_count INTEGER NOT NULL DEFAULT 0);-- CREATE TABLE room_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL, user_id INTEGER NOT NULL, message TEXT NOT NULL);-- CREATE TABLE chat_meta (key TEXT PRIMARY KEY, value TEXT);-- CREATE TABLE emotes (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, code TEXT NOT NULL, url TEXT NOT NULL, channel_owner_id INTEGER);-- `);-- const msg = d.prepare('INSERT INTO chat_messages (user_id, username, message, timestamp) VALUES (?, ?, ?, ?)');-- for (let i = 1; i <= 120; i++) await msg.run(1000 + i, `user${i}`, `message ${i}`, '2026-09-29 10:00:00');-- await d.prepare('INSERT INTO relay_users (platform, username, display_name, message_count) VALUES (?, ?, ?, ?)').run('twitch', 'nightbot', 'Nightbot', 42);-- await d.prepare('INSERT INTO rooms (slug, name, owner_id, message_count) VALUES (?, ?, ?, ?)').run('general', 'General', 1001, 5);-- await d.prepare('INSERT INTO room_messages (room_id, user_id, message) VALUES (?, ?, ?)').run(1, 1001, 'hi room');-- await d.prepare('INSERT INTO chat_meta (key, value) VALUES (?, ?)').run('deploy_head', 'abc123');-- await d.prepare('INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (?, ?, ?, ?)').run(1001, 'kek', '/e/kek.png', 1001);-- d.close();--}----let report, db;--t('import the fixture', async () => {-- await buildFixture();-- await main(['--sqlite', fixture, '--pglite', pgliteDir]);-- assert.strictEqual(process.exitCode, undefined, 'the import reported ok');-- db = createDb({ pglite: pgliteDir, service: 'chat-import-test' });--});----t('the report has every table', async () => {-- const runs = await db.prepare('SELECT counts FROM import_runs ORDER BY id DESC LIMIT 1').get();-- const counts = JSON.parse(runs.counts);-- assert.strictEqual(counts.ok, true);-- const byTable = Object.fromEntries(counts.tables.map((x) => [x.table, x.rows]));-- assert.strictEqual(byTable.chat_messages, 120, 'all messages imported');-- assert.strictEqual(byTable.relay_users, 1);-- assert.strictEqual(byTable.rooms, 1);-- assert.strictEqual(byTable.chat_meta, 1);-- assert.strictEqual(byTable.emotes, 1);-- assert.strictEqual(counts.problems.length, 0, 'no problems');--});----t('a well-known row is readable', async () => {-- const row = await db.prepare("SELECT message, username FROM chat_messages WHERE message = 'message 120'").get();-- assert.strictEqual(row.username, 'user120');-- const meta = await db.prepare('SELECT value FROM chat_meta WHERE key = ?').get('deploy_head');-- assert.strictEqual(meta.value, 'abc123');--});----t('imported rows were never queued for the Live mirror', async () => {-- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM live_mirror_outbox').get()).n, 0);--});----t('a post-import insert is past the imported max', async () => {-- const max = (await db.prepare('SELECT MAX(id) AS id FROM chat_messages').get()).id;-- const res = await db.prepare('INSERT INTO chat_messages (message) VALUES (?) RETURNING id').get('after import');-- assert.ok(res.id > max, `new id ${res.id} is past ${max}`);--});----t('cleanup', async () => { await db.close().catch(() => {}); fs.rmSync(tmp, { recursive: true, force: true }); });----t.run();ddeleted file mode 100644@@@ -1,172 +0,0 @@--'use strict';--/**-- * scripts/import-from-live.js against a fake Live snapshot built from Live's real table-- * definitions (test/fixtures/live-chat-schema.sql): counts per table, a dry run unless --apply (and-- * it writes nothing), --apply backs Chat's database up first, ids kept, Network subjects filled in,-- * id headroom, projections seeded, idempotent re-runs, Chat's edits win on chat tables (the six-- * tables Chat owns are not imported at all), conflicting rows and transient-table rows held (never-- * dropped), rows Live wrote after the first pass brought over, schema drift refused.-- */--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const { openSqlite } = require('../scripts/lib/sqlite');--const db = require('../server/db/database');--const { suite } = require('./helpers');----const t = suite('import');--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-import-'));--const liveDb = path.join(tmp, 'live-snapshot.db');--const SUBJ = 'usr_01J9SUBJECTSUBJECTSUBJECTS';----// The importer's command line (scripts/import-from-live.js cli), in this process on this test's database.--async function importer(args) {-- const cwd = process.cwd();-- process.chdir(tmp); // the default backup lands in ./data of the working directory-- let r;-- try { r = await require('../scripts/import-from-live').cli(['--live-db', liveDb, ...args]); } finally { process.chdir(cwd); }-- let report = null;-- try { report = JSON.parse(r.stdout); } catch { /* */ }-- return { code: r.code, report, stderr: r.stderr, stdout: r.stdout };--}--/** Chat's database, read by this process. */--const chatDb = () => db.getDb();--const lastId = async (t) => Number((await chatDb().prepare("SELECT pg_sequence_last_value(pg_get_serial_sequence(?, 'id')) AS v").get(t)).v);----let live;--t('build a Live snapshot with every chat table', async () => {-- await db.initDb();-- live = openSqlite(liveDb, { readonly: false, create: true });-- live.pragma('foreign_keys = OFF');-- live.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'live-chat-schema.sql'), 'utf8'));-- live.exec('ALTER TABLE channel_sounds ADD COLUMN media_url TEXT; ALTER TABLE channel_sounds ADD COLUMN media_asset_id INTEGER;');-- const u = live.prepare("INSERT INTO users (id, username, password_hash, display_name, role) VALUES (?, ?, '!', ?, ?)");-- await u.run(1, 'streamer', 'Streamer', 'streamer'); await u.run(2, 'alice', 'Alice', 'user'); await u.run(3, 'bob', 'Bob', 'user');-- await live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '77', ?)").run(SUBJ);-- await live.prepare("INSERT INTO channels (id, user_id, title) VALUES (5, 1, 'TV')").run();-- await live.prepare("INSERT INTO managed_streams (id, user_id, channel_id, slug, title, stream_key) VALUES (9, 1, 5, 'main', 'Main', 'k')").run();-- await live.prepare("INSERT INTO streams (id, user_id, channel_id, managed_stream_id, title, is_live) VALUES (40, 1, 5, 9, 'Show', 1)").run();-- const msg = live.prepare('INSERT INTO chat_messages (id, stream_id, channel_user_id, user_id, anon_id, username, message, message_type, metadata, is_global, is_deleted, reply_to_id, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');-- await msg.run(101, 40, 1, 2, null, 'Alice', 'first', 'chat', null, 0, 0, null, '2026-09-01 10:00:00');-- await msg.run(102, 40, 1, 3, null, 'Bob', 'reply', 'chat', null, 0, 0, 101, '2026-09-01 10:00:05');-- await msg.run(103, null, null, null, 'anon4', 'anon4', 'global hi', 'chat', null, 1, 0, null, '2026-09-01 10:01:00');-- await msg.run(104, 40, 1, null, null, 'Donor', 'tipped 5 Vibes', 'donation', '{"kind":"donation","amount":5}', 0, 0, null, '2026-09-01 10:02:00');-- await msg.run(105, 40, 1, 2, null, 'Alice', 'deleted one', 'chat', null, 0, 1, null, '2026-09-01 10:03:00');-- await live.prepare("INSERT INTO dm_conversations (id, created_by, created_at) VALUES (7, 2, '2026-09-02 00:00:00')").run();-- await live.prepare('INSERT INTO dm_participants (conversation_id, user_id) VALUES (7, 2), (7, 3)').run();-- await live.prepare("INSERT INTO dm_messages (id, conversation_id, sender_id, message, created_at) VALUES (70, 7, 2, 'psst', '2026-09-02 00:00:01')").run();-- await live.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id) VALUES (3, 1)').run();-- await live.prepare("INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed) VALUES ('user:alice', 'en+f3', 60, 170)").run();-- await live.prepare("INSERT INTO channel_sounds (id, channel_owner_id, command, url, created_by, media_url, media_asset_id) VALUES (11, 1, 'honk', '/opt/openvibe.live/data/sounds/snd-1.mp3', 2, 'https://openvibe.media/a/1', 555)").run();-- await live.prepare("INSERT INTO relay_users (platform, username, display_name, message_count) VALUES ('twitch', 'zed', 'Zed', 3)").run();-- await live.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username) VALUES (5, 'twitch', 'spammer')").run();-- await live.prepare("INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, username, message) VALUES (5, 40, '203.0.113.5', 'anon9', 'let me in')").run();-- await live.prepare("INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES ('user:2', 1)").run();-- await live.prepare("INSERT INTO moderation_actions (scope_type, actor_user_id, target_user_id, action_type, details) VALUES ('channel', 2, 3, 'channel_ban', '{}')").run();-- await live.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (5, 2, 1)').run();-- await live.prepare('INSERT INTO channel_moderation_settings (channel_id, slow_mode_seconds) VALUES (5, 3)').run();-- await live.prepare("INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (1, 'pog', '/e/pog.png', 1)").run();-- await live.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (2, 'og')").run();-- await live.prepare("INSERT INTO chat_ai_summaries (scope, subject_id, window, overview) VALUES ('user', 2, '7d', 'nice')").run();-- await live.prepare("INSERT INTO chat_timeline_events (scope, ts, label) VALUES ('global', '2026-09-01 10:00:00', 'hype')").run();-- await live.prepare("INSERT INTO media_requests (streamer_id, user_id, username, input, canonical_url, provider, title) VALUES (1, 2, 'Alice', 'x', 'https://youtu.be/x', 'youtube', 'X')").run();-- live.exec("CREATE TABLE chat_messages_new AS SELECT * FROM chat_messages WHERE id = 101");--});----t('dry run reports and writes nothing (the default; --dry-run says it too)', async () => {-- for (const args of [[], ['--dry-run']]) {-- const r = await importer(args);-- assert.strictEqual(r.code, 0, r.stderr);-- assert.strictEqual(r.report.dry_run, true);-- assert.strictEqual(r.report.backup, null);-- }-- const r = await importer([]);-- assert.strictEqual(r.report.tables.chat_messages.inserted, 5);-- assert.strictEqual(r.report.tables.chat_messages_new.held, 1);-- const c = chatDb();-- assert.strictEqual((await c.prepare('SELECT COUNT(*) AS n FROM chat_messages').get()).n, 0);-- assert.strictEqual((await c.prepare('SELECT COUNT(*) AS n FROM import_hold').get()).n, 0);-- assert.strictEqual((await c.prepare('SELECT COUNT(*) AS n FROM import_runs').get()).n, 0);--});----t('import copies every table with ids kept, subjects filled, headroom set', async () => {-- const r = await importer(['--apply', '--headroom', '1000']);-- assert.strictEqual(r.code, 0, r.stderr);-- assert.ok(r.report.backup && fs.existsSync(r.report.backup), 'backed up first');-- const b = JSON.parse(fs.readFileSync(r.report.backup, 'utf8'));-- assert.strictEqual(b.tables.chat_messages.length, 0, 'the backup is the database before the run');-- const T = r.report.tables;-- const expect = { chat_messages: 5, dm_conversations: 1, dm_participants: 2, dm_messages: 1, dm_blocks: 1, tts_voice_overrides: 1, channel_sounds: 1, relay_users: 1, hidden_relay_users: 1, pending_ip_messages: 1, stream_first_chats: 1, moderation_actions: 1 };-- for (const [tbl, n] of Object.entries(expect)) {-- assert.strictEqual(T[tbl].live, n, `${tbl} live`);-- assert.strictEqual(T[tbl].inserted, n, `${tbl} inserted`);-- }-- assert.strictEqual(T.media_requests.not_moved.startsWith('stays in Live'), true);-- assert.strictEqual(T.chat_messages_new.held, 1);-- assert.strictEqual(T.chat_messages.next_id, 105 + 1000 + 1);-- const c = chatDb();-- const m = await c.prepare('SELECT * FROM chat_messages WHERE id = 102').get();-- assert.strictEqual(m.reply_to_id, 101);-- assert.strictEqual(m.user_id, 3);-- assert.strictEqual((await c.prepare('SELECT subject_id FROM chat_messages WHERE id = 101').get()).subject_id, SUBJ);-- assert.strictEqual((await c.prepare('SELECT is_deleted FROM chat_messages WHERE id = 105').get()).is_deleted, 1, 'deleted rows are kept, still deleted');-- assert.strictEqual((await c.prepare('SELECT metadata FROM chat_messages WHERE id = 104').get()).metadata, '{"kind":"donation","amount":5}');-- assert.strictEqual((await c.prepare('SELECT sender_subject_id FROM dm_messages WHERE id = 70').get()).sender_subject_id, SUBJ);-- assert.strictEqual((await c.prepare('SELECT actor_subject_id FROM moderation_actions').get()).actor_subject_id, SUBJ);-- assert.strictEqual(await lastId('chat_messages'), 1105);-- const hold = await c.prepare('SELECT * FROM import_hold').all();-- assert.strictEqual(hold.length, 1);-- assert.strictEqual(hold[0].source_table, 'chat_messages_new');-- assert.match(hold[0].reason, /transient migration table/);-- assert.strictEqual((await c.prepare('SELECT username FROM ctx_users WHERE id = 2').get()).username, 'alice', 'projections seeded');-- assert.strictEqual((await c.prepare('SELECT subject_id FROM ctx_users WHERE id = 2').get()).subject_id, SUBJ);-- assert.strictEqual((await c.prepare('SELECT slug FROM ctx_managed_streams WHERE id = 9').get()).slug, 'main');-- assert.strictEqual((await c.prepare('SELECT COUNT(*) AS n FROM live_mirror_outbox').get()).n, 0, 'imported rows are never mirrored back to Live');-- assert.strictEqual((await c.prepare('SELECT COUNT(*) AS n FROM events_outbox').get()).n, 0, 'imported history is not re-announced');--});----t('re-running is idempotent', async () => {-- const r = await importer(['--apply', '--no-backup', '--headroom', '1000']);-- assert.strictEqual(r.report.backup, null);-- assert.strictEqual(r.code, 0, r.stderr);-- for (const [tbl, v] of Object.entries(r.report.tables)) {-- if (v.inserted !== undefined) assert.strictEqual(v.inserted, 0, `${tbl} inserted again`);-- }-- assert.strictEqual(r.report.tables.chat_messages.identical, 5);-- assert.strictEqual(r.report.held_total, 1, 'holds are not duplicated');--});----t('after the cutover: Chat’s edits win, conflicts are held, late Live rows come over', async () => {-- const c = chatDb();-- await c.prepare('UPDATE chat_messages SET is_deleted = 1 WHERE id = 101').run(); // a moderator deleted it in Chat-- await c.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (106, 3, 'Bob', 'written in chat')").run();-- await live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (106, 2, 'Alice', 'a different 106', '2026-09-03 00:00:00')").run();-- await live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (107, 2, 'Alice', 'written in Live after the first pass', '2026-09-03 00:00:01')").run();-- const r = await importer(['--apply', '--no-backup', '--headroom', '1000']);-- assert.strictEqual(r.code, 0, r.stderr);-- const T = r.report.tables;-- assert.strictEqual(T.chat_messages.chat_kept, 1);-- assert.strictEqual(T.chat_messages.held, 1);-- assert.strictEqual(T.chat_messages.inserted, 1);-- const d = chatDb();-- assert.strictEqual((await d.prepare('SELECT is_deleted FROM chat_messages WHERE id = 101').get()).is_deleted, 1);-- assert.strictEqual((await d.prepare('SELECT message FROM chat_messages WHERE id = 106').get()).message, 'written in chat');-- assert.strictEqual((await d.prepare('SELECT message FROM chat_messages WHERE id = 107').get()).message, 'written in Live after the first pass');-- const held = await d.prepare("SELECT * FROM import_hold WHERE source_table = 'chat_messages'").get();-- assert.match(held.reason, /different row/);-- assert.strictEqual(JSON.parse(held.row_json).message, 'a different 106');-- assert.strictEqual(await lastId('chat_messages'), 1107, 'headroom follows Live’s new max, never lowered');--});----t('schema drift is refused before anything is written', async () => {-- live.exec('ALTER TABLE chat_messages ADD COLUMN surprise TEXT');-- await live.prepare("INSERT INTO chat_messages (id, username, message, surprise) VALUES (108, 'x', 'y', 'z')").run();-- const r = await importer([]);-- assert.strictEqual(r.code, 1);-- assert.match(r.stderr, /chat_messages: surprise/);-- const d = chatDb();-- assert.strictEqual((await d.prepare('SELECT COUNT(*) AS n FROM chat_messages WHERE id = 108').get()).n, 0);--});----t.run(() => { try { live.close(); } catch { /* */ } fs.rmSync(tmp, { recursive: true, force: true }); });@@@ -20,7 +20,7 @@ const { boot, suite } = require('./helpers');const t = suite('message-deleted');const EVENTS_DIR = process.env.OPENVIBE_EVENTS_DIR || path.join(__dirname, '..', '..', 'OpenVibe.Events');--const haveEvents = ['server/index.js', 'server/redaction.js', 'node_modules/better-sqlite3'].every((f) => fs.existsSync(path.join(EVENTS_DIR, f)));++const haveEvents = ['server/index.js', 'server/redaction.js', 'package.json'].every((f) => fs.existsSync(path.join(EVENTS_DIR, f)));const SECRET = 'my number is 555-0199';let h, events, eventsDb, eventsPort, admin, alice, streamer, streamId;@@@ -64,12 +64,11 @@ t('boot (a real OpenVibe.Events on a reserved port when one is checked out nextconst { load } = require(path.join(EVENTS_DIR, 'server', 'config'));const { start } = require(path.join(EVENTS_DIR, 'server', 'index'));const quiet = { log() {}, warn() {}, error() {} };-- // Events is on PostgreSQL (ADR-035): its own test/db.js hands out a migrated database (PGlite, or the-- // containers under EVENTS_TEST_STORE=pg); an older SQLite checkout ignores the handle and uses the path.++ // Events' own test/db.js hands out a migrated database when available.const dbHelper = path.join(EVENTS_DIR, 'test', 'db.js');eventsDb = fs.existsSync(dbHelper) ? await require(dbHelper).testDb() : null;events = await start({-- config: load({ NODE_ENV: 'test', PORT: String(eventsPort), EVENTS_DB_PATH: path.join(h.tmp, 'events.db'), OV_NETWORK_PUBLIC_KEY: h.keys.publicKey, EVENTS_WORKER: 'off' }),++ config: load({ NODE_ENV: 'test', PORT: String(eventsPort), OV_NETWORK_PUBLIC_KEY: h.keys.publicKey, EVENTS_WORKER: 'off' }),db: eventsDb ? eventsDb.db : null,log: quiet,});ddeleted file mode 100644@@@ -1,159 +0,0 @@--'use strict';--/**-- * scripts/migrate-chat-preferences.js (server/prefs/from-live.js): Live's user_preferences.chat_settings-- * → the Network user module chat.preferences, through Network with Chat's service token.-- *-- * - mapping: only choices move (showTimestamps, compactMode, fontSize, showBadges); defaults-only rows,-- * rows without a Network subject and unreadable rows are counted and skipped-- * - dry run by default: reads Network, writes nothing-- * - --apply requires --backup (a new file); the backup is written, 0600, before any write and gets-- * each creation's revision; writes are create-only (If-Match: 0), so a second run creates nothing-- * - --rollback deletes what the run created, keeping records changed since-- * - the command line: refusals exit 2, a dry run through the real script exits 0-- */--const assert = require('assert');--const fs = require('fs');--const path = require('path');--const { spawn } = require('child_process');--const { openSqlite } = require('../scripts/lib/sqlite');--const { boot, suite } = require('./helpers');----const t = suite('migrate-preferences');--const NS = 'chat.preferences';--const S = {-- ann: 'usr_01J9ANN0000000000000000AAA', eve: 'usr_01J9EVE0000000000000000AAA', fay: 'usr_01J9FAY0000000000000000AAA',-- gus: 'usr_01J9GVS0000000000000000AAA', gusOld: 'usr_01J9GVS0000000000000000ZZZ', bob: 'usr_01J9B0B0000000000000000AAA',--};--const SCRIPT = path.join(__dirname, '..', 'scripts', 'migrate-chat-preferences.js');--let h, mig, livePath;--const liveDefaults = { showTimestamps: false, timestampFormat: '12h', fontSize: 'default', showAvatars: true, showBadges: true, compactMode: false, ttsEnabled: true, ttsVolume: 80, ttsSendModeV2: true };--const record = (subject) => h.netModules.records.get(`${NS}|${subject}`) || null;--const puts = () => h.netModules.calls.filter((c) => c.method === 'PUT');--// Async: the stub Network answers from this process, so it must keep running while the script does.--const run = (args) => new Promise((resolve) => {-- const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: path.join(__dirname, '..'), env: process.env });-- let stdout = ''; let stderr = '';-- child.stdout.on('data', (c) => { stdout += c; });-- child.stderr.on('data', (c) => { stderr += c; });-- child.on('close', (status) => resolve({ status, stdout, stderr }));--});----t('a Live database with every kind of row', async () => {-- h = await boot();-- mig = require('../server/prefs/from-live');-- livePath = path.join(h.tmp, 'live.db');-- const live = openSqlite(livePath, { readonly: false, create: true });-- live.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT);-- CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);-- CREATE TABLE user_preferences (user_id INTEGER PRIMARY KEY, chat_settings TEXT DEFAULT '{}', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);-- const add = async (id, name, settings, links) => {-- await live.prepare('INSERT INTO users (id, username) VALUES (?, ?)').run(id, name);-- for (const [service, subject] of links) await live.prepare('INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, ?, ?, ?)').run(id, service, `x${id}`, subject);-- await live.prepare('INSERT INTO user_preferences (user_id, chat_settings) VALUES (?, ?)').run(id, typeof settings === 'string' ? settings : JSON.stringify(settings));-- };-- await add(1, 'ann', { ...liveDefaults, showTimestamps: true, fontSize: 'large', showBadges: false }, [['network', S.ann]]);-- await add(2, 'bob', liveDefaults, [['network', S.bob]]); // defaults only-- await add(3, 'cat', { ...liveDefaults, compactMode: true }, []); // no subject-- await add(4, 'dan', '{not json', [['network', 'usr_01J9DAN0000000000000000AAA']]); // unreadable-- await add(5, 'eve', { ...liveDefaults, compactMode: true, fontSize: 'small' }, [['network', S.eve]]); // has a record already-- await add(6, 'fay', { compactMode: true }, [['network', S.fay]]); // a partial old blob-- await add(7, 'gus', { ...liveDefaults, fontSize: 'small' }, [['hobostreamer', S.gusOld], ['network', S.gus]]); // the network link wins-- live.close();-- for (const s of Object.values(S)) h.netModules.subjects.add(s);-- h.netModules.set(NS, S.eve, { hide_emotes: true });--});----t('the mapping keeps choices only', () => {-- assert.deepStrictEqual(mig.fromLiveChatSettings({ ...liveDefaults, showTimestamps: true, fontSize: 'large', showBadges: false, compactMode: true }),-- { timestamps: true, compact: true, font_scale: 1.18, show_badges: false });-- assert.deepStrictEqual(mig.fromLiveChatSettings(liveDefaults), {});-- assert.deepStrictEqual(mig.fromLiveChatSettings({ fontSize: 'small', showTimestamps: 'yes' }), { font_scale: 0.88 });-- assert.deepStrictEqual(mig.fromLiveChatSettings(null), {});--});----t('plan: counts and targets from Live, read-only', () => {-- const live = openSqlite(livePath);-- const p = mig.plan(live);-- live.close();-- assert.deepStrictEqual(p.counts, { live_rows: 7, unreadable: 1, no_subject: 1, defaults_only: 1, invalid: 0, candidates: 4, fields: { timestamps: 1, font_scale: 3, show_badges: 1, compact: 2 } });-- assert.deepStrictEqual(p.rows.map((r) => [r.live_user_id, r.subject]), [[1, S.ann], [5, S.eve], [6, S.fay], [7, S.gus]]);-- assert.deepStrictEqual(p.rows[0].data, { timestamps: true, font_scale: 1.18, show_badges: false });--});----t('the command line refuses unsafe runs (exit 2) and nothing is written', async () => {-- let r = await run(['--live-db', livePath, '--apply']);-- assert.strictEqual(r.status, 2, r.stderr); assert.ok(r.stderr.includes('--backup'));-- const existing = path.join(h.tmp, 'exists.json');-- fs.writeFileSync(existing, '{}');-- r = await run(['--live-db', livePath, '--apply', '--backup', existing]);-- assert.strictEqual(r.status, 2); assert.ok(r.stderr.includes('exists already'));-- r = await run([]);-- assert.strictEqual(r.status, 2);-- r = await run(['--live-db', path.join(h.tmp, 'nope.db')]);-- assert.strictEqual(r.status, 2);-- assert.strictEqual(puts().length, 0);--});----t('dry run through the real script: a report, reads only', async () => {-- const r = await run(['--live-db', livePath]);-- assert.strictEqual(r.status, 0, r.stderr);-- const report = JSON.parse(r.stdout);-- assert.strictEqual(report.mode, 'dry-run');-- assert.strictEqual(report.exists, 1, 'eve has one');-- assert.strictEqual(report.would_create, 3);-- assert.strictEqual(puts().length, 0);-- assert.ok(h.tokenRequests.length, 'the script asked Network for a token as chat');--});----t('--apply --backup: backup first (0600), create-only writes, revisions recorded', async () => {-- const backup = path.join(h.tmp, 'prefs-backup-1.json');-- const live = openSqlite(livePath);-- const planned = mig.plan(live);-- live.close();-- await assert.rejects(mig.migrate(planned, { apply: true }), /--backup/);-- h.netModules.beforeWrite = () => {-- const doc = JSON.parse(fs.readFileSync(backup, 'utf8'));-- assert.strictEqual(doc.targets.length, 3, 'the backup exists before the first write');-- assert.strictEqual(doc.results, null);-- };-- const report = await mig.migrate(planned, { apply: true, backup, liveDbPath: livePath });-- assert.deepStrictEqual([report.created, report.exists, report.raced, report.refused], [3, 1, 0, 0]);-- assert.ok(puts().every((c) => c.ifMatch === '"0"'), 'create-only');-- assert.deepStrictEqual(record(S.ann).data, { timestamps: true, font_scale: 1.18, show_badges: false });-- assert.strictEqual(record(S.ann).updated_by, 'svc:chat');-- assert.deepStrictEqual(record(S.gus).data, { font_scale: 0.88 });-- assert.deepStrictEqual(record(S.eve).data, { hide_emotes: true }, 'an existing record is never overwritten');-- assert.strictEqual(record(S.gusOld), null, 'the network link is the subject');-- assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600);-- const doc = JSON.parse(fs.readFileSync(backup, 'utf8'));-- assert.deepStrictEqual(doc.targets.map((x) => [x.subject, x.before]), [[S.ann, null], [S.fay, null], [S.gus, null]]);-- assert.deepStrictEqual(doc.results.map((x) => [x.subject, x.outcome, x.revision]), [[S.ann, 'created', 1], [S.fay, 'created', 1], [S.gus, 'created', 1]]);--});----t('a second run creates nothing', async () => {-- const n = puts().length;-- const r = await run(['--live-db', livePath, '--apply', '--backup', path.join(h.tmp, 'prefs-backup-2.json')]);-- assert.strictEqual(r.status, 0, r.stderr);-- const report = JSON.parse(r.stdout);-- assert.deepStrictEqual([report.created, report.exists, report.would_create], [0, 4, 0]);-- assert.strictEqual(puts().length, n);--});----t('--rollback deletes what the run created, keeps what changed since', async () => {-- h.netModules.set(NS, S.fay, { compact: false }); // fay changed hers after the migration-- const backup = path.join(h.tmp, 'prefs-backup-1.json');-- let r = await run(['--rollback', backup]);-- assert.strictEqual(r.status, 0, r.stderr);-- assert.deepStrictEqual((({ would_delete, changed_since, deleted }) => ({ would_delete, changed_since, deleted }))(JSON.parse(r.stdout)), { would_delete: 2, changed_since: 1, deleted: 0 });-- assert.ok(record(S.ann), 'dry run');-- r = await run(['--rollback', backup, '--apply']);-- assert.strictEqual(r.status, 0, r.stderr);-- const report = JSON.parse(r.stdout);-- assert.deepStrictEqual([report.deleted, report.changed_since, report.gone], [2, 1, 0]);-- assert.strictEqual(record(S.ann), null); assert.strictEqual(record(S.gus), null);-- assert.deepStrictEqual(record(S.fay).data, { compact: false });-- assert.deepStrictEqual(record(S.eve).data, { hide_emotes: true }, 'never the migration\'s');-- assert.ok(h.netModules.calls.filter((c) => c.method === 'DELETE').every((c) => /^"\d+"$/.test(c.ifMatch)), 'deletes name the revision');--});----t.run(async () => { if (h) await h.close(); });@@@ -36,16 +36,7 @@ const FIX = path.join(__dirname, 'fixtures', 'n-1');const client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.json'), 'utf8'));--// ── Skipped for the engine-switch release only: SQLite → PostgreSQL (plan T3, decision 6) ─────────--// This release serves from PostgreSQL (/ready says pglite or postgresql, never a SQLite file), so an N-1 fixture--// recorded from a release on *SQLite* — its statements in SQLite's dialect, its schema from sqlite_master — cannot be--// prepared or replayed here. After this release deploys, `npm run n-1:record` records it (engine 'postgresql': the--// migrations it applied and its statements as PostgreSQL received them) and the replay below resumes on its own.--// Until then the run reports itself skipped (the ○ convention: neither green nor a failure). Keep the harness.--if (worker.engine !== 'postgresql') {-- console.log('n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys)');-- process.exit(0);--}++if (worker.engine !== 'postgresql') throw new Error('N-1 fixture must use PostgreSQL');const { createDb } = require('openvibe-sdk/db');const quiet = { log() {}, info() {}, warn() {}, error: (...a) => console.error(...a) };@@@ -1,8 +1,7 @@'use strict';/*** Runs inside a Chat release (its directory is the cwd): boots it with that release's own-- * test/helpers.js (a stub Network and a stub Live, Chat in-process) on the database in N1_DB (a SQLite file for a-- * release on SQLite, a PGlite directory for one on PostgreSQL), seeds it,++ * test/helpers.js (a stub Network and a stub Live, Chat in-process) on the database in N1_DB (a PGlite directory), seeds it,* and prints one line `{"n1": { url, token, ids }}`. SIGTERM stops it (Chat's own shutdown).*/const path = require('path');@@@ -12,7 +11,7 @@ const path = require('path');const quiet = () => {};console.warn = quiet;const helpers = require(path.join(dir, 'test', 'helpers'));-- const h = await helpers.boot({ env: { CHAT_DB_PATH: process.env.N1_DB, CHAT_PGLITE_DIR: process.env.N1_DB } });++ const h = await helpers.boot({ env: { CHAT_PGLITE_DIR: process.env.N1_DB } });process.on('exit', () => { try { require('fs').rmSync(h.tmp, { recursive: true, force: true }); } catch { /* */ } });const star = h.addUser('n1star', { role: 'streamer' });const fan = h.addUser('n1fan');ddeleted file mode 100644@@@ -1,12 +0,0 @@--'use strict';--/** Which engine a Chat release (a checkout's directory) serves from: PostgreSQL from plan T3 on (migrations/, no better-sqlite3). */--const fs = require('fs');--const path = require('path');----function onPostgres(dir) {-- let deps = {};-- try { deps = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).dependencies || {}; } catch { /* */ }-- return !deps['better-sqlite3'] && fs.existsSync(path.join(dir, 'migrations'));--}----module.exports = { onPostgres };@@@ -19,7 +19,7 @@* their types).** SQL is every statement N-1 prepared while it served those calls, plus every literal SQL string in its-- * server code that prepares on its own schema. The N-1 schema is its sqlite_master after the replay.++ * server code that prepares on its own schema.*/const fs = require('fs');const os = require('os');@@@ -722,33 +722,6 @@ function normalizeSql(sql) {return stripSqlComments(sql).replace(/\s+/g, ' ').trim();}--/**-- * The DDL N-1 runs itself when it first needs a table or column (a module's CREATE TABLE IF NOT EXISTS,-- * a boot-time ALTER TABLE … ADD COLUMN) that `db` does not have yet. Each statement is applied to `db`-- * as it is found to apply; → the applied statements, tables first, in order.-- */--function lazyDDL(files, db) {-- const tables = () => new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));-- const columns = (t) => { try { return new Set(db.prepare(`PRAGMA table_xinfo("${t}")`).all().map((c) => c.name.toLowerCase())); } catch { return new Set(); } };-- const lits = stringLiterals(files).map(stripSqlComments);-- const applied = [];-- const apply = (ddl) => { try { db.exec(ddl); applied.push(ddl); return true; } catch { return false; } };-- for (const text of lits) {-- for (const m of text.matchAll(/CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+["`]?(\w+)["`]?\s*\(/gi)) {-- // A rebuild's scratch table (x_new) is the migration's own business, not a table N-1 uses.-- if (tables().has(m[1]) || /_(new|old|tmp|backup)$/i.test(m[1])) continue;-- const end = skipBalanced(text, m.index + m[0].length - 1, '(', ')');-- if (end >= 0) apply(normalizeSql(text.slice(m.index, end + 1)));-- }-- }-- for (const text of lits) {-- const m = /^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+(?:COLUMN\s+)?["`]?(\w+)["`]?[^;]*$/i.exec(text.trim().replace(/;$/, ''));-- if (!m || !tables().has(m[1]) || columns(m[1]).has(m[2].toLowerCase())) continue;-- apply(normalizeSql(text).replace(/;$/, ''));-- }-- return applied;--}--/** Every file under dir with one of the extensions, as { name (relative to root), text }. */function readTree(root, dirs, exts = ['.js']) {const out = [];@@@ -768,50 +741,6 @@ function readTree(root, dirs, exts = ['.js']) {return out.sort((a, b) => (a.name < b.name ? -1 : 1));}--/** The schema as DDL, tables first (no internal or FTS shadow tables), then indexes, triggers and views. */--function schemaDDL(db) {-- const shadow = new Set(db.prepare('PRAGMA table_list').all().filter((t) => t.type === 'shadow').map((t) => t.name));-- const rows = db.prepare("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'").all()-- .filter((r) => !shadow.has(r.name));-- const order = { table: 0, index: 1, view: 2, trigger: 3 };-- return rows.sort((a, b) => order[a.type] - order[b.type] || (a.name < b.name ? -1 : 1)).map((r) => r.sql);--}----/** Prepares every statement on db (a missing table or column fails the prepare). → [{ sql, error }] */--function prepareProblems(db, statements) {-- const problems = [];-- for (const sql of statements) {-- try { db.prepare(sql); } catch (e) { problems.push({ sql, error: e.message }); }-- }-- return problems;--}----/**-- * INSERTs of N-1 that N would refuse: a column N made NOT NULL without a default that the INSERT does-- * not name. → [{ sql, error }]-- */--function insertProblems(db, statements) {-- const problems = [];-- const cols = new Map();-- const required = (table) => {-- if (!cols.has(table)) {-- let info = [];-- try { info = db.prepare(`PRAGMA table_xinfo("${table.replace(/"/g, '""')}")`).all(); } catch { info = []; }-- const pkInt = info.filter((c) => c.pk).length === 1 && info.find((c) => c.pk && /^INTEGER$/i.test(c.type));-- cols.set(table, info.filter((c) => c.notnull && c.dflt_value == null && !c.hidden && !(pkInt && c.pk)).map((c) => c.name));-- }-- return cols.get(table);-- };-- for (const sql of statements) {-- const m = /^\s*(?:INSERT|REPLACE)(?:\s+OR\s+\w+)?\s+INTO\s+["`]?(\w+)["`]?\s*\(([^)]*)\)\s*(VALUES|SELECT)/i.exec(sql);-- if (!m) continue;-- const named = new Set(m[2].split(',').map((c) => c.trim().replace(/^["`]|["`]$/g, '').toLowerCase()));-- const missing = required(m[1]).filter((c) => !named.has(c.toLowerCase()));-- if (missing.length) problems.push({ sql, error: `${m[1]}.${missing.join(', ')} is NOT NULL without a default, and N-1 does not set it` });-- }-- return problems;--}--// ── PostgreSQL (a release on openvibe-sdk/db, plan T3) ─────────/**@@@ -899,7 +828,7 @@ module.exports = {flatten, readsOf, readProblems, statusCompatible,send, requestsFor, notFoundProbe, isNoRoute, callProblems,wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,-- stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,++ stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, readTree,worktree, gitFiles, summarize,pgMigrations, pgPrepareProblems, pgInsertProblems,};@@@ -2,16 +2,12 @@/*** Loaded with `node -r` into a release booted by test/n-1/service.js (N-1 at record time, N in the* test), from that release's directory. With N1_SQL_OUT set, every SQL text the process prepares or-- * executes on the chat database is written there as JSON when it exits: on a release on SQLite, what-- * better-sqlite3 prepares on CHAT_DB_PATH; on a release on PostgreSQL (openvibe-sdk/db, no better-sqlite3),-- * the statements its handles run, as PostgreSQL received them ($n parameters).++ * executes on the chat database is written as PostgreSQL SQL.*/const fs = require('fs');--const path = require('path');const out = process.env.N1_SQL_OUT;--const onPostgres = out && require('./engine').onPostgres(process.cwd());--if (out && onPostgres) {++if (out) {const sdkDb = require(require.resolve('openvibe-sdk/db', { paths: [process.cwd()] }));const seen = new Set();const createDb = sdkDb.createDb;@@@ -25,15 +21,4 @@ if (out && onPostgres) {return d;};process.on('exit', () => { try { fs.writeFileSync(out, JSON.stringify([...seen])); } catch { /* */ } });--} else if (out) {-- const Database = require(path.join(process.cwd(), 'node_modules', 'better-sqlite3'));-- const seen = new Set();-- const note = (db, sql) => {-- try { if (process.env.CHAT_DB_PATH && path.resolve(db.name) === path.resolve(process.env.CHAT_DB_PATH)) seen.add(String(sql)); } catch { /* */ }-- };-- const prepare = Database.prototype.prepare;-- Database.prototype.prepare = function (sql, ...rest) { note(this, sql); return prepare.call(this, sql, ...rest); };-- const exec = Database.prototype.exec;-- Database.prototype.exec = function (sql, ...rest) { note(this, sql); return exec.call(this, sql, ...rest); };-- process.on('exit', () => { try { fs.writeFileSync(out, JSON.stringify([...seen])); } catch { /* */ } });}@@@ -1,9 +1,5 @@'use strict';--/**-- * Chat serves from PostgreSQL only (plan T3, decision 11): better-sqlite3 is not a dependency, and nothing the-- * service loads opens SQLite. Reading a SQLite file is left to the one-time tools under scripts/ (Live's snapshot,-- * the production import), through node:sqlite in scripts/lib/sqlite.js.-- */++/** PostgreSQL is the serving store; completed SQLite tooling and file settings are gone. */const assert = require('assert');const fs = require('fs');const path = require('path');@@@ -11,36 +7,61 @@ const { suite } = require('./helpers');const t = suite('no-sqlite');const ROOT = path.join(__dirname, '..');++const at = (name) => path.join(ROOT, name);++const source = (name) => fs.readFileSync(at(name), 'utf8');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;++ if (!fs.existsSync(dir)) return [];++ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {++ const file = path.join(dir, entry.name);++ return entry.isDirectory() ? files(file) : [file];++ });}--t('package.json: better-sqlite3 is neither a dependency nor a dev dependency', () => {-- const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));-- for (const k of ['dependencies', 'devDependencies', 'optionalDependencies']) assert.ok(!(pkg[k] || {})['better-sqlite3'], `${k} lists better-sqlite3`);-- assert.ok(pkg.dependencies.pg && pkg.dependencies['openvibe-sdk'], 'pg and openvibe-sdk serve');++t('package dependencies use PostgreSQL and do not include SQLite drivers', () => {++ const pkg = JSON.parse(source('package.json'));++ for (const group of ['dependencies', 'devDependencies', 'optionalDependencies']) {++ for (const name of ['better-sqlite3', 'sqlite3']) assert.ok(!(pkg[group] || {})[name], `${group} lists ${name}`);++ }++ assert.ok(pkg.dependencies.pg && pkg.dependencies['openvibe-sdk']);++ assert.ok(pkg.devDependencies['@electric-sql/pglite']);++ assert.ok(!pkg.scripts['import-from-live']);++});++++t('removed tools, tests, and runbooks are absent', () => {++ for (const name of [++ 'scripts/import-from-live.js', 'scripts/import-sqlite-to-pg.js', 'scripts/migrate-chat-preferences.js',++ 'scripts/parity-check.js', 'scripts/lib/sqlite.js', 'server/prefs/from-live.js',++ 'test/import.test.js', 'test/import-pg.test.js', 'test/migrate-preferences.test.js',++ 'test/rehearsal.test.js', 'test/cutover-evidence.test.js', 'docs/cutover.md',++ 'docs/cutover-evidence-t3.md', 'docs/pg-port',++ ]) assert.ok(!fs.existsSync(at(name)), `${name} still exists`);++});++++t('service and configuration do not open SQLite files', () => {++ for (const file of files(at('server')).filter((f) => /\.js$/.test(f))) {++ assert.doesNotMatch(fs.readFileSync(file, 'utf8'), /require\(['"](?:better-sqlite3|sqlite3|node:sqlite|openvibe-sdk\/sqlite-import|openvibe-sdk\/sqlite-cli)['"]\)/, file);++ assert.doesNotMatch(fs.readFileSync(file, 'utf8'), /\b(?:[A-Z_]*DB_PATH)\b/, file);++ }++ for (const name of ['.env.example', 'deploy/systemd/openvibe-chat.service', 'README.md']) {++ assert.doesNotMatch(source(name), /\b(?:[A-Z_]*DB_PATH)\b/, name);++ }++ for (const file of files(at('scripts')).filter((f) => /\.js$/.test(f))) {++ assert.doesNotMatch(fs.readFileSync(file, 'utf8'), /require\(['"](?:better-sqlite3|sqlite3|node:sqlite)['"]\)/, file);++ }});--t('nothing under server/ requires better-sqlite3 or node:sqlite', () => {-- const offenders = files(path.join(ROOT, 'server'))-- .filter((f) => /require\(\s*['"](better-sqlite3|node:sqlite|sqlite3)['"]\s*\)|from\s+['"](better-sqlite3|node:sqlite)['"]/.test(fs.readFileSync(f, 'utf8')))-- .map((f) => path.relative(ROOT, f));-- assert.deepStrictEqual(offenders, []);++t('current docs and deployment files do not refer to retired tools or the Chat file setting', () => {++ const retired = /CHAT_DB_PATH|scripts\/(?:import-from-live|import-sqlite-to-pg|migrate-chat-preferences|parity-check)\.js|docs\/(?:cutover(?:-evidence-t3)?\.md|pg-port\/|live-patch\.diff)/;++ for (const name of ['README.md', 'STATUS.json', '.env.example', 'deploy/systemd/openvibe-chat.service']) {++ assert.doesNotMatch(source(name), retired, name);++ }++ for (const file of files(at('docs'))) assert.doesNotMatch(fs.readFileSync(file, 'utf8'), retired, file);});--t('scripts read SQLite only through scripts/lib/sqlite.js', () => {-- const offenders = files(path.join(ROOT, 'scripts'))-- .filter((f) => path.relative(ROOT, f) !== path.join('scripts', 'lib', 'sqlite.js'))-- .filter((f) => /require\(\s*['"](node:sqlite|sqlite3)['"]\s*\)/.test(fs.readFileSync(f, 'utf8')))-- .map((f) => path.relative(ROOT, f));-- assert.deepStrictEqual(offenders, []);++t('production without DATABASE_URL fails before opening PGlite', async () => {++ const { openDb } = require('../server/db/database');++ await assert.rejects(openDb({ db: { url: '' }, nodeEnv: 'production' }), /DATABASE_URL is not set/);});t.run();ddeleted file mode 100644@@@ -1,55 +0,0 @@--'use strict';--/**-- * The cutover rehearsal's seed (docs/cutover.md's ```rehearse block → test/rehearsal/seed.sql).-- * `ov rehearse` loads the declared seed on a fresh database after main's migrations and before the-- * branch's, so the seed must be PostgreSQL and must only name columns of migrations/0001 and 0002 —-- * not chat_ingress_applied, which 0003 creates. Without the declaration the harness would load every-- * test/fixtures/*.sql instead, and test/fixtures/live-chat-schema.sql is Live's SQLite schema.-- * This reproduces that exact order on an in-process PostgreSQL (PGlite).-- */--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const { createDb } = require('openvibe-sdk/db');--const { suite } = require('./helpers');----const t = suite('rehearsal');--const ROOT = path.join(__dirname, '..');--const MIGRATIONS = path.join(ROOT, 'migrations');--const RUNBOOK = path.join(ROOT, 'docs', 'cutover.md');--const SEED = path.join(ROOT, 'test', 'rehearsal', 'seed.sql');----let db;--t('the runbook declares only the PostgreSQL seed', () => {-- const block = /^```rehearse[ \t]*\n([\s\S]*?)^```/gm.exec(fs.readFileSync(RUNBOOK, 'utf8'));-- assert.ok(block, "docs/cutover.md has a ```rehearse block");-- const seeds = block[1].split('\n').map((l) => l.trim()).filter((l) => l.startsWith('seed:'))-- .map((l) => l.slice(5).trim());-- assert.deepStrictEqual(seeds, ['test/rehearsal/seed.sql'], 'the only seed is test/rehearsal/seed.sql');--});----t('the seed applies after 0001–0002 and before 0003', async () => {-- const base = fs.mkdtempSync(path.join(os.tmpdir(), 'rh-base-'));-- for (const f of ['0001_initial.sql', '0002_audio_claim_owner.sql']) fs.copyFileSync(path.join(MIGRATIONS, f), path.join(base, f));-- db = createDb({ pglite: true, service: 'rehearsal-test', log: { log() {}, warn() {}, error() {} } });-- const first = await db.migrate({ dir: base, log: { log() {} } });-- assert.deepStrictEqual(first.applied.map((a) => a.id), ['0001', '0002'], 'main\'s migrations applied');-- await db.query(fs.readFileSync(SEED, 'utf8'));-- for (const [table, n] of [['chat_messages', 3], ['dm_conversations', 1], ['dm_participants', 2], ['dm_messages', 2], ['bridge_applied', 1]]) {-- const row = await db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();-- assert.strictEqual(row.n, n, `${table} has ${n} seeded row(s)`);-- }-- assert.strictEqual((await db.prepare(`SELECT to_regclass('chat_ingress_applied') AS t`).get()).t, null, '0003 has not run yet');-- // windowDays 0: a test wants the final schema, not a contract held for its N-1 window (ADR-028).-- const second = await db.migrate({ dir: MIGRATIONS, log: { log() {} }, windowDays: 0 });-- const later = fs.readdirSync(MIGRATIONS).filter((f) => /^\d{4}_.*\.sql$/.test(f)).map((f) => f.slice(0, 4))-- .filter((id) => id > '0002').sort();-- assert.ok(later.includes('0003'), 'migrations/0003 exists');-- assert.strictEqual(second.held.length, 0, 'no later migration is held');-- assert.deepStrictEqual(second.applied.map((a) => a.id), later, 'every migration after 0002 applies on top of the seed');--});----t('cleanup', async () => { await db.close(); });----t.run();ddeleted file mode 100644@@@ -1,29 +0,0 @@----INSERT INTO chat_messages (stream_id, user_id, anon_id, username, message, message_type, source_platform, timestamp, channel_user_id, subject_id) VALUES-- (9001, 42, NULL, 'alice', 'first!', 'chat', 'openvibe', '2026-10-01 12:00:00', 42, 'usr_alice'),-- (9001, NULL, 'anon1', 'guest', 'hi from a guest', 'chat', 'openvibe', '2026-10-01 12:00:05', 42, NULL),-- (9001, NULL, NULL, NULL, 'guest joined the chat', 'system', 'openvibe', '2026-10-01 12:00:06', 42, NULL);----INSERT INTO dm_conversations (name, is_group, created_by, created_at, updated_at) VALUES-- (NULL, 0, 42, '2026-10-01 12:01:00', '2026-10-01 12:01:45');----INSERT INTO dm_participants (conversation_id, user_id, last_read_at, joined_at, subject_id) VALUES-- (1, 42, '1970-01-01 00:00:00', '2026-10-01 12:01:00', 'usr_alice'),-- (1, 43, '1970-01-01 00:00:00', '2026-10-01 12:01:00', 'usr_bob');----INSERT INTO dm_messages (conversation_id, sender_id, message, created_at, sender_subject_id) VALUES-- (1, 42, 'hey bob', '2026-10-01 12:01:30', 'usr_alice'),-- (1, 43, 'hey alice', '2026-10-01 12:01:45', 'usr_bob');----INSERT INTO bridge_applied (key, result, applied_at) VALUES-- ('rehearsal:seed:1', '{"ok":true}', 1788220800000);@@@ -270,15 +270,4 @@ t('CORS: no *.openvibe.tools wildcard — only exact origins get credentialed CO} finally { config.extraOrigins = saved; }});--t('scripts/parity-check.js: two identical services answer the same', async () => {-- const { spawn } = require('child_process');-- const path = require('path');-- const r = await new Promise((resolve) => {-- const c = spawn(process.execPath, [path.join(__dirname, '..', 'scripts', 'parity-check.js'), '--live', h.base, '--chat', h.base, '--stream', String(streamId), '--channel', String(streamer.id), '--token', alice.token]);-- let out = ''; c.stdout.on('data', (d) => { out += d; }); c.on('close', (code) => resolve({ code, out }));-- });-- assert.strictEqual(r.code, 0, r.out);-- assert.match(r.out, /all paths answer the same/);--});--t.run(async () => { if (h) await h.close(); });@@@ -112,7 +112,7 @@ t('TTS admin settings: admins only; credentials only the owner; written by Live'const view = await h.http('GET', '/api/tts/admin/settings', { token: admin.token });assert.strictEqual(view.body.settings.googleApiKey, '••••••••', 'admins see credentials masked');// (Live masks the engine's cached settings object in place, so for up to 30s after an admin-- // looks, the owner — and Google synthesis — see the mask too. Moved as it is; see docs/cutover.md.)++ // looks, the owner — and Google synthesis — see the mask too.)});t.run(async () => { if (h) await h.close(); });
@@ -22,9 +22,7 @@ TRUST_PROXY=2
@@ -22,9 +22,7 @@ TRUST_PROXY=2
#CHAT_LIMITS_MINUTE=120
#CHAT_LIMITS_MINUTE=120
#CHAT_LIMITS_HOUR=3000
#CHAT_LIMITS_HOUR=3000
-# Chat's own database (production: systemd StateDirectory=openvibe-chat)
-CHAT_DB_PATH=./data/chat.db
-# Chat's caches (101soundboards clips, TTS preview clips). Default: <db dir>/cache
+# Chat's caches (101soundboards clips, TTS preview clips). Default: ./data/cache
#CHAT_CACHE_DIR=./data/cache
#CHAT_CACHE_DIR=./data/cache
# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)
# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence — one identity, every conversation.
> Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence — one identity, every conversation.
-**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,
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, 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
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
@@ -55,11 +55,9 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -55,11 +55,9 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
Live for. Reads are cached projections (`ctx_*` tables kept complete by paged syncs; TTL
Live for. Reads are cached projections (`ctx_*` tables kept complete by paged syncs; TTL
caches that serve stale while refreshing) warmed when a socket joins, so a chat message never
caches that serve stale while refreshing) warmed when a socket joins, so a chat message never
waits on a network read. Live pushes invalidations when it changes cached data.
waits on a network read. Live pushes invalidations when it changes cached data.
-- **Live's side** is `docs/live-patch.diff` (applies to Live `main` with `git apply`):
- `/internal/chat-context/*` and `/internal/chat-effects/*`, and `CHAT_AUTHORITY=chat`, which stops
- Live's chat server and routes. Live's own chat calls reach Chat through the Chat-owned ingress
- (`/internal/chat/*`, `docs/chat-ingress.md`); the bridge proxy at `POST /internal/live/calls` is
- retired (T3 J3a).
+- **Live integration.** Live serves `/internal/chat-context/*` and `/internal/chat-effects/*` and
+ runs with `CHAT_AUTHORITY=chat`. Its chat calls reach Chat through `/internal/chat/*`
+ (`docs/chat-ingress.md`).
- **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, and a transactional events outbox. Chat's changes were copied into Live's tables by a read
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
mirror until 2026-10-05: since Live #31 nothing in Live reads its mirror-filled chat tables in chat
@@ -71,13 +69,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -71,13 +69,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
`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()`); `0006_stop_live_mirror_triggers.sql` dropped the twelve read-mirror capture
`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.
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
- SQLite file (Live's snapshot, the import) use `node:sqlite` through `scripts/lib/sqlite.js`.
-- **Import:** `scripts/import-from-live.js --live-db <snapshot> [--apply]` — a dry run unless
- `--apply` (which first copies the rows of the tables it writes to a JSON file), idempotent, never drops
- a row of Chat's tables (`import_hold`), reports counts per table.
+ `VALKEY_URL`/`VALKEY_PREFIX` put the per-actor rate-limit counters on Valkey instead of this process.
- **The six chat tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,
- **The six chat tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,
`chat_ai_summaries`, `chat_timeline_events`): Chat's own since the C-04 cutover — Chat is their only
`chat_ai_summaries`, `chat_timeline_events`): Chat's own since the C-04 cutover — Chat is their only
writer, there is no authority switch any more. Live's writers go through Chat's APIs below and the
writer, there is no authority switch any more. Live's writers go through Chat's APIs below and the
@@ -120,7 +112,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -120,7 +112,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 reads them through Chat's internal read API (the read mirror was retired on 2026-10-05 and the rollback through it is gone) |
+| 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 (`DATABASE_URL` through `openvibe-sdk/db`; `migrations/` applies at boot); Live reads them through Chat's internal read API |
| 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) |
@@ -142,9 +134,9 @@ Chat owns (from the cutover): `chat_messages`, `dm_conversations`, `dm_participa
@@ -142,9 +134,9 @@ Chat owns (from the cutover): `chat_messages`, `dm_conversations`, `dm_participa
Chat owns too (C-04 done; there is no authority switch any more): `channel_moderators`,
Chat owns too (C-04 done; there is no authority switch any more): `channel_moderators`,
`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`.
`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`.
-The importer no longer copies them; Live reads them through `GET /internal/moderation/*`.
+Live reads them through `GET /internal/moderation/*`.
-Stays in Live (decided with evidence, `docs/cutover.md`): `media_requests`, `media_request_settings`.
+Stays in Live: `media_requests`, `media_request_settings`.
### Events
### Events
@@ -357,19 +349,6 @@ openvibe.chat's Settings page edits all four. When Network cannot answer, an enf
@@ -357,19 +349,6 @@ openvibe.chat's Settings page edits all four. When Network cannot answer, an enf
when the revision is newer (without the subscription, within the TTL). Network down: the cached copy
when the revision is newer (without the subscription, within the TTL). Network down: the cached copy
with `stale: true`, else 503; writes 503.
with `stale: true`, else 503; writes 503.
- API tokens read only. A person Live knows without a Network subject gets 409 `prefs.subject_unknown`.
- API tokens read only. A person Live knows without a Network subject gets 409 `prefs.subject_unknown`.
-- **Migration** of what Live kept server-side (`user_preferences.chat_settings`, the browser's whole
- `chatSettings`): `scripts/migrate-chat-preferences.js`. Only choices move (`showTimestamps` →
- `timestamps`, `compactMode` → `compact`, `fontSize` small/large → `font_scale` 0.88/1.18, `showBadges`
- false → `show_badges`); a person with defaults only gets no record, and an existing record is never
- overwritten (create-only, `If-Match: 0`), so it is safe to re-run. The rest of `chatSettings` (TTS,
- volumes, cross-feed, notifications, …) has no field in schema v1 and stays in Live for now.
-
-```bash
-node scripts/migrate-chat-preferences.js --live-db /tmp/live-snapshot.db # dry run
-node scripts/migrate-chat-preferences.js --live-db /tmp/live-snapshot.db --apply --backup /root/chat-prefs-$(date +%F).json
-node scripts/migrate-chat-preferences.js --rollback /root/chat-prefs-<date>.json [--apply] # undo that run
-```
-
## Running it
## Running it
```bash
```bash
@@ -379,8 +358,6 @@ npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,s
@@ -379,8 +358,6 @@ npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,s
npm test # Node 22; stub Live and Network in-process
npm test # Node 22; stub Live and Network in-process
npm run test:pg # the same on PostgreSQL + PgBouncer + Valkey (OV_TEST_PG_URL, OV_TEST_PG_DIRECT_URL,
npm run test:pg # the same on PostgreSQL + PgBouncer + Valkey (OV_TEST_PG_URL, OV_TEST_PG_DIRECT_URL,
# 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/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"
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
@@ -402,8 +379,7 @@ the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a
@@ -402,8 +379,7 @@ the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a
its own interval by that much. It also reports whether events are relayed and the Events consumer's
its own interval by that much. It also reports whether events are relayed and the Events consumer's
counters.
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,
-rollback — is `docs/cutover.md`.
+`/etc/openvibe/chat.env`.
### Per-actor limits
### Per-actor limits
@@ -454,8 +430,8 @@ server/calls/ moved from Live: the call server (/ws/call), its REST
@@ -454,8 +430,8 @@ server/calls/ moved from Live: the call server (/ws/call), its REST
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
server/db/ 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, migrate-chat-preferences, subscribe-events, n-1-record
-docs/ cutover.md, calls-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
+scripts/ parity, subscribe-events, n-1-record
+docs/ calls-cutover.md, parity.md, chat-ingress.md, capabilities-proposal/
```
```
## Owns
## Owns
@@ -561,7 +537,7 @@ The unit is `openvibe-chat.service` on `127.0.0.1:4400`, the env file `/etc/open
@@ -561,7 +537,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`).
+rollback is a Chat release rollback, not a switch back to Live.
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
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W6",
"wave": "W6",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express, ws, PostgreSQL through openvibe-sdk/db (DATABASE_URL via PgBouncer, DATABASE_DIRECT_URL for migrations/; embedded PGlite outside production), Valkey for the per-actor limits (VALKEY_URL); better-sqlite3 gone (plan T3)",
+ "runtime": "node 22, express, ws, PostgreSQL through openvibe-sdk/db (DATABASE_URL via PgBouncer, DATABASE_DIRECT_URL for migrations/; embedded PGlite outside production), Valkey for the per-actor limits (VALKEY_URL)",
"port": 4400,
"port": 4400,
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"contracts": "openvibe-contracts v0.126.0",
"contracts": "openvibe-contracts v0.126.0",
@@ -47,7 +47,7 @@
@@ -47,7 +47,7 @@
"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 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)",
"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). 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",
@@ -4,8 +4,7 @@
@@ -4,8 +4,7 @@
#
#
# include /opt/openvibe.chat/deploy/nginx/openvibe.live-chat.locations.conf;
# include /opt/openvibe.chat/deploy/nginx/openvibe.live-chat.locations.conf;
#
#
-# Adding the include (+ nginx -t + reload) is the traffic flip; removing it is the rollback
-# (docs/cutover.md). Headers and limits are Live's for the same paths: the /api/ zone and burst,
+# The include routes these paths to Chat. Headers and limits are Live's for the same paths: the /api/ zone and burst,
# the body size, X-Forwarded-For for trust proxy 2, CF-Connecting-IP + upgrade for the socket.
# the body size, X-Forwarded-For for trust proxy 2, CF-Connecting-IP + upgrade for the socket.
# `^~` keeps the static-asset regex location from ever taking these (e.g. /api/sounds/file/x.mp3).
# `^~` keeps the static-asset regex location from ever taking these (e.g. /api/sounds/file/x.mp3).
# Trailing slashes matter: /api/chat-ai (Live's AI routes) must NOT match /api/chat/.
# Trailing slashes matter: /api/chat-ai (Live's AI routes) must NOT match /api/chat/.
@@ -36,7 +36,6 @@ RestrictRealtime=true
@@ -36,7 +36,6 @@ RestrictRealtime=true
# Chat's own database and caches. The code tree stays read-only.
# Chat's own database and caches. The code tree stays read-only.
StateDirectory=openvibe-chat
StateDirectory=openvibe-chat
-Environment=CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db
Environment=CHAT_CACHE_DIR=/var/lib/openvibe-chat/cache
Environment=CHAT_CACHE_DIR=/var/lib/openvibe-chat/cache
# Channel sounds stay in Live's sounds directory (rows hold absolute paths into it; Live's
# Channel sounds stay in Live's sounds directory (rows hold absolute paths into it; Live's
# donation alerts read alert sounds there). Legacy layout and release layout (shared/data).
# donation alerts read alert sounds there). Legacy layout and release layout (shared/data).
@@ -122,8 +122,7 @@ after this long and the caller is told (see below).
@@ -122,8 +122,7 @@ after this long and the caller is told (see below).
- `curl -s https://openvibe.live/api/streams/voice-channels | jq '.channels[].id'` → `"public"` and
- `curl -s https://openvibe.live/api/streams/voice-channels | jq '.channels[].id'` → `"public"` and
whatever users created since the flip.
whatever users created since the flip.
- Two test accounts in the Chat tab: join the Public voice channel, hear and see each other, mute
- Two test accounts in the Chat tab: join the Public voice channel, hear and see each other, mute
- shows on the other side, leave. `sqlite3 /var/lib/openvibe-chat/chat.db "select id, kind, state,
- channel_id, end_reason from calls order by id desc limit 5"` shows the `channel` session `active`,
+ shows on the other side, leave. `psql "$DATABASE_DIRECT_URL" -c "select id, kind, state, channel_id, end_reason from calls order by id desc limit 5"` shows the `channel` session `active`,
then `ended` / `empty`.
then `ended` / `empty`.
- Call one test account from the other (user menu → Call): the callee rings (and gets the
- Call one test account from the other (user menu → Call): the callee rings (and gets the
notification), accept → connected; the row is `direct`, `ringing` → `active` → `ended`. Decline →
notification), accept → connected; the row is `direct`, `ringing` → `active` → `ended`. Decline →
deleted file mode 100644
deleted file mode 100644
@@ -1,59 +0,0 @@
@@ -1,59 +0,0 @@
-# T3 cutover: closeout evidence — OpenVibe.Chat
-
-What the read-only interfaces can actually prove about the Chat PostgreSQL cutover (plan T3, ADR-035). The
-cutover ran **2026-09-23 02:03 UTC** (`README.md`, `docs/cutover.md`); production serves the
-PostgreSQL-only release. Checked **2026-10-04 23:03Z (UTC)**; the values below were captured then by the
-orchestrator through `ov access run openvibe-ovh <action>` — `health`, `releases`, `env-set`. Every result
-below is a literal output of one of those actions or of a read-only Git query.
-
-Nothing here was deployed, restarted, rolled back or imported; no credential, URL or env **value** was
-read. Checks that need an interface the broker does not expose are marked **pending** in their own
-section and were not guessed. SHAs are printed as `ovhost` reports them (12 hex characters).
-
-## Release and readiness — verified
-
-- `health chat` (exit 0): `chat 1d8c8aa14e69 ready openvibe-chat=active chat sockets=2`. `ovhost` needs
- the service's `/api/ready` to answer before it calls the service `ready`.
-- `releases chat` (exit 0): `c88d5814d7df → 0e00f50d6ba9 → 1d8c8aa14e69 deployed` (2026-10-04T12:03Z,
- 2026-10-04T21:23Z, 2026-10-04T22:43Z). `1d8c8aa14e69` is the release running in production; the
- PostgreSQL cutover itself deployed 2026-09-23 02:03 UTC (`docs/cutover.md`).
-
-**Why this is a PostgreSQL-only release.** `server/db/database.js` serves through `openvibe-sdk/db`,
-which in production requires `DATABASE_URL` (runtime, through PgBouncer) and `DATABASE_DIRECT_URL` (the
-owner role, for `migrations/`) and only falls back to embedded PGlite outside production. A release that
-is `active`/`ready` therefore booted with both set. `better-sqlite3` is not a dependency and nothing the
-service loads opens SQLite, guarded by `test/no-sqlite.test.js`; the schema is `migrations/0001_initial.sql`.
-
-## Env presence and mode — verified
-
-- `env-set chat` (names only, file `/etc/openvibe/chat.env`): **`DATABASE_URL`**, **`DATABASE_DIRECT_URL`**,
- **`VALKEY_URL`**, **`VALKEY_PREFIX`**, `EVENTS_URL`, `LIVE_MIRROR`, `OV_NETWORK_INTERNAL_URL`,
- `OV_LIVE_INTERNAL_URL`, `BASE_URL`, `SITE_URL`, `PORT` and the rest present, none empty. `CHAT_DB_PATH`
- comes from the unit and is read only by the one-time importer.
-
-## Import and row parity — pending
-
-The SQLite→PostgreSQL import and its per-table count+checksum report live in
-`docs/pg-port/progress-attempt4.md`; the production import report (`scripts/import-sqlite-to-pg.js`,
-recorded in `import_runs`) and the rollback copy (`chat.db.pre-pg`) are not exposed by any read-only
-action. **Not checked.**
-
-## Valkey ACL scope — pending
-
-Presence of `VALKEY_URL`/`VALKEY_PREFIX` is provable (above). The scope of the Valkey user (commands,
-`~ov:chat:*` confinement in `/etc/valkey/services.acl`) needs a connection no read-only action opens.
-**Not checked.**
-
-## Reproduce
-
-```bash
-ov access run openvibe-ovh health chat
-ov access run openvibe-ovh service-status chat
-ov access run openvibe-ovh plan chat
-ov access run openvibe-ovh releases chat
-ov access run openvibe-ovh validate chat
-ov access run openvibe-ovh env-set chat
-ov access run openvibe-ovh journal openvibe-chat.service 200
-```
-
-Every one of these is a low-risk read. `deploy`, `rollback`, `restart` and `db-backup` were not used.
deleted file mode 100644
deleted file mode 100644
@@ -1,336 +0,0 @@
@@ -1,336 +0,0 @@
-# Chat cutover — Live → OpenVibe.Chat (roadmap Wave 6)
-
-What changes at the cutover: browsers keep calling `https://openvibe.live`, but nginx sends the chat
-socket and the chat REST prefixes to OpenVibe.Chat (127.0.0.1:4400), and Live runs with
-`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 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 Live chat bridge is retired (T3 J3a).** Live #20/#21 route every producer through Chat's own
-ingress (`/internal/chat/*`, `docs/chat-ingress.md`) and dropped Live's bridge outbox, so Chat no
-longer mounts `POST /internal/live/calls` or `GET /internal/live/presence`, and deploy notices arrive
-only as `live.release.deployed`. Where the steps below mention the bridge, they record the cutover as
-it ran. Its tables `bridge_applied` / `bridge_refs` stayed as data until J3b dropped them
-(`migrations/0005_drop_bridge_tables.sql`).
-
-## Rehearsal
-
-`ov rehearse` loads this seed after main's migrations, then this branch's migrations:
-
-```rehearse
-# Chat's PostgreSQL seed; test/fixtures/live-chat-schema.sql is Live's SQLite schema for the import tests
-seed: test/rehearsal/seed.sql
-```
-
-- [What is served where](#what-is-served-where)
-- [Data authority](#data-authority)
-- [Prerequisites](#prerequisites)
-- [Rehearsal](#rehearsal)
-- [Rehearsal on a live.db snapshot](#rehearsal-on-a-livedb-snapshot)
-- [Cutover](#cutover)
-- [Rollback](#rollback)
-- [Behaviour that is not byte-for-byte identical](#behaviour-that-is-not-byte-for-byte-identical)
-- [Live quirks moved as they are](#live-quirks-moved-as-they-are)
-
-## What is served where
-
-nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in Live's
-`server { … server_name openvibe.live … }` block above `location /api/`):
-
-| nginx location | Chat serves |
-| --- | --- |
-| `^~ /ws/chat` | the chat WebSocket (`?stream=`; session cookie or Authorization bearer; the deprecated `?token=` still works; protocol unchanged) |
-| `^~ /api/chat/` | `GET gif/providers`, `GET gif/trending`, `GET gif/search`, `POST send`, `GET search`, `GET user/:userId/history`, `GET user/:username/profile`, `GET relay-user/:platform/:username`, `GET relay-user/:platform/:username/logs`, `GET anon/:anonId`, `GET anon/:anonId/logs`, `GET filters/friendly`, `GET global/history`, `GET :streamId/replay`, `GET :streamId/history`, `GET channel/:userId/history`, `GET :streamId/users`, `POST admin/purge/preview`, `DELETE admin/purge`, `GET admin/logs`, `GET admin/logs/export` |
-| `^~ /api/dm/` | `GET/POST conversations`, `GET/PATCH conversations/:id`, `GET/POST conversations/:id/messages`, `DELETE conversations/:id/messages/:msgId`, `POST conversations/:id/read`, `POST conversations/:id/participants`, `DELETE conversations/:id/participants/:userId`, `GET unread`, `GET users/search`, `GET blocks`, `POST/DELETE blocks/:userId`, `GET blocks/check/:userId` |
-| `^~ /api/tts/` | `GET voices`, `GET settings`, `GET/PUT admin/settings`, `POST admin/test`, `GET audio/:file` (Chat's clips; Live's arena/mod-preview clips are fetched from Live under the same URL), `GET queue`, `POST queue/skip`, `POST queue/clear`, `POST queue/:id/report` (the TTS and sound queue) |
-| `= /api/sounds` and `^~ /api/sounds/` | `POST /api/sounds` (upload), `GET channel/:userId`, `GET all/:streamId`, `DELETE :id`, `PATCH command`, `GET file/:filename`, `GET alert/mine`, `POST/DELETE alert/:kind` |
-
-> **WebSocket credentials.** C-05 shipped in `2dcca25`: a `/ws/chat` upgrade authenticates with the
-> session cookie or an `Authorization` bearer, and Live's legacy `token` cookie is ignored there. The
-> deprecated `?token=` query param is still honoured for Live's bot guide and call client, counted in
-> `chat_ws_url_token_uses`. C-06 — dropping the `ov_token` cookie fallback so upgrades authenticate by
-> bearer only — is gated on `chat_ws_cookie_reliant`: the browser clients now send their token in the
-> first message of every socket (`join`, `join_room`, the call's `auth-update`), and a socket the
-> cookie signed in whose first handled auth message has no valid token counts there, once; call
-> sockets silent past the token grace period count too. J7 removes the cookie
-> branch from `extractWsToken` once `chat_ws_cookie_reliant` stays 0 for one full release after this
-> ships. `chat_ws_cookie_uses` is only the raw "cookie seen" series: browsers send the cookie on every
-> same-origin upgrade, so it never reads 0.
-
-Stay on Live (do not route): `/api/chat-ai/*` (the trailing slash in `/api/chat/` keeps it out),
-`/api/emotes/*`, `/api/channels/*` (channel moderation dashboard), `/api/mod/*`, `/api/media/*`,
-`/ws/call` (until the calls cutover, `docs/calls-cutover.md`), `/ws/broadcast`, `/ws/control`, and
-everything else.
-
-Loopback only, never routed: Chat `/internal/live/*`, `/internal/chat/*`, `/health`, `/ready`; Live
-`/internal/chat-context/*`, `/internal/chat-effects/*` (both refuse anything that came through
-nginx).
-
-The additive Chat-owned ingress at `/internal/chat/{messages,events,moderation,invalidate,presence}`
-is documented in [chat-ingress.md](chat-ingress.md). Live keeps using `/internal/live/*` until its
-writers are repointed; deploy the three new capability grants before that Live release.
-
-## Data authority
-
-| 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 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` (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`,
- `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
- capabilities as undefined (Live's CI with the patch, Chat's non-blocking contracts job).
-2. **Network.** Create the principal and grants:
- ```bash
- sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js create chat --write-env /etc/openvibe/chat.env
- ```
- Network's env file goes **before** the script name so Node loads it as configuration;
- `--write-env` names the file the secret is written to (Chat's). Never put `--env-file`
- 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`,
- `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
- viewer, relay and donation lines, deploy notices); since T3 J3a it guards
- `POST /internal/chat/messages`. Add them to
- `DEFAULT_GRANTS` in `server/identity/principals.js` so every boot keeps them.
- VIP member badges (after this cutover, any time): `chat vip.entitlement.check openvibe.vip`;
- without it no message carries a badge (README, "VIP member badges").
-3. **Live.** Apply `docs/live-patch.diff` to Live `main` (`git apply docs/live-patch.diff`), run its
- tests (`node test/chat-context.test.js`, `npm test`), deploy it **without** `CHAT_AUTHORITY` —
- nothing changes for users; the new internal routes answer only service tokens and every effect
- answers 409 while the flag is off. Add `OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400` to
- `/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` 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
- sudo cp deploy/systemd/openvibe-chat.service /etc/systemd/system/ && sudo systemctl daemon-reload
- ```
- Do not start it before the rehearsal is signed off.
-
-## Rehearsal on a live.db snapshot
-
-Nothing here touches production data. A rehearsal Chat may read production Live
-(`/internal/chat-context/*` is read-only), but every effect is refused while Live's flag is off —
-no coins, AI replies or pastes happen; anon numbers come out temporary (`anon9xxxxxxxx`).
-
-```bash
-# 1. snapshot (online, consistent)
-sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-rehearsal.db"
-SNAP_AT="$(date -u '+%Y-%m-%d %H:%M:%S')"
-
-# 2. import into a scratch database
-cd /opt/openvibe.chat
-node scripts/import-from-live.js --live-db /tmp/live-rehearsal.db --chat-db /tmp/chat-rehearsal.db
-node scripts/import-from-live.js --live-db /tmp/live-rehearsal.db --chat-db /tmp/chat-rehearsal.db --apply --no-backup
-```
-
-Check the report: per table `live` = `inserted` (first run), `held_total` 0 (anything held: look at
-`import_hold.reason`), `media_requests.not_moved`, `chat_messages.next_id` = Live's max + headroom.
-Cross-check: `sqlite3 /tmp/live-rehearsal.db "select count(*) from chat_messages"` against the
-report. Run the import again: every `inserted` must be 0 (idempotent).
-
-```bash
-# 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 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)
-node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "$SNAP_AT" \
- --stream <a live stream id> --stream <an ended stream id> --channel <streamer user id> --channel <another> \
- --token <a test account's Network JWT>
-```
-
-Expected: `all paths answer the same`. Then by hand against 4401 (`wscat -H 'Origin: https://openvibe.live'
--c ws://127.0.0.1:4401/ws/chat?stream=<id>`): `{"type":"join","streamId":<id>}` → `auth`; a join
-with a token → `authenticated: true`; `/help`; a message → stored in `/tmp/chat-rehearsal.db` and
-returned by `/api/chat/<id>/history` on 4401. Stop the rehearsal Chat and delete `/tmp/chat-rehearsal*`.
-
-## Cutover
-
-Pick a quiet moment (`curl -s https://openvibe.live/api/streams`: restarting Live drops RTMP
-streamers; `deploy.sh --wait-idle` waits for none).
-
-1. **Start Chat on an empty database, import.**
- ```bash
- sudo systemctl start openvibe-chat && curl -s http://127.0.0.1:4400/ready | jq
- sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-cutover-1.db"
- sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-cutover-1.db --chat-db /var/lib/openvibe-chat/chat.db --apply
- ```
- Chat now holds everything up to snapshot 1 and its id sequences start 10000 above Live's
- (`--headroom`), so rows Live writes until the flip still fit below.
-2. **Flip.** Set `CHAT_AUTHORITY=chat` in `/etc/openvibe/live.env`, restart Live (it tells chat
- clients it restarts), and as soon as `https://openvibe.live/api/ready` answers, add the include
- to nginx and reload:
- ```bash
- sudo systemctl restart openvibe-live # or deploy.sh --wait-idle
- # add: include /opt/openvibe.chat/deploy/nginx/openvibe.live-chat.locations.conf;
- sudo nginx -t && sudo systemctl reload nginx
- ```
- Between the two steps `/ws/chat` on Live answers 503 and clients retry with backoff; afterwards
- they land on Chat.
-3. **Second import pass** — rows Live wrote between snapshot 1 and its restart:
- ```bash
- 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 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).
- - 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
- reload), `/help`, a DM between two test accounts (live delivery + unread badge), a moderator
- `/timeout` on a test account, a channel `!sound`, the global feed on the home page.
- - 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); Live's readers now use Chat's internal read API
- (`server/chat/internal-reads.js`).
-
-## Rollback
-
-There is no rollback to Live. Chat is required in every mode: Live runs with `CHAT_AUTHORITY=chat`
-and mounts no chat routes, so unsetting the flag would leave `/ws/chat` and the chat REST prefixes
-unanswered, not served by Live. The nginx include stays in place through any rollback.
-
-1. A bad Chat release is undone on Chat's side: `sudo ovhost rollback chat --to <sha>` (README,
- "Deploys") restarts the previous release. nginx, the routing include and `/etc/openvibe/live.env`
- 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. 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
- any later recovery.
-
-## Behaviour that is not byte-for-byte identical
-
-- **Ordering of Live's reactions.** The OpenCoins chat bonus (`coin_earned`), AI-viewer reactions,
- translations and the PowerChat relay happen in Live after the message is broadcast (one call per
- message); before, the coin reply was sent before the broadcast. Arena and media-queue replies
- arrive a round trip later.
-- **Freshness of Live data.** Chat answers from caches: changes made in Live reach Chat at once
- through invalidations (channel moderators/settings and alert sounds via Live's db writes, IP
- approvals, role/avatar pushes, admin user edits, bans followed by a disconnect) and otherwise
- within the TTL — bans 10 s, channel policy 15 s, settings 30 s, follows/cosmetics/tags 60 s,
- new users 30 s (all users every 15 min), live/ended streams 10 s. Live's synchronous reads of
- chat presence (viewer counts, AI-viewer pacing, `getConnectedUserIp` in `/api/mod` bans) are up
- to 3 s old.
-- **Placeholder ids.** A chat row Live's own modules create (AI viewers, relays, donations) gets its
- id from Chat; the Live caller sees a placeholder (≤ -2^40), mapped to the real id for everything it
- sends to chat afterwards. What such a caller stores itself keeps the placeholder:
- `ai_viewer_log.chat_message_id` and the PowerChat message id of AI-viewer lines.
-- **Separate rate-limit budget.** The chat REST routes count against Chat's own `/api` limiter
- (same numbers) instead of sharing Live's; Live's request analytics no longer see them.
-- **Live unreachable.** Chat keeps serving rooms from its caches; new sign-ins resolve as anonymous
- and new anonymous visitors get a temporary number (`anon9xxxxxxxx`) until Live answers; effects are
- skipped (coins, AI viewers) or answer an error (`/color`, media commands).
-- **Deploy notices** are still Live's (its commits, announced in chat through the OpenVibe.Events
- event `live.release.deployed`; the bridge op is retired, T3 J3a); Chat's own deploys are not announced.
-- **Responses** carry the same fields as before; Chat's `*subject_id` columns are stripped from API
- answers.
-
-## Live quirks moved as they are
-
-Found while moving; deliberately not fixed in this wave (fix after the cutover, in one place):
-
-- `GET /api/chat/search` always reports `total: 0` (its count regex does not cross the SELECT's line
- break).
-- `POST /api/chat/send` never attaches cosmetics (Live required a module that does not exist).
-- A `/timeout` stores an ISO `expires_at` that Live compares as TEXT with SQLite's
- `'YYYY-MM-DD HH:MM:SS'`, so a timeout lasts at least until the end of that UTC day.
-- `/api/tts/admin/settings` masks credentials in the TTS engine's cached settings object, so for up
- to 30 s after an admin views them the owner's view — and Google/AWS synthesis — see the mask.
-- `/paste` announces "Anonymous shared a paste" and titles it "Chat paste by anon" (it reads
- fields the chat client object does not have).
-- The AI-viewer / PowerChat "is moderator" flag passes the channel owner's user id where a channel
- id belongs (`canModerateChannel(user, channelUserId)`).
-
-## PostgreSQL switch (plan T3, ADR-035)
-
-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
-`DATABASE_DIRECT_URL` (the owner role, a direct connection, for migrations). `VALKEY_URL` / `VALKEY_PREFIX`
-are optional: with them the per-actor rate-limit counters are shared across processes and hosts, without
-them they stay in this process (as before). The role pair is the one `openvibe-sdk/testing`'s `store:'pg'`
-creates, so `npm run test:pg` rehearses the same shape.
-
-**Rehearse on a copy.** Take a consistent copy of the running database and import it into a local PGlite:
-
- sqlite3 /var/lib/openvibe-chat/chat.db ".backup /tmp/chat-pg-source.db"
- node scripts/import-sqlite-to-pg.js --sqlite /tmp/chat-pg-source.db --pglite ./data/pglite
-
-The import (`openvibe-sdk/db` `importSqlite`, reading the file with `node:sqlite`) copies every table the
-migration has, parents first, ids kept; identity sequences are `setval`'d past the imported maximum; each
-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 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: 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):
-
- node scripts/import-sqlite-to-pg.js --sqlite /var/lib/openvibe-chat/chat.db --url "$DATABASE_DIRECT_URL"
-
-3. Check the counts: the report's rows per table against `sqlite3 /var/lib/openvibe-chat/chat.db "select
- count(*) from <table>"` for `chat_messages`, `dm_messages`, `rooms`, `room_messages`, `audio_requests`;
- `ok` on the tail line.
-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`). (`/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 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
-served a full release.
-
-The read-only closeout record — release/readiness, env presence, the pending checks — is `docs/cutover-evidence-t3.md`.
deleted file mode 100644
deleted file mode 100644
@@ -1,1596 +0,0 @@
@@ -1,1596 +0,0 @@
-diff --git a/.env.example b/.env.example
-index 31f53b7..19810f1 100644
-+++ b/.env.example
-@@ -107,3 +107,9 @@ OPS_ALERT_WEBHOOK_URL=
- # Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)
- PASTES_ON_COMMUNITY=0
- OV_COMMUNITY_URL=https://openvibe.community
-+
-+# Chat runs in OpenVibe.Chat when this is "chat" (roadmap W6; server/chat/chat-authority.js): Live
-+# stops serving /ws/chat and /api/chat|dm|tts|sounds (nginx sends them to Chat), forwards its own
-+# chat calls to Chat and keeps its chat tables as Chat's read mirror. Unset = Live runs chat.
-+#CHAT_AUTHORITY=chat
-+OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
-diff --git a/docs/chat-system.md b/docs/chat-system.md
-index 65d1647..fd58f07 100644
-+++ b/docs/chat-system.md
-@@ -8,6 +8,29 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte
- - **REST API**: `server/chat/routes.js` — moderation endpoints, message search, admin tools
- - **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync
-
-+### OpenVibe.Chat (roadmap Wave 6)
-+
-+Chat is moving to [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (127.0.0.1:4400)
-+with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches it on
-+(`server/chat/chat-authority.js`); without it nothing here changes.
-+
-+- nginx sends `/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/` and `/api/sounds` to Chat; Live answers
-+ them with 503 if one still arrives, and `/ws/chat` upgrades are refused.
-+- `require('./chat/chat-server')` returns `chat-remote.js`: the same methods, forwarded in order to
-+ Chat's `POST /internal/live/calls` (broadcasts, DMs, TTS, channel sounds, disconnects, user updates).
-+ Chat-table writes Live's other modules make (AI viewers, relays, donations, `/api/mod`) are forwarded
-+ too; inserts return a placeholder id (≤ -2^40) Chat maps to the real one. Unacknowledged writes wait
-+ in `chat_bridge_outbox` (and are applied here if Live boots without the flag again — rollback).
-+- Synchronous reads (`getTotalConnections`, viewer counts, slow modes, a connected user's IP) come from
-+ Chat's presence snapshot, polled every 3 s.
-+- Chat reads Live data and asks for side effects on `/internal/chat-context/*` and
-+ `/internal/chat-effects/*` (`server/chat/live-context-routes.js`), with Network service tokens
-+ (`live.chat_context.read`, `live.chat_effects.write`, `live.chat_mirror.write`,
-+ `server/net/service-guard.js`). Effects re-check the acting moderator/owner/admin and are refused
-+ unless `CHAT_AUTHORITY=chat`.
-+- Live's chat tables become Chat's read mirror (`POST /internal/chat-effects/mirror`): same ids, so home
-+ stats, recaps, AI context, VOD chat replay and the `/api/mod` queues keep reading them in place.
-+
- ## Features
-
- ### Real-time Chat
-diff --git a/server/auth/auth.js b/server/auth/auth.js
-index e2dd198..4a7e908 100644
-+++ b/server/auth/auth.js
-@@ -413,6 +413,10 @@ function reloadNetworkKey() {
- module.exports = {
- apiTokenAllows,
- verifyToken,
-+ verifyTokenWithReason,
-+ getNetworkIssuer,
-+ // The Network's RS256 key also signs service tokens (server/net/service-guard.js).
-+ getNetworkPublicKey: () => openvibeToolsPublicKey,
- requireAuth,
- optionalAuth,
- requireAdmin,
-diff --git a/server/chat/chat-authority.js b/server/chat/chat-authority.js
-new file mode 100644
-index 0000000..4867b0b
-+++ b/server/chat/chat-authority.js
-@@ -0,0 +1,23 @@
-+'use strict';
-+/**
-+ * Who runs chat (roadmap Wave 6).
-+ *
-+ * CHAT_AUTHORITY=chat: OpenVibe.Chat (127.0.0.1:4400) serves /ws/chat and the chat REST routes
-+ * (nginx routes them there) and owns the chat tables. Live then
-+ * - does not start its chat WebSocket server or mount /api/chat, /api/dm, /api/tts, /api/sounds;
-+ * - hands every chat call its other modules make to Chat (server/chat/chat-remote.js — what
-+ * require('./chat/chat-server') returns in this mode);
-+ * - keeps its chat tables as a read mirror that Chat writes (POST /internal/chat-effects/mirror)
-+ * so its own readers (home stats, recaps, AI context, /api/mod queues) keep working and a
-+ * rollback loses nothing;
-+ * - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.
-+ * Anything else (default): Live runs chat itself, exactly as before.
-+ */
-+
-+const CHAT_URL = (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/+$/, '');
-+
-+function isRemote() {
-+ return process.env.CHAT_AUTHORITY === 'chat';
-+}
-+
-+module.exports = { isRemote, CHAT_URL };
-diff --git a/server/chat/chat-remote.js b/server/chat/chat-remote.js
-new file mode 100644
-index 0000000..7809725
-+++ b/server/chat/chat-remote.js
-@@ -0,0 +1,374 @@
-+'use strict';
-+/**
-+ * The chat server, when OpenVibe.Chat runs it (CHAT_AUTHORITY=chat, see chat-authority.js).
-+ *
-+ * require('./chat/chat-server') returns this object instead of a ChatServer, so every module that
-+ * talks to chat keeps working unchanged:
-+ * - calls that push to browsers (broadcastToStream, forwardToGlobal, sendDm, TTS, channel sounds,
-+ * disconnectUser, sendUserUpdate …) are queued, in order, and sent to Chat's
-+ * POST /internal/live/calls in small batches (service token for audience openvibe.chat,
-+ * capability chat.live_bridge.write);
-+ * - the chat-table writes Live's other modules make (db.saveChatMessage from AI viewers, relays,
-+ * donations; /api/mod deletes and queues; moderation log) are forwarded the same way, because
-+ * Chat owns those tables. Updates and deletes also run on Live's mirror at once so their
-+ * return values (deleted ids) are right; inserts run only in Chat and return a placeholder id
-+ * (≤ -2^40) that Chat maps to the real one for every later call of this boot. Forwarded writes
-+ * are kept in chat_bridge_outbox until Chat acknowledges them, so a restart or a Chat outage
-+ * loses none;
-+ * - synchronous reads (connection counts, viewer counts, slow modes, a connected user's address)
-+ * come from a presence snapshot polled from Chat's GET /internal/live/presence every few seconds.
-+ */
-+const crypto = require('crypto');
-+const db = require('../db/database');
-+const principal = require('../net/network-principal');
-+const { CHAT_URL } = require('./chat-authority');
-+
-+const AUDIENCE = 'openvibe.chat';
-+const BOOT = crypto.randomUUID();
-+const REF_BASE = 2 ** 40;
-+const BATCH = 200;
-+const PRESENCE_MS = 3000;
-+const EPHEMERAL_TTL_MS = 30000; // a broadcast older than this is dropped, not delivered late
-+const MAX_QUEUE = 20000;
-+
-+// Chat-table writes Live still makes, and whether they also run on Live's mirror right away.
-+// Inserts never run here (their ids are Chat's): the caller gets a placeholder.
-+const FORWARDED_DB = {
-+ saveChatMessage: 'insert',
-+ logModerationAction: 'insert',
-+ holdMessageForApproval: 'insert',
-+ hideRelayUser: 'insert',
-+ createChannelSound: 'insert',
-+ mergeChatMessageMetadata: 'local',
-+ deleteChatMessage: 'local',
-+ deleteUserChatMessages: 'local',
-+ deleteAnonChatMessages: 'local',
-+ deleteRelayUserMessages: 'local',
-+ deleteChatMessagesByTimeRange: 'local',
-+ reviewPendingIpMessage: 'local',
-+ approveAllFromIp: 'local',
-+ denyAllFromIp: 'local',
-+ recordRelayUser: 'local',
-+ unhideRelayUser: 'local',
-+ unhideRelayUserByIdentity: 'local',
-+ recordFirstChat: 'local',
-+ setTtsVoiceOverride: 'local',
-+ deleteTtsVoiceOverride: 'local',
-+ setChannelSoundEmote: 'local',
-+ deleteChannelSound: 'local',
-+ renameChannelSoundCommand: 'local',
-+ updateChannelSoundEmoteRefs: 'local',
-+};
-+
-+// Live-owned data Chat caches, written by Live's own routes (dashboard, /api/channels, /api/mod):
-+// after the write, Chat is told to reload it instead of waiting for its cache to expire.
-+const OBSERVED_DB = {
-+ addChannelModerator: (a) => ['channel', a[0]],
-+ removeChannelModerator: (a) => ['channel', a[0]],
-+ upsertChannelModerationSettings: (a) => ['channel', a[0]],
-+ setChannelAlertSound: (a) => ['channel', a[0]],
-+ approveIp: (a) => ['approvals', a[0]],
-+ revokeIpApproval: (a) => ['approvals', a[0]],
-+ forgiveBan: () => ['bans', null],
-+};
-+
-+function create(ChatServer) {
-+ // A local ChatServer that never listens: its address and anon-number helpers are Live's own
-+ // (call-server uses them for voice calls, and Chat asks Live for anon numbers).
-+ const local = new ChatServer();
-+ const queue = []; // { seq, op, args, ref, durableId, at }
-+ let seq = 0;
-+ let refCounter = 0;
-+ let busy = false;
-+ let inflight = 0; // ops at the head of the queue that are being sent right now
-+ let batchLimit = BATCH; // 1 after Chat refused a batch as a whole (too large / malformed)
-+ let backoffUntil = 0;
-+ let flushTimer = null;
-+ let presenceTimer = null;
-+ let started = false;
-+ let presence = { total: 0, streams: {}, slow_mode: {}, users: [], anons: [], at: null };
-+ const slowModeByStream = new Map();
-+ const stats = { sent: 0, failed: 0, dropped: 0, lastError: null };
-+
-+ function ensureOutbox() {
-+ db.getDb().exec(`CREATE TABLE IF NOT EXISTS chat_bridge_outbox (
-+ id INTEGER PRIMARY KEY AUTOINCREMENT,
-+ boot TEXT NOT NULL,
-+ ref INTEGER,
-+ op TEXT NOT NULL,
-+ args TEXT NOT NULL,
-+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-+ )`);
-+ }
-+
-+ function schedule(ms = 0) {
-+ if (flushTimer) return;
-+ flushTimer = setTimeout(() => { flushTimer = null; flush().catch(() => {}); }, ms);
-+ if (flushTimer.unref) flushTimer.unref();
-+ }
-+
-+ function enqueue(op, args, { ref = null, durable = false, boot = BOOT, durableId = null } = {}) {
-+ let id = durableId;
-+ if (durable && !id) {
-+ try {
-+ ensureOutbox();
-+ id = db.getDb().prepare('INSERT INTO chat_bridge_outbox (boot, ref, op, args) VALUES (?, ?, ?, ?)').run(boot, ref, op, JSON.stringify(args)).lastInsertRowid;
-+ } catch (err) { console.warn('[ChatRemote] outbox write failed:', err.message); }
-+ }
-+ if (queue.length >= MAX_QUEUE) {
-+ // Drop the oldest broadcast, never a write.
-+ const i = queue.findIndex((q, idx) => idx >= inflight && !q.durableId);
-+ if (i >= 0) { queue.splice(i, 1); stats.dropped++; }
-+ }
-+ queue.push({ seq: ++seq, op, args, ref, durableId: id, boot, at: Date.now() });
-+ schedule();
-+ }
-+
-+ async function post(path, body, retried = false) {
-+ const res = await fetch(`${CHAT_URL}${path}`, {
-+ method: body ? 'POST' : 'GET',
-+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await principal.serviceHeaders(AUDIENCE)) },
-+ body: body ? JSON.stringify(body) : undefined,
-+ signal: AbortSignal.timeout(10000),
-+ });
-+ if (res.status === 401 && !retried) { principal.invalidate(AUDIENCE); return post(path, body, true); }
-+ if (!res.ok) { const err = new Error(`Chat ${res.status}`); err.status = res.status; throw err; }
-+ return res.json();
-+ }
-+
-+ async function flush() {
-+ if (busy || !queue.length) return;
-+ if (Date.now() < backoffUntil) { schedule(backoffUntil - Date.now()); return; }
-+ busy = true;
-+ try {
-+ while (queue.length) {
-+ // Stale broadcasts are dropped; forwarded writes always go.
-+ const now = Date.now();
-+ for (let i = queue.length - 1; i >= 0; i--) {
-+ if (!queue[i].durableId && now - queue[i].at > EPHEMERAL_TTL_MS) { queue.splice(i, 1); stats.dropped++; }
-+ }
-+ if (!queue.length) break;
-+ // One boot per request (replayed writes of an earlier boot keep their own id space).
-+ const boot = queue[0].boot;
-+ const batch = [];
-+ for (const q of queue) { if (q.boot !== boot || batch.length >= batchLimit) break; batch.push(q); }
-+ let out;
-+ inflight = batch.length;
-+ try {
-+ // A forwarded write carries its outbox id as an idempotency key: Chat applies it once
-+ // even when a lost response makes Live send it again.
-+ out = await post('/internal/live/calls', { boot, ops: batch.map((q) => ({ seq: q.seq, op: q.op, args: q.args, ref: q.ref, key: q.durableId ? `live:${q.durableId}` : undefined })) });
-+ } catch (err) {
-+ inflight = 0;
-+ stats.failed++;
-+ if (err.status === 400 || err.status === 413) {
-+ // Refused as a request, not per op: retry one by one; a single op still refused
-+ // can never be delivered, so it is dropped (logged) instead of blocking the queue.
-+ if (batch.length > 1) { batchLimit = 1; continue; }
-+ const q = queue.shift();
-+ stats.dropped++;
-+ console.error(`[ChatRemote] Chat refused ${q.op}${q.op === 'db' ? ` ${q.args[0]}` : ''} (${err.status}); dropped`);
-+ if (q.durableId) { try { db.getDb().prepare('DELETE FROM chat_bridge_outbox WHERE id = ?').run(q.durableId); } catch { /* */ } }
-+ continue;
-+ }
-+ if (stats.lastError !== err.message) console.warn(`[ChatRemote] Chat unreachable (${queue.length} queued): ${err.message}`);
-+ stats.lastError = err.message;
-+ backoffUntil = Date.now() + 2000;
-+ schedule(2000);
-+ return;
-+ }
-+ stats.lastError = null;
-+ inflight = 0;
-+ batchLimit = BATCH;
-+ queue.splice(0, batch.length);
-+ stats.sent += batch.length;
-+ const done = batch.filter((q) => q.durableId).map((q) => q.durableId);
-+ if (done.length) {
-+ try { db.getDb().prepare(`DELETE FROM chat_bridge_outbox WHERE id IN (${done.map(() => '?').join(',')})`).run(...done); } catch { /* */ }
-+ }
-+ for (const r of (out && out.results) || []) {
-+ if (!r.ok) console.warn(`[ChatRemote] Chat refused op #${r.seq}: ${r.error}`);
-+ }
-+ }
-+ } finally { busy = false; }
-+ }
-+
-+ async function pollPresence() {
-+ try {
-+ const p = await post('/internal/live/presence');
-+ presence = p;
-+ slowModeByStream.clear();
-+ for (const [k, v] of Object.entries(p.slow_mode || {})) slowModeByStream.set(Number(k), Number(v) || 0);
-+ } catch { /* keep the last snapshot */ }
-+ }
-+
-+ // ── Forwarded database writes ───────────────────────────────
-+ const originals = {};
-+ function installDbForwarding() {
-+ for (const [fn, mode] of Object.entries(FORWARDED_DB)) {
-+ if (typeof db[fn] !== 'function' || originals[fn]) continue;
-+ originals[fn] = db[fn];
-+ db[fn] = function forwardedChatWrite(...args) {
-+ if (mode === 'insert') {
-+ const ref = -(REF_BASE + (++refCounter));
-+ enqueue('db', [fn, ...args], { ref, durable: true });
-+ return { changes: 1, lastInsertRowid: ref };
-+ }
-+ let result;
-+ try { result = originals[fn](...args); } catch (err) { result = undefined; console.warn(`[ChatRemote] mirror ${fn}: ${err.message}`); }
-+ enqueue('db', [fn, ...args], { durable: true });
-+ return result;
-+ };
-+ }
-+ }
-+
-+ function installInvalidations() {
-+ for (const [fn, target] of Object.entries(OBSERVED_DB)) {
-+ if (typeof db[fn] !== 'function' || originals[fn]) continue;
-+ originals[fn] = db[fn];
-+ db[fn] = function observedLiveWrite(...args) {
-+ const result = originals[fn](...args);
-+ try { const [kind, id] = target(args); enqueue('invalidate', [kind, id]); } catch { /* */ }
-+ return result;
-+ };
-+ }
-+ }
-+
-+ function replayOutbox() {
-+ try {
-+ ensureOutbox();
-+ const rows = db.getDb().prepare('SELECT id, boot, ref, op, args FROM chat_bridge_outbox ORDER BY id').all();
-+ for (const r of rows) {
-+ let args;
-+ try { args = JSON.parse(r.args); } catch { continue; }
-+ queue.push({ seq: ++seq, op: r.op, args, ref: r.ref, durableId: r.id, boot: r.boot, at: Date.now() });
-+ }
-+ if (rows.length) console.log(`[ChatRemote] replaying ${rows.length} chat write(s) queued before the restart`);
-+ } catch (err) { console.warn('[ChatRemote] outbox replay:', err.message); }
-+ }
-+
-+ // A stream row carries Live's stream keys: Chat only ever needs the owner.
-+ const slimStream = (s) => (s ? { id: s.id, user_id: s.user_id } : s);
-+ const send = (op) => (...args) => { enqueue(op, args); };
-+
-+ // Live code that iterated chatServer.clients and wrote to each socket (a global delete in
-+ // /api/mod) gets one pseudo-socket whose send() reaches every chat client through Chat.
-+ const everyone = { readyState: 1, bufferedAmount: 0, send: (raw) => enqueue('broadcastAllRaw', [String(raw)]), close() {} };
-+ const clients = new Map([[everyone, { pseudo: true }]]);
-+
-+ const remote = {
-+ remote: true,
-+ boot: BOOT,
-+ stats,
-+ clients,
-+ slowModeByStream,
-+
-+ init() {
-+ if (started) return null;
-+ started = true;
-+ ensureOutbox();
-+ replayOutbox();
-+ schedule();
-+ pollPresence();
-+ presenceTimer = setInterval(pollPresence, PRESENCE_MS);
-+ if (presenceTimer.unref) presenceTimer.unref();
-+ console.log(`[Chat] CHAT_AUTHORITY=chat — chat runs in OpenVibe.Chat (${CHAT_URL}); Live forwards to it`);
-+ return null;
-+ },
-+ handleUpgrade(req, socket) {
-+ // nginx sends /ws/chat to Chat; a socket that still lands here is told to retry.
-+ try { socket.write('HTTP/1.1 503 Service Unavailable\r\nRetry-After: 2\r\nConnection: close\r\n\r\n'); } catch { /* */ }
-+ try { socket.destroy(); } catch { /* */ }
-+ return true;
-+ },
-+ close() {
-+ if (presenceTimer) clearInterval(presenceTimer);
-+ presenceTimer = null;
-+ return flush().catch(() => {});
-+ },
-+ flush,
-+
-+ // ── Pushes to browsers (Chat delivers) ──
-+ broadcastToStream: send('broadcastToStream'),
-+ broadcastToChannelRoom: send('broadcastToChannelRoom'),
-+ broadcastGlobal: send('broadcastGlobal'),
-+ broadcastAll: send('broadcastAll'),
-+ forwardToGlobal: send('forwardToGlobal'),
-+ forwardToGlobalByChannel: send('forwardToGlobalByChannel'),
-+ forwardToStreamerRooms: send('forwardToStreamerRooms'),
-+ broadcastToOwnerStreams: send('broadcastToOwnerStreams'),
-+ sendDm: send('sendDm'),
-+ sendUserUpdate: send('sendUserUpdate'),
-+ disconnectUser: send('disconnectUser'),
-+ sendToConn: send('sendToConn'),
-+ synthesizeAndBroadcastTTS(...args) { enqueue('synthesizeAndBroadcastTTS', args); return Promise.resolve(); },
-+ triggerChannelSound(ws, client, stream, command, args = [], relay = null) {
-+ const c = client ? { streamId: client.streamId || null, user: client.user ? { id: client.user.id, username: client.user.username, display_name: client.user.display_name, role: client.user.role, avatar_url: client.user.avatar_url, profile_color: client.user.profile_color } : null, anonId: client.anonId || null, ip: client.ip || null } : {};
-+ enqueue('triggerChannelSound', [null, c, slimStream(stream), command, args, relay]);
-+ },
-+ /** Commits this boot shipped (deploy-notice.js decides which; Chat stores and shows them). */
-+ deployNotice(commits) { enqueue('deployNotice', [commits], { durable: true }); },
-+ /** Live changed a user (role push, avatar): Chat drops what it cached about them. */
-+ userChanged(userId) { if (userId) enqueue('userChanged', [userId]); },
-+
-+ // ── Synchronous reads (presence snapshot) ──
-+ getTotalConnections() { return Number(presence.total) || 0; },
-+ getStreamViewerCount(streamId) { return Number((presence.streams || {})[streamId]) || 0; },
-+ getConnectedUserIp(userId) {
-+ const hit = (presence.users || []).find((u) => u.user_id === userId);
-+ return hit ? hit.ip : null;
-+ },
-+ findClientByAnonId(anonId, streamId) {
-+ // Same match as ChatServer.findClientByAnonId: the socket's stream must equal streamId.
-+ const hit = (presence.anons || []).find((a) => a.anon_id === anonId && (a.stream_id ?? null) === streamId);
-+ return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;
-+ },
-+ getUserList() { return { logged: [], anonCount: 0 }; },
-+
-+ // ── Live's own address / anon helpers ──
-+ normalizeIp: (ip) => local.normalizeIp(ip),
-+ getClientIp: (req) => local.getClientIp(req),
-+ getAnonIdForIp: (ip) => local.getAnonIdForIp(ip),
-+ getAnonIdForConnection(ip, streamId = null) {
-+ const key = local.normalizeIp(ip);
-+ const hit = (presence.anons || []).find((a) => a.ip === key && (streamId == null || (a.stream_id || null) === streamId));
-+ return hit ? hit.anon_id : local.getAnonIdForIp(key);
-+ },
-+ /** The anon number for an address (Network's unified resolve, else Live's table) — for Chat. */
-+ async resolveAnon(ip) {
-+ const key = local.normalizeIp(ip);
-+ const num = await local._resolveUnifiedAnonNum(key);
-+ return { anon_number: num, first_seen: db.getAnonFirstSeen(key) };
-+ },
-+ };
-+
-+ installDbForwarding();
-+ installInvalidations();
-+ remote._restoreDb = () => { for (const [fn, orig] of Object.entries(originals)) db[fn] = orig; };
-+ return remote;
-+}
-+
-+/**
-+ * Rollback: Live runs chat itself again (CHAT_AUTHORITY unset) but chat writes it forwarded were
-+ * never acknowledged by Chat (Chat down). Apply them to Live's own tables so nothing is lost.
-+ * A write Chat did apply whose answer was lost is applied twice — rare, and only for that window.
-+ */
-+function drainToLocal(log = console) {
-+ try {
-+ const d = db.getDb();
-+ if (!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_bridge_outbox'").get()) return 0;
-+ const rows = d.prepare("SELECT id, args FROM chat_bridge_outbox WHERE op = 'db' ORDER BY id").all();
-+ let applied = 0;
-+ for (const r of rows) {
-+ try {
-+ const [fn, ...args] = JSON.parse(r.args);
-+ if (FORWARDED_DB[fn] && typeof db[fn] === 'function') { db[fn](...args); applied++; }
-+ } catch (err) { log.warn(`[ChatRemote] could not apply queued chat write #${r.id}: ${err.message}`); }
-+ }
-+ d.prepare('DELETE FROM chat_bridge_outbox').run();
-+ if (rows.length) log.log(`[ChatRemote] applied ${applied} of ${rows.length} chat write(s) OpenVibe.Chat never acknowledged`);
-+ return applied;
-+ } catch (err) { log.warn('[ChatRemote] drain:', err.message); return 0; }
-+}
-+
-+module.exports = { create, drainToLocal, FORWARDED_DB, OBSERVED_DB, REF_BASE, AUDIENCE };
-diff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
-index cfce589..ae77d68 100644
-+++ b/server/chat/chat-server.js
-@@ -2491,4 +2491,6 @@ class ChatServer {
- }
- }
-
--module.exports = new ChatServer();
-+// CHAT_AUTHORITY=chat: OpenVibe.Chat runs the chat server; Live's modules get a proxy with the same
-+// methods that hands every call to it (chat-remote.js). Default: Live runs chat itself.
-+module.exports = require('./chat-authority').isRemote() ? require('./chat-remote').create(ChatServer) : new ChatServer();
-diff --git a/server/chat/deploy-notice.js b/server/chat/deploy-notice.js
-index 4b0f91a..33f8811 100644
-+++ b/server/chat/deploy-notice.js
-@@ -82,6 +82,14 @@ async function announce({ db, chatServer, log = console }) {
- const { head, commits } = await newCommits(db);
- if (!head || !commits.length) return { announced: 0 };
-
-+ // CHAT_AUTHORITY=chat: Live still decides what shipped; OpenVibe.Chat stores the rolling
-+ // message and shows it (its own copy of this module), so hand it the commits.
-+ if (chatServer && chatServer.remote) {
-+ chatServer.deployNotice(commits);
-+ try { db.setSetting(SETTING, head); } catch (err) { log.warn('[Deploy notice] not recorded:', err.message); }
-+ return { announced: commits.length };
-+ }
-+
- let saved;
- try { saved = persist(db, commits); db.setSetting(SETTING, head); }
- catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }
-diff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
-new file mode 100644
-index 0000000..53a72ce
-+++ b/server/chat/live-context-routes.js
-@@ -0,0 +1,656 @@
-+'use strict';
-+/**
-+ * What OpenVibe.Chat asks of Live (roadmap Wave 6). Chat owns chat; Live still owns accounts,
-+ * streams, channels, bans, cosmetics, coins, AI viewers, the arena, the media queue and hardware.
-+ * Chat's single adapter (OpenVibe.Chat server/live-context.js) calls these with a Network service
-+ * token for audience openvibe.live (server/net/service-guard.js); nothing here is routed by nginx.
-+ *
-+ * /internal/chat-context/* capability live.chat_context.read — projections Chat caches
-+ * POST /auth {token} → { user, expires_at, reason } (Live's own token rules)
-+ * GET /users?after_id&limit → { rows } users projection (paged by id)
-+ * POST /users/lookup {ids?, usernames?} → { users }
-+ * GET /users/:id/follows → { streamer_ids }
-+ * GET /users/profile?username&viewer_id → the /api/chat/user/:username/profile card
-+ * GET /streams?after_id&limit, /streams/active, /streams/:id
-+ * GET /managed-streams?after_id&limit, /channels?after_id&limit, /channels/by-user/:userId
-+ * GET /channels/:id/policy → { channel, settings, moderator_ids, language }
-+ * GET /channels/:id/approved-ip?ip → { approved }
-+ * GET /bans?version → { version, bans } | { version, unchanged: true }
-+ * POST /decor {user_ids} → { decor: { id: { cosmetic, tag } } }
-+ * GET /settings → { settings } (tts_*, GIF and soundboard keys only)
-+ * GET /anon/:num, /anon-first-seen?ip → { first_seen }
-+ * GET /tts-audio/:file → a clip from the arena voice cache
-+ *
-+ * /internal/chat-effects/* capability live.chat_effects.write — side effects (CHAT_AUTHORITY=chat only)
-+ * anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,
-+ * alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,
-+ * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, asset-sync
-+ * mirror (capability live.chat_mirror.write) — Chat's changes to its tables, applied to Live's copy
-+ *
-+ * Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat
-+ * checked first; Live does not take its word for it.
-+ */
-+const express = require('express');
-+const fs = require('fs');
-+const path = require('path');
-+const db = require('../db/database');
-+const config = require('../config');
-+const permissions = require('../auth/permissions');
-+const { guard } = require('../net/service-guard');
-+const { isRemote } = require('./chat-authority');
-+
-+const contextRouter = express.Router();
-+const effectsRouter = express.Router();
-+
-+const int = (v, d = 0) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
-+const pageArgs = (req) => ({ after: Math.max(0, int(req.query.after_id)), limit: Math.min(5000, Math.max(1, int(req.query.limit, 1000))) });
-+const fail = (res, status, error) => res.status(status).json({ error });
-+
-+// ── Projections ─────────────────────────────────────────────────
-+
-+const SUBJECT_SQL = "(SELECT la.subject_id FROM linked_accounts la WHERE la.user_id = u.id AND la.service = 'network' AND la.subject_id IS NOT NULL ORDER BY la.id DESC LIMIT 1)";
-+const USER_SQL = `SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, u.role, u.is_banned, u.ban_reason,
-+ COALESCE(u.is_owner, 0) AS is_owner, u.created_at, ${SUBJECT_SQL} AS subject_id FROM users u`;
-+const STREAM_SQL = 'SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams';
-+const MS_SQL = 'SELECT id, user_id, slug, title, sort_order, created_at FROM managed_streams';
-+const CHANNEL_SQL = 'SELECT id, user_id, title FROM channels';
-+
-+/** The chat projection of a user row (no secrets: no email, password, stream key, balances). */
-+function userProjection(user, subjectId) {
-+ if (!user) return null;
-+ let subject = subjectId || user.subject_id || null;
-+ if (!subject) {
-+ try { subject = db.get("SELECT subject_id FROM linked_accounts WHERE user_id = ? AND service = 'network' AND subject_id IS NOT NULL ORDER BY id DESC LIMIT 1", [user.id])?.subject_id || null; } catch { subject = null; }
-+ }
-+ return {
-+ id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url || null,
-+ profile_color: user.profile_color || null, role: user.role, is_banned: user.is_banned ? 1 : 0, ban_reason: user.ban_reason || null,
-+ is_owner: user.is_owner ? 1 : 0, created_at: user.created_at, subject_id: subject,
-+ };
-+}
-+
-+contextRouter.use(guard('live.chat_context.read'));
-+
-+// Resolve a browser/bot token exactly as Live's requireAuth / authenticateWs do.
-+contextRouter.post('/auth', (req, res) => {
-+ const token = String((req.body && req.body.token) || '');
-+ if (!token) return res.json({ user: null, reason: 'invalid' });
-+ const auth = require('../auth/auth');
-+ const apiUser = auth.authenticateApiToken(token);
-+ if (apiUser) {
-+ const user = { ...userProjection(apiUser), auth_source: 'api_token', scopes: apiUser.scopes || [] };
-+ return res.json({ user, expires_at: null, reason: null });
-+ }
-+ const r = auth.verifyTokenWithReason(token);
-+ if (!r.ok) return res.json({ user: null, reason: 'invalid' });
-+ const user = auth.resolveNetworkUser(r.decoded);
-+ if (!user) return res.json({ user: null, reason: 'unresolved' });
-+ res.json({
-+ user: { ...userProjection(user, r.decoded.subject_id), auth_source: 'network' },
-+ expires_at: typeof r.decoded.exp === 'number' ? new Date(r.decoded.exp * 1000).toISOString() : null,
-+ reason: null,
-+ });
-+});
-+
-+contextRouter.get('/users', (req, res) => {
-+ const { after, limit } = pageArgs(req);
-+ res.json({ rows: db.all(`${USER_SQL} WHERE u.id > ? ORDER BY u.id LIMIT ?`, [after, limit]) });
-+});
-+
-+contextRouter.post('/users/lookup', (req, res) => {
-+ const ids = (Array.isArray(req.body?.ids) ? req.body.ids : []).map(Number).filter(Number.isInteger).slice(0, 500);
-+ const names = (Array.isArray(req.body?.usernames) ? req.body.usernames : []).map(String).slice(0, 100);
-+ const rows = [];
-+ if (ids.length) rows.push(...db.all(`${USER_SQL} WHERE u.id IN (${ids.map(() => '?').join(',')})`, ids));
-+ for (const n of names) { const r = db.get(`${USER_SQL} WHERE u.username = ? COLLATE NOCASE`, [n]); if (r) rows.push(r); }
-+ res.json({ users: rows });
-+});
-+
-+contextRouter.get('/users/profile', (req, res) => {
-+ // The same card /api/chat/user/:username/profile built in Live.
-+ try {
-+ const name = String(req.query.username || '');
-+ let user = db.getUserByUsername(name);
-+ if (!user) user = db.get('SELECT * FROM users WHERE display_name = ? COLLATE NOCASE', [name]);
-+ if (!user) return fail(res, 404, 'User not found');
-+ const profile = db.getUserProfile(user.id);
-+ if (!profile) return fail(res, 404, 'Profile not found');
-+ // OpenCoins are part of the game; Vibes (real money) and presence stay with the user.
-+ const viewerId = int(req.query.viewer_id, 0);
-+ if (!viewerId || viewerId !== user.id) {
-+ delete profile.openvibe_bucks_balance;
-+ delete profile.last_seen;
-+ }
-+ try {
-+ const game = require('../game/game-engine');
-+ const player = game.getPlayer(user.id);
-+ if (player) {
-+ profile.game = {
-+ total_level: player.total_level,
-+ mining_level: player.mining_level,
-+ fishing_level: player.fishing_level,
-+ woodcut_level: player.woodcut_level,
-+ farming_level: player.farming_level,
-+ combat_level: player.combat_level,
-+ crafting_level: player.crafting_level,
-+ mining_xp: player.mining_xp,
-+ fishing_xp: player.fishing_xp,
-+ woodcut_xp: player.woodcut_xp,
-+ farming_xp: player.farming_xp,
-+ combat_xp: player.combat_xp,
-+ crafting_xp: player.crafting_xp,
-+ total_coins_earned: player.total_coins_earned || 0,
-+ };
-+ }
-+ } catch { /* game not initialized or player doesn't exist */ }
-+ res.json(profile);
-+ } catch (err) {
-+ fail(res, 500, 'Failed to get profile');
-+ }
-+});
-+
-+contextRouter.get('/users/:id/follows', (req, res) => {
-+ res.json({ streamer_ids: db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [int(req.params.id)]).map((r) => r.streamer_id) });
-+});
-+
-+contextRouter.get('/streams', (req, res) => {
-+ const { after, limit } = pageArgs(req);
-+ res.json({ rows: db.all(`${STREAM_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });
-+});
-+// Live now, plus anything that ended in the last quarter hour (so Chat sees it go offline).
-+contextRouter.get('/streams/active', (req, res) => {
-+ res.json({ rows: db.all(`${STREAM_SQL} WHERE is_live = 1 OR (ended_at IS NOT NULL AND ended_at >= datetime('now', '-15 minutes'))`) });
-+});
-+contextRouter.get('/streams/:id', (req, res) => {
-+ const stream = db.get(`${STREAM_SQL} WHERE id = ?`, [int(req.params.id)]);
-+ if (!stream) return res.json({ stream: null });
-+ res.json({
-+ stream,
-+ owner: db.get(`${USER_SQL} WHERE u.id = ?`, [stream.user_id]) || null,
-+ managed_stream: stream.managed_stream_id ? (db.get(`${MS_SQL} WHERE id = ?`, [stream.managed_stream_id]) || null) : null,
-+ channel: stream.channel_id ? (db.get(`${CHANNEL_SQL} WHERE id = ?`, [stream.channel_id]) || null) : (db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [stream.user_id]) || null),
-+ });
-+});
-+
-+contextRouter.get('/managed-streams', (req, res) => {
-+ const { after, limit } = pageArgs(req);
-+ res.json({ rows: db.all(`${MS_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });
-+});
-+
-+contextRouter.get('/channels', (req, res) => {
-+ const { after, limit } = pageArgs(req);
-+ res.json({ rows: db.all(`${CHANNEL_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });
-+});
-+contextRouter.get('/channels/by-user/:userId', (req, res) => {
-+ res.json({ channel: db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [int(req.params.userId)]) || null });
-+});
-+contextRouter.get('/channels/:id/policy', (req, res) => {
-+ const channelId = int(req.params.id);
-+ const channel = db.get(`${CHANNEL_SQL} WHERE id = ?`, [channelId]) || null;
-+ let language = 'en';
-+ try { if (channel) language = require('../i18n/translate').channelLanguage(channel.user_id) || 'en'; } catch { language = 'en'; }
-+ res.json({
-+ channel,
-+ settings: db.getChannelModerationSettings(channelId),
-+ moderator_ids: db.all('SELECT user_id FROM channel_moderators WHERE channel_id = ?', [channelId]).map((r) => r.user_id),
-+ language,
-+ });
-+});
-+contextRouter.get('/channels/:id/approved-ip', (req, res) => {
-+ res.json({ approved: db.isIpApproved(int(req.params.id), String(req.query.ip || '')) });
-+});
-+
-+// Active bans (user, IP and CIDR rows). `version` changes whenever the table does, so an
-+// unchanged table is one small answer.
-+contextRouter.get('/bans', (req, res) => {
-+ const v = db.get('SELECT COUNT(*) AS c, COALESCE(MAX(id), 0) AS m, COALESCE(SUM(id * 7 + COALESCE(stream_id, 0) + LENGTH(COALESCE(expires_at, \'\'))), 0) AS s FROM bans');
-+ const version = `${v.c}:${v.m}:${v.s}`;
-+ if (req.query.version && String(req.query.version) === version) return res.json({ version, unchanged: true });
-+ res.json({
-+ version,
-+ bans: db.all('SELECT id, stream_id, user_id, ip_address, anon_id, expires_at FROM bans WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP'),
-+ });
-+});
-+
-+contextRouter.post('/decor', (req, res) => {
-+ const ids = (Array.isArray(req.body?.user_ids) ? req.body.user_ids : []).map(Number).filter(Number.isInteger).slice(0, 500);
-+ let cosmetics = null, tags = null;
-+ try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }
-+ try { tags = require('../game/tags'); } catch { /* */ }
-+ const decor = {};
-+ for (const id of ids) {
-+ let cosmetic = {}, tag = null;
-+ try { if (cosmetics) cosmetic = cosmetics.getCosmeticProfile(id) || {}; } catch { cosmetic = {}; }
-+ try { if (tags) tag = tags.getTagProfile(id) || null; } catch { tag = null; }
-+ decor[id] = { cosmetic, tag };
-+ }
-+ res.json({ decor });
-+});
-+
-+// Site settings chat reads (TTS config, GIF provider keys, the 101soundboards key) — nothing else.
-+const CHAT_SETTING_KEYS = new Set(['gif_tenor_api_key', 'gif_giphy_api_key', 'soundboard_101_api_key']);
-+const isChatSetting = (k) => CHAT_SETTING_KEYS.has(k) || /^tts_[a-z0-9_]+$/.test(k);
-+contextRouter.get('/settings', (req, res) => {
-+ const settings = {};
-+ for (const r of db.all("SELECT key FROM site_settings WHERE key LIKE 'tts\\_%' ESCAPE '\\' OR key IN ('gif_tenor_api_key', 'gif_giphy_api_key', 'soundboard_101_api_key')")) {
-+ if (isChatSetting(r.key)) settings[r.key] = db.getSetting(r.key);
-+ }
-+ res.json({ settings });
-+});
-+
-+contextRouter.get('/anon/:num', (req, res) => {
-+ res.json({ first_seen: db.get('SELECT created_at FROM anon_ip_mappings WHERE anon_num = ?', [int(req.params.num)])?.created_at || null });
-+});
-+contextRouter.get('/anon-first-seen', (req, res) => {
-+ res.json({ first_seen: db.getAnonFirstSeen(String(req.query.ip || '')) });
-+});
-+
-+contextRouter.get('/tts-audio/:file', (req, res) => {
-+ try {
-+ const hit = require('../arena/voice').cachedByName(req.params.file);
-+ if (!hit) return fail(res, 404, 'No such clip');
-+ res.set({ 'Content-Type': hit.mimeType });
-+ require('fs').createReadStream(hit.path).pipe(res);
-+ } catch (err) { fail(res, 500, err.message); }
-+});
-+
-+// ── Effects ────────────────────────────────────────────────────
-+
-+effectsRouter.post('/mirror', guard('live.chat_mirror.write'), (req, res, next) => next());
-+effectsRouter.use((req, res, next) => {
-+ if (req.path === '/mirror') return next();
-+ return guard('live.chat_effects.write')(req, res, next);
-+});
-+// Live only takes Chat's writes while Chat is the authority — a rehearsal Chat pointed at
-+// production Live can never award coins, post to AI viewers or overwrite chat tables.
-+effectsRouter.use((req, res, next) => {
-+ if (!isRemote()) return fail(res, 409, 'Live runs chat itself (CHAT_AUTHORITY is not "chat")');
-+ next();
-+});
-+
-+const chatServer = () => require('./chat-server');
-+const actorOf = (id) => (id ? db.getUserById(id) : null);
-+
-+effectsRouter.post('/anon', async (req, res) => {
-+ const ip = String(req.body?.ip || '');
-+ if (!ip) return fail(res, 400, 'ip required');
-+ try { res.json(await chatServer().resolveAnon(ip)); } catch (err) { fail(res, 500, err.message); }
-+});
-+
-+effectsRouter.post('/ip-log', (req, res) => {
-+ const entries = Array.isArray(req.body?.entries) ? req.body.entries.slice(0, 1000) : [];
-+ let ipUtils = null;
-+ try { ipUtils = require('../admin/ip-utils'); } catch { /* */ }
-+ for (const e of entries) {
-+ try {
-+ const geo = ipUtils ? ipUtils.enrichIp(e.ip) : null;
-+ db.logIp({ userId: e.userId || null, anonId: e.anonId || null, ip: e.ip, action: e.action || 'chat', geo });
-+ } catch { /* non-critical */ }
-+ }
-+ res.json({ ok: true, logged: entries.length });
-+});
-+
-+effectsRouter.post('/viewer-counts', (req, res) => {
-+ for (const [sid, count] of Object.entries(req.body?.counts || {})) {
-+ try { db.updateViewerCount(int(sid), Math.max(0, int(count))); } catch { /* */ }
-+ }
-+ res.json({ ok: true });
-+});
-+
-+effectsRouter.post('/viewer-snapshots', (req, res) => {
-+ for (const s of (Array.isArray(req.body?.snapshots) ? req.body.snapshots : []).slice(0, 1000)) {
-+ try { db.insertViewerSnapshot(int(s.stream_id), Math.max(0, int(s.viewer_count)), Math.max(0, int(s.chat_messages_5m))); } catch { /* */ }
-+ }
-+ res.json({ ok: true });
-+});
-+
-+// /color: the user changes their own color.
-+effectsRouter.post('/user-color', (req, res) => {
-+ const userId = int(req.body?.user_id);
-+ const color = String(req.body?.color || '');
-+ if (!userId || !/^#[0-9a-fA-F]{6}$/.test(color)) return fail(res, 400, 'user_id and a #rrggbb color required');
-+ db.run('UPDATE users SET profile_color = ? WHERE id = ?', [color, userId]);
-+ res.json({ ok: true });
-+});
-+
-+// /ban, /timeout, /unban from chat. The moderator is checked again: global staff, or a
-+// moderator of the stream chat decided on (for an offline channel room, its latest stream).
-+effectsRouter.post('/ban', (req, res) => {
-+ const b = req.body || {};
-+ const actor = actorOf(int(b.actor_user_id));
-+ const modStream = int(b.moderation_stream_id) || null;
-+ const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && permissions.canModerateStream(actor, modStream)));
-+ if (!allowed) return fail(res, 403, 'You do not have permission.');
-+ const streamId = b.stream_id != null ? int(b.stream_id) || null : null;
-+ if (b.action === 'unban') {
-+ const userId = int(b.user_id);
-+ if (!userId) return fail(res, 400, 'user_id required');
-+ db.run('DELETE FROM bans WHERE user_id = ? AND (stream_id = ? OR stream_id IS NULL)', [userId, streamId]);
-+ return res.json({ ok: true });
-+ }
-+ if (b.action !== 'ban') return fail(res, 400, 'action must be ban or unban');
-+ if (b.user_id) {
-+ const target = db.getUserById(int(b.user_id));
-+ if (!target) return fail(res, 404, 'User not found');
-+ // Prevent non-admins from banning admins
-+ if (permissions.isGlobalModOrAbove(target) && target.role === 'admin' && actor.role !== 'admin') return fail(res, 403, 'You cannot ban an admin.');
-+ db.run('INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)',
-+ [streamId, target.id, String(b.reason || 'Banned by moderator').slice(0, 200), actor.id, b.expires_at || null]);
-+ } else if (b.ip_address) {
-+ db.run('INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)',
-+ [streamId, String(b.ip_address), b.anon_id ? String(b.anon_id) : null, String(b.reason || 'Banned by moderator').slice(0, 200), actor.id]);
-+ } else {
-+ return fail(res, 400, 'user_id or ip_address required');
-+ }
-+ res.json({ ok: true });
-+});
-+
-+// IP approval mode: an address that chatted here before is approved automatically ('auto_existing').
-+effectsRouter.post('/approve-ip', (req, res) => {
-+ const channelId = int(req.body?.channel_id);
-+ const ip = String(req.body?.ip || '');
-+ if (!channelId || !ip) return fail(res, 400, 'channel_id and ip required');
-+ db.approveIp(channelId, ip, req.body.approved_by ? int(req.body.approved_by) : null, String(req.body.source || 'auto').slice(0, 32));
-+ res.json({ ok: true });
-+});
-+
-+// /slow persists the channel's slow mode.
-+effectsRouter.post('/channel-settings', (req, res) => {
-+ const channelId = int(req.body?.channel_id);
-+ const actor = actorOf(int(req.body?.actor_user_id));
-+ if (!channelId || !actor || !permissions.canModerateChannel(actor, channelId)) return fail(res, 403, 'You do not have permission.');
-+ const fields = {};
-+ if (req.body.fields && req.body.fields.slow_mode_seconds !== undefined) fields.slow_mode_seconds = Math.max(0, int(req.body.fields.slow_mode_seconds));
-+ if (!Object.keys(fields).length) return fail(res, 400, 'no chat-settable fields');
-+ db.upsertChannelModerationSettings(channelId, fields);
-+ res.json({ ok: true });
-+});
-+
-+// Donation / goal alert sounds: the streamer's own channel only, files in the shared sounds dir.
-+effectsRouter.post('/alert-sound', (req, res) => {
-+ const channelId = int(req.body?.channel_id);
-+ const channel = channelId ? db.getChannelById(channelId) : null;
-+ if (!channel || channel.user_id !== int(req.body?.actor_user_id)) return fail(res, 403, 'Not your channel');
-+ const url = req.body.url ? path.resolve(String(req.body.url)) : null;
-+ if (url) {
-+ // Compared through realpath: Chat and Live may reach the shared sounds dir via different symlinks.
-+ let inside = false;
-+ try { inside = path.dirname(fs.realpathSync(url)) === fs.realpathSync(path.resolve(config.sounds.path)); } catch { inside = false; }
-+ if (!inside) return fail(res, 400, 'alert sounds live in the sounds directory');
-+ }
-+ db.setChannelAlertSound(channelId, req.body.kind === 'goal' ? 'goal' : 'donation', url, url ? String(req.body.mime || 'audio/mpeg') : null);
-+ res.json({ ok: true });
-+});
-+
-+effectsRouter.post('/ensure-channel', (req, res) => {
-+ const userId = int(req.body?.user_id);
-+ if (!userId || !db.getUserById(userId)) return fail(res, 404, 'User not found');
-+ const ch = db.ensureChannel(userId);
-+ res.json({ channel: ch ? { id: ch.id, user_id: ch.user_id, title: ch.title } : null });
-+});
-+
-+// TTS admin settings: admins; credentials only the owner (Live's /api/tts/admin/settings rules).
-+effectsRouter.post('/site-settings', (req, res) => {
-+ const actor = actorOf(int(req.body?.actor_user_id));
-+ if (!actor || actor.role !== 'admin') return fail(res, 403, 'Admin access required');
-+ const allowed = ['tts_enabled', 'tts_provider', 'tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region', 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global', 'tts_default_voice'];
-+ const secret = new Set(['tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key']);
-+ const owner = permissions.isOwner(actor);
-+ let count = 0;
-+ for (const [key, value] of Object.entries(req.body.settings || {})) {
-+ if (!allowed.includes(key)) continue;
-+ if (secret.has(key) && !owner) continue;
-+ if (secret.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;
-+ db.setSetting(key, value);
-+ count++;
-+ }
-+ try { require('./tts-engine').invalidateSettingsCache(); } catch { /* */ }
-+ res.json({ ok: true, updated: count });
-+});
-+
-+// One real chat line: OpenCoins chat bonus, AI chat viewers, the PowerChat overlay relay.
-+effectsRouter.post('/chat-message', (req, res) => {
-+ const b = req.body || {};
-+ let coin = null;
-+ if (b.award && b.user_id && b.stream_id) {
-+ try { coin = require('../monetization/opencoins').awardChat(int(b.user_id), int(b.stream_id)); } catch { coin = null; }
-+ }
-+ if (b.ai && b.stream_id) {
-+ try {
-+ require('../integrations/ai-chatbot-service').onRealChatMessage(int(b.stream_id), {
-+ username: b.username,
-+ message: b.message,
-+ userId: b.user_id || null,
-+ anonId: b.anon_id || null,
-+ isStreamer: !!b.is_streamer,
-+ isMod: !!b.is_mod,
-+ msgId: b.msg_id || null,
-+ channelUserId: b.channel_user_id || null,
-+ });
-+ } catch { /* non-critical */ }
-+ }
-+ if (b.powerchat && b.channel_user_id && b.powerchat_chat) {
-+ try {
-+ const pc = require('../integrations/powerchat-platform');
-+ const channelUserId = int(b.channel_user_id);
-+ if (pc.channelRelayEnabled(channelUserId, b.stream_id || null)) {
-+ let isSub = false;
-+ try { isSub = !!(b.user_id && db.isActiveSubscriber(int(b.user_id), channelUserId)); } catch { /* */ }
-+ const c = b.powerchat_chat;
-+ pc.forwardChat(channelUserId, {
-+ chatterName: c.chatterName,
-+ externalChatterId: c.externalChatterId,
-+ message: c.message,
-+ avatarUrl: c.avatarUrl || undefined,
-+ isModerator: !!c.isModerator,
-+ isSubscriber: isSub,
-+ });
-+ }
-+ } catch { /* non-critical */ }
-+ }
-+ res.json({ coin });
-+});
-+
-+effectsRouter.post('/ai/mod-command', (req, res) => {
-+ try {
-+ const engine = require('../integrations/ai-chatbot-service');
-+ const reply = engine.onModCommand
-+ ? engine.onModCommand(req.body.channel_user_id || null, req.body.stream_id || null, Array.isArray(req.body.args) ? req.body.args : [], { by: req.body.by })
-+ : 'AI viewers: command not supported by this engine.';
-+ res.json({ reply: reply || null });
-+ } catch (err) { fail(res, 400, err.message); }
-+});
-+
-+// !hype / !beef / !arena: arena-chat answers the sender through Chat (sendToConn).
-+effectsRouter.post('/arena-command', (req, res) => {
-+ const client = req.body?.client || {};
-+ const cs = chatServer();
-+ const shim = {
-+ sendTo: (_ws, payload) => cs.sendToConn(client.conn_id, payload),
-+ broadcastToStream: (streamId, payload) => cs.broadcastToStream(streamId, payload),
-+ };
-+ let handled = false;
-+ try { handled = require('../arena/arena-chat').handle(shim, null, client, String(req.body.cmd || ''), Array.isArray(req.body.parts) ? req.body.parts : []); } catch (e) { console.warn('[Arena] chat command:', e.message); }
-+ res.json({ handled: !!handled });
-+});
-+
-+// !sr / !queue / !np / !skip — the media queue is Live's.
-+effectsRouter.post('/media-queue', async (req, res) => {
-+ const b = req.body || {};
-+ const mediaQueue = require('../media/media-queue');
-+ const streamerId = int(b.streamerId);
-+ if (!streamerId) return fail(res, 400, 'streamerId required');
-+ try {
-+ if (b.op === 'add') {
-+ const request = await mediaQueue.addRequest({ streamerId, streamId: int(b.streamId) || null, userId: int(b.userId), username: String(b.username || ''), input: String(b.input || '') });
-+ return res.json({ request });
-+ }
-+ if (b.op === 'state') return res.json({ state: mediaQueue.getState(streamerId) });
-+ if (b.op === 'skip') {
-+ const actor = actorOf(int(b.actorUserId));
-+ const streamId = int(b.streamId) || null;
-+ const allowed = actor && (actor.id === streamerId || permissions.isGlobalModOrAbove(actor) || (streamId && permissions.canModerateStream(actor, streamId)));
-+ if (!allowed) return fail(res, 403, 'Only the streamer or a moderator can skip media.');
-+ const ended = mediaQueue.finishCurrent(streamerId, 'skipped');
-+ const next = mediaQueue.startNext(streamerId);
-+ return res.json({ ended: ended || null, next: next || null });
-+ }
-+ fail(res, 400, 'unknown op');
-+ } catch (err) {
-+ fail(res, 400, err.message || 'Media command failed.');
-+ }
-+});
-+
-+// Cozmo / !say: the robot's control socket is in Live, keyed by the streamer's stream key.
-+const HARDWARE_COMMANDS = new Set(['forward', 'backward', 'turn_left', 'turn_right', 'lift_up', 'lift_down', 'head_up', 'head_down']);
-+effectsRouter.post('/hardware', (req, res) => {
-+ const command = String(req.body?.command || '');
-+ if (!HARDWARE_COMMANDS.has(command) && !/^say:[\s\S]{1,200}$/.test(command)) return fail(res, 400, 'unknown command');
-+ const user = db.getUserById(int(req.body.streamer_user_id));
-+ if (!user) return res.json({ ok: false, reason: 'no_user' });
-+ const controlServer = require('../controls/control-server');
-+ const hwWs = controlServer.hardwareClients.get(user.stream_key);
-+ if (!hwWs || hwWs.readyState !== 1) return res.json({ ok: false, reason: 'no_hardware' });
-+ hwWs.send(JSON.stringify({
-+ type: 'command',
-+ command,
-+ from_user: String(req.body.from_user || ''),
-+ timestamp: new Date().toISOString(),
-+ }));
-+ res.json({ ok: true });
-+});
-+
-+// /paste from chat, through Live's pastes client (Community or Media per PASTES_AUTHORITY).
-+effectsRouter.post('/paste', async (req, res) => {
-+ const b = req.body || {};
-+ try {
-+ const paste = await require('../pastes-client').createPaste({
-+ title: String(b.title || '').slice(0, 200),
-+ content: String(b.content || ''),
-+ language: b.language || 'auto',
-+ visibility: b.visibility || 'public',
-+ user_id: b.user_id || undefined,
-+ stream_id: b.stream_id || undefined,
-+ });
-+ res.json({ paste: { slug: paste.slug } });
-+ } catch (err) { fail(res, 502, err.message); }
-+});
-+
-+effectsRouter.post('/translate', async (req, res) => {
-+ let i18n;
-+ try { i18n = require('../i18n/translate'); } catch { return res.json({ translation: null }); }
-+ if (!i18n.available()) return res.json({ translation: null });
-+ try {
-+ const tr = await i18n.translateChatMessage(String(req.body?.text || ''), req.body?.channel_user_id || null);
-+ res.json({ translation: tr && tr.text ? tr : null });
-+ } catch { res.json({ translation: null }); }
-+});
-+
-+effectsRouter.post('/notify/dm', (req, res) => {
-+ try {
-+ const { pushBulkNotification, actorInfo } = require('../utils/notify');
-+ const sender = db.getUserById(int(req.body?.sender_id));
-+ const otherIds = (Array.isArray(req.body?.recipient_ids) ? req.body.recipient_ids : []).map(Number).filter(Boolean).slice(0, 50);
-+ const convId = int(req.body?.conversation_id);
-+ if (otherIds.length && convId) {
-+ const senderName = sender?.display_name || sender?.username || 'Someone';
-+ // Generic preview — the message itself only travels over the live socket.
-+ pushBulkNotification(otherIds, {
-+ type: 'DIRECT_MESSAGE',
-+ title: `Message from ${senderName}`,
-+ message: 'Sent you a message',
-+ url: `https://openvibe.live/dm/${convId}`,
-+ ...actorInfo(sender),
-+ });
-+ }
-+ } catch { /* non-critical */ }
-+ res.json({ ok: true });
-+});
-+
-+effectsRouter.post('/notify/dm-read', (req, res) => {
-+ try {
-+ const { markNotificationsRead } = require('../utils/notify');
-+ markNotificationsRead(int(req.body?.user_id), 'DIRECT_MESSAGE', `%/dm/${int(req.body?.conversation_id)}`);
-+ } catch { /* non-critical */ }
-+ res.json({ ok: true });
-+});
-+
-+// Channel sound files are mirrored to OpenVibe.Media from Live's copy of channel_sounds.
-+effectsRouter.post('/asset-sync', (req, res) => {
-+ try {
-+ const sync = require('../media-proxy/asset-sync');
-+ if (req.body?.op === 'syncSoon') sync.syncSoon();
-+ else if (req.body?.op === 'remove-sound') {
-+ const row = db.get('SELECT media_asset_id FROM channel_sounds WHERE id = ?', [int(req.body.asset_id)]);
-+ if (row && row.media_asset_id) sync.removeAsset(row.media_asset_id);
-+ }
-+ } catch { /* best-effort */ }
-+ res.json({ ok: true });
-+});
-+
-+// ── The read mirror of Chat's tables ──────────────────────────────
-+// Same tables, same ids. Upserts set only the columns Live's table has (Chat's *subject_id
-+// columns are skipped; Live-only columns such as channel_sounds.media_asset_id are kept).
-+const MIRROR_TABLES = {
-+ chat_messages: ['id'],
-+ dm_conversations: ['id'],
-+ dm_participants: ['id'],
-+ dm_messages: ['id'],
-+ dm_blocks: ['id'],
-+ tts_voice_overrides: ['identity_key'],
-+ channel_sounds: ['id'],
-+ relay_users: ['platform', 'username'],
-+ hidden_relay_users: ['id'],
-+ pending_ip_messages: ['id'],
-+ stream_first_chats: ['chatter_key', 'channel_user_id'],
-+ moderation_actions: ['id'],
-+};
-+const _cols = new Map();
-+function liveColumns(table) {
-+ if (!_cols.has(table)) _cols.set(table, new Set(db.getDb().prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));
-+ return _cols.get(table);
-+}
-+
-+function applyMirror(changes) {
-+ const d = db.getDb();
-+ let applied = 0;
-+ const skipped = [];
-+ // A copy of the authority's rows: foreign keys are the authority's business. Synchronous, so
-+ // nothing else runs while they are off.
-+ d.pragma('foreign_keys = OFF');
-+ try {
-+ d.transaction(() => {
-+ for (const c of changes) {
-+ const pk = MIRROR_TABLES[c && c.table];
-+ if (!pk) { skipped.push({ table: c && c.table, reason: 'not a mirrored table' }); continue; }
-+ const have = liveColumns(c.table);
-+ try {
-+ if (c.op === 'delete') {
-+ d.prepare(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`).run(...pk.map((k) => c.pk[k]));
-+ } else if (c.op === 'upsert' && c.row) {
-+ const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));
-+ const upd = cols.filter((k) => !pk.includes(k));
-+ d.prepare(`INSERT INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})
-+ ON CONFLICT(${pk.join(', ')}) DO ${upd.length ? `UPDATE SET ${upd.map((k) => `${k} = excluded.${k}`).join(', ')}` : 'NOTHING'}`)
-+ .run(...cols.map((k) => c.row[k]));
-+ } else { skipped.push({ table: c.table, reason: 'bad change' }); continue; }
-+ applied++;
-+ } catch (err) {
-+ skipped.push({ table: c.table, pk: c.pk || (c.row && pk.map((k) => c.row[k])), reason: err.message });
-+ }
-+ }
-+ })();
-+ } finally {
-+ d.pragma('foreign_keys = ON');
-+ }
-+ return { applied, skipped };
-+}
-+
-+effectsRouter.post('/mirror', (req, res) => {
-+ const changes = Array.isArray(req.body?.changes) ? req.body.changes.slice(0, 1000) : [];
-+ const out = applyMirror(changes);
-+ if (out.skipped.length) console.warn(`[ChatMirror] ${out.skipped.length} change(s) not applied:`, JSON.stringify(out.skipped.slice(0, 3)));
-+ res.json({ ok: true, ...out });
-+});
-+
-+module.exports = { contextRouter, effectsRouter, applyMirror, userProjection, MIRROR_TABLES };
-diff --git a/server/index.js b/server/index.js
-index 894e159..2763945 100644
-+++ b/server/index.js
-@@ -585,6 +585,11 @@ app.get('/internal/analytics-summary', (req, res) => {
- } catch (err) { res.status(500).json({ ok: false, error: err.message }); }
- });
-
-+// OpenVibe.Chat reads Live data and asks for side effects here, with Network service tokens
-+// (server/chat/live-context-routes.js); mounted before the X-Internal-Key routes below.
-+const chatLiveContext = require('./chat/live-context-routes');
-+app.use('/internal/chat-context', chatLiveContext.contextRouter);
-+app.use('/internal/chat-effects', chatLiveContext.effectsRouter);
- app.use('/internal', require('./internal/routes'));
-
-
-@@ -653,7 +658,11 @@ app.use('/api/streams', streamRoutes);
- // Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).
- const liveEvents = require('./streaming/live-events');
- app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));
--app.use('/api/chat', chatRoutes);
-+// CHAT_AUTHORITY=chat: OpenVibe.Chat serves the chat REST routes (nginx sends /api/chat/, /api/dm/,
-+// /api/tts/, /api/sounds there). One that still reaches Live must not write to the mirror.
-+const chatRemote = require('./chat/chat-authority').isRemote();
-+const chatMoved = (req, res) => res.status(503).set('Retry-After', '2').json({ error: 'Chat is served by OpenVibe.Chat' });
-+app.use('/api/chat', chatRemote ? chatMoved : chatRoutes);
- app.use('/api/funds', monetizationRoutes);
- app.use('/api/coins', coinsRoutes);
- app.use('/api/payments', require('./monetization/payments-routes'));
-@@ -675,7 +684,7 @@ app.use('/api/restream', restreamRoutes);
- app.use('/api/thumbnails', thumbnailRoutes);
- app.use('/api/themes', themeRoutes);
- app.use('/api/emotes', emoteRoutes);
--app.use('/api/sounds', require('./chat/sounds-routes'));
-+app.use('/api/sounds', chatRemote ? chatMoved : require('./chat/sounds-routes'));
- app.use('/api/ai-viewers', require('./ai/viewers/routes'));
- app.use('/api/powerchat', require('./integrations/powerchat-routes'));
- // Game & Canvas — migrated to openvibe.games
-@@ -695,9 +704,9 @@ app.use('/api/media', require('./media/routes'));
- app.use('/api/img-proxy', require('./media/external-image-proxy'));
- app.use('/api/vibe-coding', vibeCodingRoutes);
- const ttsRoutes = require('./chat/tts-routes');
--app.use('/api/tts', ttsRoutes);
-+app.use('/api/tts', chatRemote ? chatMoved : ttsRoutes);
- const dmRoutes = require('./chat/dm-routes');
--app.use('/api/dm', dmRoutes);
-+app.use('/api/dm', chatRemote ? chatMoved : dmRoutes);
- const analyticsRoutes = require('./streaming/analytics-routes');
- app.use('/api/analytics', analyticsRoutes);
- const newsRoutes = require('./news/news-routes');
-@@ -1081,6 +1090,8 @@ async function start() {
- // Initialize DM tables
- const dm = require('./chat/dm');
- dm.ensureTables();
-+ // Back from CHAT_AUTHORITY=chat (rollback): chat writes OpenVibe.Chat never acknowledged land here.
-+ if (!require('./chat/chat-authority').isRemote()) require('./chat/chat-remote').drainToLocal();
- // Migrate: add last_heartbeat column if missing
- try { db.run("ALTER TABLE streams ADD COLUMN last_heartbeat DATETIME"); console.log('[DB] Added last_heartbeat column'); } catch { /* already exists */ }
- // Migrate: add theme_id to users table if missing
-diff --git a/server/internal/routes.js b/server/internal/routes.js
-index 540fcb2..d962f81 100644
-+++ b/server/internal/routes.js
-@@ -17,6 +17,11 @@ function requireInternalKey(req, res, next) {
-
- router.use(requireInternalKey);
-
-+// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.
-+function notifyChat(userId) {
-+ try { const cs = require('../chat/chat-server'); if (cs.remote) cs.userChanged(userId); } catch { /* non-critical */ }
-+}
-+
- // ── Site copy for the shared footer (OpenVibe.Network → here, once a day) ─────────────
- // The Network sends facts about each site plus an allow-list of link ids; the model writes a
- // short blurb per site and PICKS ids from that list. It never returns URLs or markup, so nothing
-@@ -81,6 +86,7 @@ router.post('/user-avatar', (req, res) => {
- if (!user && username) user = db.getUserByUsername(username);
- if (!user) return res.status(404).json({ ok: false, error: 'user not found' });
- if ((user.avatar_url || null) !== url) db.updateUserAvatar(user.id, url, null);
-+ notifyChat(user.id);
- return res.json({ ok: true, id: user.id, changed: (user.avatar_url || null) !== url });
- } catch (err) {
- console.error('[Internal] user-avatar error:', err.message);
-@@ -107,6 +113,7 @@ router.post('/user-role', (req, res) => {
- // Never strip the owner's admin role via a role push (is_owner is local).
- const finalRole = (user.is_owner && role !== 'admin') ? 'admin' : role;
- db.getDb().prepare('UPDATE users SET role = ? WHERE id = ?').run(finalRole, user.id);
-+ notifyChat(user.id);
- console.log(`[Internal] role push: ${user.username} -> ${finalRole}`);
- return res.json({ ok: true, id: user.id, username: user.username, role: finalRole });
- } catch (err) {
-diff --git a/server/net/service-guard.js b/server/net/service-guard.js
-new file mode 100644
-index 0000000..e4b13d4
-+++ b/server/net/service-guard.js
-@@ -0,0 +1,45 @@
-+'use strict';
-+/**
-+ * Live as a receiver of service tokens (roadmap Wave 6).
-+ *
-+ * guard(capability) checks a Network client-credentials token (RS256, contract
-+ * identity.service-token-claims@1) for audience openvibe.live on Live's own internal routes —
-+ * today the ones OpenVibe.Chat calls (/internal/chat-context/*, /internal/chat-effects/*). The
-+ * token is verified with the same Network key Live verifies user JWTs with.
-+ *
-+ * Chat's capabilities (live.chat_context.read, live.chat_effects.write, live.chat_mirror.write)
-+ * are proposals not yet in the openvibe-contracts registry, whose requireCapability() refuses
-+ * unknown ids; until they are registered the grant is checked with capabilities.grants() and the
-+ * guard switches to capabilities.check() by itself once the registry knows the id. Like
-+ * X-Internal-Key routes, these are loopback-only: anything that came through nginx is refused.
-+ */
-+const { serviceAuth, capabilities, http } = require('openvibe-contracts');
-+const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');
-+
-+const AUDIENCE = 'openvibe.live';
-+
-+function viaProxy(req) {
-+ return !!(req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']);
-+}
-+
-+function guard(capability) {
-+ const registered = !!capabilities.get(capability);
-+ return function liveServiceGuard(req, res, next) {
-+ const ctx = http.requestContext(req.headers);
-+ if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });
-+ const auth = String(req.headers.authorization || '');
-+ if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });
-+ const publicKey = getNetworkPublicKey();
-+ if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });
-+ const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: getNetworkIssuer(), audience: AUDIENCE });
-+ if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });
-+ const c = registered
-+ ? capabilities.check(r.claims, capability)
-+ : (capabilities.grants(r.claims.cap, capability) ? { allowed: true } : { allowed: false, code: 'capability.denied', reason: `${capability} not granted` });
-+ if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });
-+ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
-+ next();
-+ };
-+}
-+
-+module.exports = { guard, AUDIENCE };
-diff --git a/test/chat-context.test.js b/test/chat-context.test.js
-new file mode 100644
-index 0000000..1d935cc
-+++ b/test/chat-context.test.js
-@@ -0,0 +1,283 @@
-+'use strict';
-+
-+// Chat moved to OpenVibe.Chat (roadmap Wave 6). What Live answers it on /internal/chat-context/*
-+// and /internal/chat-effects/* (service tokens, capabilities, re-checked moderators, the read
-+// mirror), and the chat-server proxy Live's own modules get with CHAT_AUTHORITY=chat (ordered
-+// bridge calls, placeholder ids for forwarded inserts, presence reads). Against stub Network and
-+// Chat servers.
-+
-+const assert = require('assert');
-+const fs = require('fs');
-+const os = require('os');
-+const path = require('path');
-+const http = require('http');
-+const crypto = require('crypto');
-+const jwt = require('jsonwebtoken');
-+const { serviceAuth } = require('openvibe-contracts');
-+
-+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chatctx-'));
-+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
-+fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
-+fs.mkdirSync(path.join(tmp, 'sounds'));
-+process.env.DB_PATH = path.join(tmp, 'live.db');
-+process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
-+process.env.OV_NETWORK_URL = 'https://openvibe.network';
-+process.env.OV_OAUTH_CLIENT_ID = 'live';
-+process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
-+process.env.SOUNDS_PATH = path.join(tmp, 'sounds');
-+process.env.CHAT_AUTHORITY = 'chat';
-+const quiet = console.log;
-+console.log = () => {};
-+console.warn = () => {};
-+
-+const ISS = 'https://openvibe.network';
-+const now = () => Math.floor(Date.now() / 1000);
-+let jti = 0;
-+function serviceToken(cap, { aud = 'openvibe.live', sub = 'svc:chat' } = {}) {
-+ return serviceAuth.signServiceToken({ iss: ISS, sub, actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey);
-+}
-+const READ = serviceToken(['live.chat_context.read']);
-+const WRITE = serviceToken(['live.chat_effects.write']);
-+const MIRROR = serviceToken(['live.chat_mirror.write']);
-+
-+// Stub Network (Live's own service token for audience openvibe.chat) and stub Chat (the bridge).
-+const bridgeCalls = [];
-+const network = http.createServer((req, res) => {
-+ let raw = '';
-+ req.on('data', (c) => { raw += c; });
-+ req.on('end', () => {
-+ res.setHeader('Content-Type', 'application/json');
-+ if (req.url === '/oauth/token') {
-+ const f = new URLSearchParams(raw);
-+ return res.end(JSON.stringify({ access_token: serviceToken(['chat.live_bridge.write', 'chat.presence.read'], { aud: f.get('audience'), sub: 'svc:live' }), expires_in: 300 }));
-+ }
-+ res.statusCode = 404; res.end('{}');
-+ });
-+});
-+const chat = http.createServer((req, res) => {
-+ let raw = '';
-+ req.on('data', (c) => { raw += c; });
-+ req.on('end', () => {
-+ res.setHeader('Content-Type', 'application/json');
-+ assert.ok(String(req.headers.authorization || '').startsWith('Bearer '), 'Live calls Chat with a service token');
-+ if (req.url === '/internal/live/calls') {
-+ const body = JSON.parse(raw);
-+ bridgeCalls.push(body);
-+ return res.end(JSON.stringify({ ok: true, results: body.ops.map((o) => ({ seq: o.seq, ok: true })) }));
-+ }
-+ if (req.url === '/internal/live/presence') {
-+ return res.end(JSON.stringify({ total: 7, streams: { 1: 3 }, slow_mode: { 1: 5000 }, users: [{ user_id: 3, ip: '198.51.100.3', stream_id: 1 }], anons: [{ anon_id: 'anon9', ip: '203.0.113.9', stream_id: 1 }] }));
-+ }
-+ res.statusCode = 404; res.end('{}');
-+ });
-+});
-+
-+const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));
-+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-+
-+(async () => {
-+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;
-+ process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${await listen(chat)}`;
-+
-+ const db = require('../server/db/database');
-+ db.initDb();
-+ require('../server/chat/dm').ensureTables();
-+ const d = db.getDb();
-+ const mkUser = (username, role = 'user', extra = {}) => {
-+ const id = db.createUser({ username, email: `${username}@example.test`, password_hash: '!x', display_name: username.toUpperCase(), stream_key: `key-${username}` }).lastInsertRowid;
-+ d.prepare('UPDATE users SET role = ?, is_owner = ? WHERE id = ?').run(role, extra.owner ? 1 : 0, id);
-+ return Number(id);
-+ };
-+ const owner = mkUser('owner', 'admin', { owner: true });
-+ const admin = mkUser('admin2', 'admin');
-+ const streamer = mkUser('streamer', 'streamer');
-+ const mod = mkUser('moddy');
-+ const viewer = mkUser('viewer');
-+ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '501', 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ')").run(viewer);
-+ db.createChannel({ user_id: streamer, title: 'Streamer TV' });
-+ const channel = db.getChannelByUserId(streamer);
-+ const streamId = Number(db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' }).lastInsertRowid);
-+ db.addChannelModerator ? db.addChannelModerator(channel.id, mod, streamer) : d.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(channel.id, mod, streamer);
-+ d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)').run(viewer, streamer);
-+ db.setSetting('tts_enabled', 'true');
-+ db.setSetting('stripe_secret_key', 'sk_live_never_shared');
-+
-+ const express = require('express');
-+ const routes = require('../server/chat/live-context-routes');
-+ const app = express();
-+ app.use(express.json());
-+ app.use('/internal/chat-context', routes.contextRouter);
-+ app.use('/internal/chat-effects', routes.effectsRouter);
-+ const port = await listen(http.createServer(app));
-+ const call = async (method, p, { token, body, headers = {} } = {}) => {
-+ const res = await fetch(`http://127.0.0.1:${port}${p}`, {
-+ method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers },
-+ body: body ? JSON.stringify(body) : undefined,
-+ });
-+ const text = await res.text();
-+ let json = null; try { json = JSON.parse(text); } catch { /* */ }
-+ return { status: res.status, body: json, text };
-+ };
-+
-+ let exit = 0;
-+ try {
-+ // 1. Service tokens only: none, wrong audience, missing capability, through nginx.
-+ assert.strictEqual((await call('GET', '/internal/chat-context/users')).status, 401);
-+ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: serviceToken(['live.chat_context.read'], { aud: 'openvibe.media' }) })).status, 401);
-+ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: WRITE })).status, 403);
-+ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: READ, headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');
-+ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: READ, body: { user_id: viewer, color: '#ff00ff' } })).status, 403, 'reads cannot write');
-+
-+ // 2. Users projection: paged, with the Network subject, never secrets.
-+ const users = (await call('GET', '/internal/chat-context/users?after_id=0&limit=2', { token: READ })).body.rows;
-+ assert.strictEqual(users.length, 2);
-+ const all = (await call('GET', `/internal/chat-context/users?after_id=${users[1].id}`, { token: READ })).body.rows;
-+ const v = all.find((u) => u.id === viewer);
-+ assert.strictEqual(v.subject_id, 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ');
-+ for (const u of users.concat(all)) { assert.ok(!('email' in u) && !('password_hash' in u) && !('stream_key' in u), 'no secrets in the projection'); }
-+
-+ // 3. Token resolution follows Live's rules and carries the subject.
-+ const userJwt = jwt.sign({ sub: '501', username: 'viewer', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 600 });
-+ const auth = (await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: userJwt } })).body;
-+ assert.strictEqual(auth.user.id, viewer);
-+ assert.strictEqual(auth.user.subject_id, 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ');
-+ assert.strictEqual(auth.user.auth_source, 'network');
-+ assert.ok(auth.expires_at);
-+ assert.deepStrictEqual((await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: 'garbage' } })).body, { user: null, reason: 'invalid' });
-+
-+ // 4. Streams, channel policy (settings + moderators + language), follows, bans with version.
-+ const s = (await call('GET', `/internal/chat-context/streams/${streamId}`, { token: READ })).body;
-+ assert.strictEqual(s.stream.user_id, streamer);
-+ assert.strictEqual(s.owner.username, 'streamer');
-+ assert.strictEqual(s.channel.id, channel.id);
-+ assert.ok(!('stream_key' in s.stream));
-+ const active = (await call('GET', '/internal/chat-context/streams/active', { token: READ })).body.rows;
-+ assert.ok(active.some((r) => r.id === streamId && r.is_live === 1));
-+ const policy = (await call('GET', `/internal/chat-context/channels/${channel.id}/policy`, { token: READ })).body;
-+ assert.deepStrictEqual(policy.moderator_ids, [mod]);
-+ assert.strictEqual(policy.settings.allow_anonymous, 1, 'defaults when the channel has no row');
-+ assert.deepStrictEqual((await call('GET', `/internal/chat-context/users/${viewer}/follows`, { token: READ })).body.streamer_ids, [streamer]);
-+ const b1 = (await call('GET', '/internal/chat-context/bans', { token: READ })).body;
-+ assert.deepStrictEqual(b1.bans, []);
-+ assert.strictEqual((await call('GET', `/internal/chat-context/bans?version=${encodeURIComponent(b1.version)}`, { token: READ })).body.unchanged, true);
-+ const settings = (await call('GET', '/internal/chat-context/settings', { token: READ })).body.settings;
-+ assert.strictEqual(settings.tts_enabled, db.getSetting('tts_enabled'), 'typed like Live\'s getSetting');
-+ assert.ok(!('stripe_secret_key' in settings), 'only chat settings leave Live');
-+
-+ // 5. Effects need CHAT_AUTHORITY=chat.
-+ process.env.CHAT_AUTHORITY = '';
-+ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 409);
-+ process.env.CHAT_AUTHORITY = 'chat';
-+ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 200);
-+ assert.strictEqual(db.getUserById(viewer).profile_color, '#ff00ff');
-+
-+ // 6. Bans: Live re-checks the moderator; the rows are exactly chat-server's.
-+ const ban = (body) => call('POST', '/internal/chat-effects/ban', { token: WRITE, body });
-+ assert.strictEqual((await ban({ action: 'ban', actor_user_id: viewer, moderation_stream_id: streamId, stream_id: streamId, user_id: mod })).status, 403, 'a viewer cannot ban');
-+ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: owner })).status, 403, 'a channel mod cannot ban an admin');
-+ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer, reason: 'Banned by moderator', banned_by: mod })).status, 200);
-+ assert.ok(db.isUserBanned(viewer, streamId));
-+ const b2 = (await call('GET', `/internal/chat-context/bans?version=${encodeURIComponent(b1.version)}`, { token: READ })).body;
-+ assert.strictEqual(b2.bans.length, 1, 'the version moved with the table');
-+ assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);
-+ assert.ok(!db.isUserBanned(viewer, streamId));
-+
-+ // 7. TTS settings: admins; credentials only the owner.
-+ const put = (actor, settingsBody) => call('POST', '/internal/chat-effects/site-settings', { token: WRITE, body: { actor_user_id: actor, settings: settingsBody } });
-+ assert.strictEqual((await put(viewer, { tts_enabled: 'false' })).status, 403);
-+ assert.strictEqual((await put(admin, { tts_max_length: 300, tts_google_api_key: 'admin-cannot' })).body.updated, 1);
-+ assert.notStrictEqual(db.getSetting('tts_google_api_key'), 'admin-cannot', 'an admin cannot set a credential');
-+ assert.strictEqual((await put(owner, { tts_google_api_key: 'owner-can' })).body.updated, 1);
-+ assert.strictEqual(db.getSetting('tts_google_api_key'), 'owner-can');
-+
-+ // 8. Slow mode persistence and alert sounds: only for people who may.
-+ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { slow_mode_seconds: 9 } } })).status, 403);
-+ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { slow_mode_seconds: 9 } } })).status, 200);
-+ assert.strictEqual(db.getChannelModerationSettings(channel.id).slow_mode_seconds, 9);
-+ const snd = path.join(tmp, 'sounds', 'alert.mp3'); fs.writeFileSync(snd, 'x');
-+ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, kind: 'donation', url: snd } })).status, 403, 'only the channel owner');
-+ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');
-+ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: snd } })).status, 200);
-+
-+ // 9. The read mirror: same ids, Chat-only columns ignored, Live-only columns kept.
-+ const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { token: MIRROR, body: { changes } });
-+ assert.strictEqual((await call('POST', '/internal/chat-effects/mirror', { token: WRITE, body: { changes: [] } })).status, 403, 'mirror has its own capability');
-+ let m = await mirror([{ table: 'chat_messages', op: 'upsert', row: { id: 900001, stream_id: streamId, channel_user_id: streamer, user_id: viewer, username: 'VIEWER', message: 'hi from chat', message_type: 'chat', is_global: 0, is_deleted: 0, timestamp: '2026-09-22 10:00:00', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' } }]);
-+ assert.strictEqual(m.body.applied, 1);
-+ assert.strictEqual(db.getChatMessageById(900001).message, 'hi from chat');
-+ // Columns only Live's copy has (media-proxy/asset-sync adds them at start).
-+ for (const c of ['media_url TEXT', 'media_asset_id INTEGER']) { try { d.exec(`ALTER TABLE channel_sounds ADD COLUMN ${c}`); } catch { /* present */ } }
-+ d.prepare("INSERT INTO channel_sounds (id, channel_owner_id, command, url, media_asset_id) VALUES (77, ?, 'honk', '/x.mp3', 555)").run(streamer);
-+ m = await mirror([{ table: 'channel_sounds', op: 'upsert', row: { id: 77, channel_owner_id: streamer, command: 'honk2', url: '/x.mp3', created_by_subject_id: null } }, { table: 'chat_messages', op: 'delete', pk: { id: 900001 } }, { table: 'users', op: 'delete', pk: { id: viewer } }]);
-+ assert.strictEqual(m.body.applied, 2);
-+ assert.strictEqual(m.body.skipped.length, 1, 'only chat tables are mirrored');
-+ const s77 = d.prepare('SELECT command, media_asset_id FROM channel_sounds WHERE id = 77').get();
-+ assert.deepStrictEqual(s77, { command: 'honk2', media_asset_id: 555 });
-+ assert.strictEqual(db.getChatMessageById(900001), undefined);
-+ assert.ok(db.getUserById(viewer), 'users are never touched');
-+
-+ // 10. The proxy Live's modules get: ordered bridge calls, placeholders, presence.
-+ const chatServer = require('../server/chat/chat-server');
-+ assert.strictEqual(chatServer.remote, true);
-+ chatServer.init();
-+ const before = d.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n;
-+ const saved = db.saveChatMessage({ stream_id: streamId, user_id: null, username: 'Bot', message: 'beep', message_type: 'chat', source_platform: 'ai' });
-+ assert.ok(saved.lastInsertRowid <= -(2 ** 40), 'a placeholder id');
-+ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, before, 'inserts happen in Chat, not here');
-+ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 1, 'kept until Chat acknowledges');
-+ chatServer.broadcastToStream(streamId, { type: 'chat', id: saved.lastInsertRowid, message: 'beep' });
-+ chatServer.synthesizeAndBroadcastTTS(streamId, 'Bot', 'beep', null, null, 'aibot:bot', null, `m${saved.lastInsertRowid}`);
-+ await chatServer.flush();
-+ await sleep(100);
-+ const ops = bridgeCalls.flatMap((c) => c.ops);
-+ assert.deepStrictEqual(ops.map((o) => o.op), ['db', 'broadcastToStream', 'synthesizeAndBroadcastTTS']);
-+ assert.strictEqual(ops[0].args[0], 'saveChatMessage');
-+ assert.strictEqual(ops[0].ref, saved.lastInsertRowid);
-+ assert.ok(/^live:\d+$/.test(ops[0].key), 'forwarded writes carry an idempotency key');
-+ assert.strictEqual(ops[1].args[1].id, saved.lastInsertRowid);
-+ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 0, 'acknowledged writes leave the outbox');
-+ // Deletes run on the mirror at once (their ids are returned) and are forwarded too.
-+ d.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (900002, ?, 'MODDY', 'x')").run(mod);
-+ assert.deepStrictEqual(db.deleteUserChatMessages(mod, {}), [900002]);
-+ await chatServer.flush();
-+ assert.deepStrictEqual(bridgeCalls.at(-1).ops[0].args.slice(0, 2), ['deleteUserChatMessages', mod]);
-+ // Live's own writes to data Chat caches (dashboard mods, IP approvals) tell Chat to reload it.
-+ db.addChannelModerator(channel.id, viewer, streamer);
-+ await chatServer.flush();
-+ assert.deepStrictEqual(bridgeCalls.at(-1).ops.map((o) => [o.op, ...o.args]), [['invalidate', 'channel', channel.id]]);
-+ assert.ok(d.prepare('SELECT 1 FROM channel_moderators WHERE channel_id = ? AND user_id = ?').get(channel.id, viewer), 'the write itself still happens in Live');
-+ // The /api/mod global delete loops over chatServer.clients: one pseudo-socket reaches everyone.
-+ for (const [ws] of chatServer.clients) { if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'delete-messages', ids: [1] })); }
-+ await chatServer.flush();
-+ assert.strictEqual(bridgeCalls.at(-1).ops[0].op, 'broadcastAllRaw');
-+ // Synchronous reads come from Chat's presence snapshot.
-+ await sleep(200);
-+ assert.strictEqual(chatServer.getTotalConnections(), 7);
-+ assert.strictEqual(chatServer.getStreamViewerCount(1), 3);
-+ assert.strictEqual(chatServer.slowModeByStream.get(1), 5000);
-+ assert.strictEqual(chatServer.getConnectedUserIp(3), '198.51.100.3');
-+ assert.strictEqual(chatServer.findClientByAnonId('anon9', 1).ip, '203.0.113.9');
-+ assert.strictEqual(chatServer.findClientByAnonId('anon9', 2), null);
-+ // A /ws/chat upgrade that still lands on Live is refused, not served from the mirror.
-+ let written = '';
-+ chatServer.handleUpgrade({}, { write: (x) => { written += x; }, destroy() {} });
-+ assert.match(written, /^HTTP\/1\.1 503/);
-+ chatServer.close();
-+
-+ // Rollback: writes Chat never acknowledged are applied to Live's own tables.
-+ d.prepare("INSERT INTO chat_bridge_outbox (boot, ref, op, args) VALUES ('old', -1, 'db', ?)").run(JSON.stringify(['recordFirstChat', 'user:77', streamer]));
-+ chatServer._restoreDb();
-+ assert.strictEqual(require('../server/chat/chat-remote').drainToLocal({ log() {}, warn() {} }), 1);
-+ assert.ok(d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = 'user:77'").get());
-+ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 0);
-+
-+ quiet('chat context (Live side of OpenVibe.Chat): all checks passed');
-+ } catch (err) {
-+ console.error(err);
-+ exit = 1;
-+ } finally {
-+ try { db.close(); } catch { /* */ }
-+ fs.rmSync(tmp, { recursive: true, force: true });
-+ process.exit(exit);
-+ }
-+})();
deleted file mode 100644
deleted file mode 100644
@@ -1,63 +0,0 @@
@@ -1,63 +0,0 @@
-Repository: /home/workstation/OpenVibers/OpenVibe.Chat (work ONLY here). Never open .env files or anything holding secrets; never commit, push, deploy, or touch other repositories (read others freely). Use Node 22: prefix node/npm with `fnm exec --using=22.22.1`. Run every command in the foreground. Build fake keys in tests at runtime. Never change how any page looks. `rm` is denied: delete files with `git rm`. Migrations: new files under `migrations/`; never edit one another commit added. Work in commits-sized steps and keep `npm test` green after each; you do not commit — list the steps in your report. End with what you changed per step (file:line), the per-table import report format, the p95 numbers of the history page before/after, and the final summary lines of `npm test` and `npm run test:pg`.
-
-# Plan T3 (last part): OpenVibe.Chat on PostgreSQL + Valkey
-
-Read first: docs/pg-port/research.md (the verified research: every table, file,
-sync call site, risk and step). It was written BEFORE T3 finished; the facts below override it.
-
-## What changed since the brief (facts)
-- T3 is done: Chat has been the only writer of the six tables since 2026-09-29 14:46 UTC; the staged machinery
- (stagedApply/stagedSlice/tableAuthority ops, sliceHash, applyStagedChanges, scripts/table-authority.js,
- docs/staged-tables-cutover.md) and `table_authority` are DELETED. Brief steps 8, 10 and 11 and risks 10 and the
- staged half of 3 no longer apply; open question 1 is moot.
-- STILL LIVE: the C-02/C-03 bridge and the mirror of the TWELVE non-staged tables into Live's copies
- (`live_mirror_outbox`, `installMirrorTriggers()` TEMP triggers gated per connection, `scripts/mirror-flush.js`).
-- Chat pins openvibe-sdk v0.23.1 today; the target is v0.25.2 (ships `openvibe-sdk/db`, `/testing`, `/limits`,
- `/valkey`, and `tools/asyncify` in the SDK repo at the tools/asyncify folder of github.com/OpenVibers/OpenVibe.SDK (clone it)).
-- Patterns to copy (read-only): OpenVibe.Media `server/db/database.js` (openDb/initDb/getDb, PGlite dev fallback) and
- `migrations/0001_initial.sql` prelude (ov_ts, ov_now, datetime(), json_* compat); OpenVibe.Events/OpenVibe.Community
- test/helpers for the per-process migrated test database and `npm run test:pg`.
-
-## Decisions (Opus; build exactly these)
-1. Mirror capture: real PostgreSQL triggers (plpgsql, in the migration) on the twelve mirrored tables append to
- `live_mirror_outbox`, skipped when `current_setting('ov.mirror_skip', true) = '1'`; the bridge's apply path (Live's
- writes) runs `SET LOCAL ov.mirror_skip = '1'` inside its transaction so Live's changes never come back.
- `installMirrorTriggers()` and the TEMP-trigger code go. Test: "Live's changes never come back", "every Chat writer is
- captured", "pending 0 after a flush", on PGlite and PostgreSQL.
-2. `chat_messages` page order is `id DESC` alone (ids are preserved by the import, so order is too); keep `timestamp`
- as SQLite-format text via `ov_now()` for display. Index to match; EXPLAIN in the latency test shows an index scan.
-3. Audio queue: the claim is one atomic `UPDATE audio_requests SET state='playing', attempts=attempts+1 WHERE id=$1 AND
- state='queued' RETURNING *` (zero rows = someone else took it); the synth runs OUTSIDE any transaction; enqueue's
- dedupe is a unique index + `ON CONFLICT DO NOTHING` with the caps counted inside the transaction. Boot keeps today's
- handling of rows left 'playing'. Test: two concurrent pump() loops on real PostgreSQL → exactly one play per
- request.
-4. SDK v0.25.2 first (step 0, its own step), with `pg`, `@electric-sql/pglite`, `iovalkey`.
-5. Valkey backs the per-actor rate limiter (`openvibe-sdk/limits` + `createValkeyLimitStore`, `VALKEY_URL`/
- `VALKEY_PREFIX`, a /ready check that reports `skipped` when unset). The history-store memo stays in-process.
-6. N-1: the engine-switch release cannot replay SQLite-dialect SQL. `test/n-1.test.js` prints one
- `skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys)` line for this release only
- (the ○ convention), and `npm run n-1:record` must work against PostgreSQL afterwards (record the previous
- release's statements from its PostgreSQL schema). Do not delete the harness.
-7. Account export / subject merge / live-mirror dynamic SQL: dedicated static queries per table (no interpolated
- identifiers), ordered by the primary key where the SQLite code used rowid ("most recent N" = highest id).
-8. `relay_users` gets a real identity column `id` (the answers keep their `id` field; `CHAT_TABLES.relay_users` keys
- by id).
-9. Timestamps stay SQLite-format text (the compat prelude), like Media and Live's decision; booleans stay integers
- where the code compares with 0/1.
-10. Production import: `scripts/import-sqlite-to-pg.js` wrapping `openvibe-sdk/db` `importSqlite` (per-table count +
- checksum report written to `import_runs`, sequences `setval` above the imported max), runnable with
- `--pglite <dir>` as a dry run. It is run later by Opus through Host's switch tooling; you only build and test it
- (a test imports a SQLite fixture into PGlite and checks the report and a post-import insert id).
-11. Production requires `DATABASE_URL` (runtime, PgBouncer) and `DATABASE_DIRECT_URL` (owner, migrations); dev and
- `npm test` use embedded PGlite; `npm run test:pg` uses `eval "$(a local PostgreSQL 16 + Valkey (install with apt in this session if missing) up)"`.
- Last step: better-sqlite3 leaves `package.json` and nothing in `server/` requires it (a guard test).
-
-## Steps (brief §9, renumbered): 0 SDK+drivers → 1 migrations scaffold (+ triggers of decision 1) → 2 open/init/get +
-test preload → 3 dialect fixes + RETURNING helper → 4 asyncify (+ the MANUAL sites, + dynamic SQL per decision 7) →
-5 history store (decision 2, latency test over 200k rows) → 6 audio queue (decision 3) → 7 mirror on triggers
-(decision 1) → 8 Valkey limiter → 9 N-1 (decision 6) → 10 import script (decision 10) → 11 drop better-sqlite3.
-
-Gate: `npm test` (PGlite) and `npm run test:pg` both green at the end; `test/live-down.test.js`, `test/bridge.test.js`,
-`test/outbox-mirror.test.js`, `test/audio-queue.test.js`, `test/chat-apis.test.js`, `test/chat-ai.test.js` unchanged in
-meaning. Update README.md (storage section), STATUS.json and docs/cutover.md (the PostgreSQL switch runbook: backup,
-drain, import with the report, switch env, verify /ready and counts, rollback = previous release + the SQLite file).
deleted file mode 100644
deleted file mode 100644
@@ -1,135 +0,0 @@
@@ -1,135 +0,0 @@
-# T3 — OpenVibe.Chat on PostgreSQL + Valkey (attempt 3)
-
-Worked only in /home/workstation/OpenVibers/OpenVibe.Chat, Node 22 (`fnm exec --using=22.22.1`), every command in the
-foreground, no commits, no `rm`. Nothing on a page was touched (CSS/markup/copy), so there is no "For Opus" item.
-
-## What this attempt did
-
-Attempt 3 finished the one remaining step that is independent of the engine move (step 9) and verified the whole tree.
-It did **not** start the engine move (steps 2–4): that change is atomic — the moment `server/db/database.js` hands back
-Promises, every one of ~467 db-op call sites in 40 server files and ~40 test files breaks at once, and the reading is
-that a half-converted tree is worse than a consistent one. So the tree is left green and consistent, with a precise
-statement of what remains.
-
-### Step 9 — N-1 (decision 6): DONE
-- `test/n-1.test.js:37-48` — added a guard that prints exactly one skip line and exits 0, for this release only:
- `n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys)`.
- Verified against `openvibe-shared/test-runner`'s `SKIP_RE`, so the file is listed with ○ and **not** counted as
- passed (no fake green). The harness (`test/n-1/harness.js`) and the fixtures are kept, as required.
-- The second half of decision 6 — `npm run n-1:record` recording the previous release's statements from its PostgreSQL
- schema — is **blocked on the engine move**: the recorder reads the release's schema, which is still SQLite until
- step 2 lands. `scripts/n-1-record.js` is unchanged and still works against the current (SQLite) release.
-
-### Verified (not changed) — the work a1/a2 left in the tree
-- `package.json:11` `test:pg`, `package.json:34` openvibe-sdk v0.25.2 (pg/@electric-sql/pglite/iovalkey present).
-- `migrations/0001_initial.sql` (738 lines): 41 tables, 42 indexes, the SQLite-compat prelude
- (`ov_ts/ov_now/ov_now_iso/datetime/julianday/json_*/instr`), `relay_users` identity `id`, and 12 real PostgreSQL
- mirror triggers on the twelve Live-read tables, each gated on `current_setting('ov.mirror_skip', true) = '1'`.
-- `server/config.js:47-56` `db.{url,directUrl}` and `valkey.{url,prefix}`.
-- Step 5 (decision 2): `server/chat/history-store.js:103` `ORDER BY cm.id DESC` alone; `:41/:55/:170` portable
- `datetime('now')`; `server/db/schema.sql:53` partial index `idx_chat_page_live (id DESC) WHERE is_deleted = 0`;
- `test/history-latency.test.js`.
-- Step 8 (decision 5): `server/net/actor-limits.js:30,87-94` lazy limiter behind `createValkeyLimitStore`;
- `server/index.js:38` `limits.useValkey(valkey)` at boot; `server/app.js:146` optional `/ready` valkey check;
- `test/actor-limits.test.js` shared-budget case (runs on PG+Valkey, skips without `VALKEY_URL`).
-- Step 10 (decision 10): `scripts/import-sqlite-to-pg.js` (84 lines).
-- Docs: `README.md` storage section, `STATUS.json` runtime line, `docs/cutover.md` PostgreSQL switch runbook.
-
-## Per-table import report format (decision 10 / step 10)
-
-`node scripts/import-sqlite-to-pg.js --sqlite <file> (--url <pg-url> | --pglite <dir>) [--truncate] [--no-verify]`.
-It wraps `openvibe-sdk/db` `importSqlite`, which returns
-`{ ok, tables: [{ table, source, rows, ms, checksum }], problems: [] }`; `scripts/import-sqlite-to-pg.js:63-67` prints
-one line per table, then the tail, then records the run in `import_runs`:
-
- [import] chat_messages 12345 rows checksum 9f3c… (16 hex) (42 ms)
- [import] dm_messages 678 rows checksum 1a0b… (7 ms)
- …
- [import] PROBLEM <table>: <problem> # only when a table fails to settle
- [import] 41 tables, 123456 rows, ok # or FAILED, which exits 1
-
-Per table: `table` (name), `source` (SQLite table it came from), `rows` (count), `ms` (insert time), `checksum`
-(16 hex chars — row count + a canonical-text hash of every row walked in primary-key order, computed on both sides and
-compared; `--no-verify` omits it). `problems[]` entries carry `{ table, problem }`. The session runs with
-`SET ov.mirror_skip = '1'` (imported rows never queue into Live's mirror), then `ANALYZE` as the owner, then
-`SET ov.mirror_skip = '0'`; identity sequences are `setval`'d past the imported max. Covered by
-`test/import-pg.test.js` (report, rows, empty mirror queue, post-import id past the max).
-
-## History page p95 — before / after (decision 2 / step 5)
-
-Same query (the global page of `history-store.js`), 100 reps, `LIMIT 60`, owner-side `ANALYZE`, measured on
-PGlite 0.5.8 and on the real PostgreSQL 18 + PgBouncer containers:
-
-| rows | store | BEFORE `ORDER BY timestamp DESC, id DESC` | AFTER `ORDER BY id DESC` | AFTER plan |
-| --- | --- | --- | --- | --- |
-| 60 000 | PGlite | 12.92 ms | **9.61 ms** | Limit → Index Scan, no Sort |
-| 60 000 | PostgreSQL | 7.54 ms | **6.36 ms** | Index Scan Backward, no Sort (BEFORE: Incremental Sort over `idx_chat_timestamp`) |
-| 200 000 | PGlite | 15.98 ms | **13.50 ms** | Limit → Index Scan, no Sort |
-| 200 000 | PostgreSQL | 9.49 ms | **9.44 ms** | Index Scan Backward using `chat_messages_pkey`, no Sort |
-
-The real requirement is the plan, not the wall clock: AFTER never has a Sort (the ordering comes straight from the
-index), BEFORE does. The large a2 number — and why the import runs `ANALYZE` — is confirmed here: when the runtime
-role's `ANALYZE` is (silently) a no-op, there are no column statistics and the AFTER page query degrades to
-bitmap scan + Sort (**≈ 290 ms at 60 000 rows**, measured), versus **≈ 6 ms** with statistics. The runtime role is
-DML-only, so `ANALYZE` must run as the owner (importer, autovacuum).
-
-## Final gate
-
-`npm test` (PGlite):
-
- 44/45 test files passed, 1 skipped (n-1.test.js: n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys))
-
-`npm run test:pg` (containers up: `eval "$(OpenVibe.SDK/scripts/test-services.sh up)"`):
-
- 44/45 test files passed, 1 skipped (n-1.test.js: n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys))
-
-Caveat, stated plainly: until the engine move lands, the app itself still serves from SQLite, so `test:pg` exercises
-PostgreSQL only through the PG-backed tests (`history-latency`, `actor-limits` on Valkey, `pg-schema`, `import-pg`) and
-runs the rest of the suite on SQLite. It is green, but it is not yet the production shape.
-
-## Steps — status
-
-| Step | What | Status |
-| --- | --- | --- |
-| 0 | SDK v0.25.2 + `pg`/`pglite`/`iovalkey` | done (a1) |
-| 1 | `migrations/0001_initial.sql` + mirror triggers | done (a1) |
-| 2 | `openDb/initDb/getDb` over `openvibe-sdk/db` + `test/helpers/pg-preload.mjs` | **not started** |
-| 3 | dialect fixes + `RETURNING` helper | **not started** |
-| 4 | `tools/asyncify` + the manual sites + dynamic SQL per table | **not started** |
-| 5 | history store `id DESC` + index + latency test | done (a2) |
-| 6 | audio queue atomic claim | **not started** (needs 2–4) |
-| 7 | drop `installMirrorTriggers`/TEMP triggers, `SET LOCAL ov.mirror_skip` on the bridge path | **not started** (needs 2–4) |
-| 8 | Valkey limiter | done (a1) |
-| 9 | N-1 skip line | done (a3); the `n-1:record` PG path needs 2–4 |
-| 10 | import script + report | done (a1) |
-| 11 | drop better-sqlite3 (+ guard test) | **not started** (needs 2–4) |
-
-## What remains, concretely (the atomic engine move)
-
-One change-sized step, all-or-nothing, no commit in between:
-1. Rewrite `server/db/database.js` (1492 lines, ~140 async exports) on `createDb({ url, pglite })`: `openDb/initDb/getDb`,
- `db.tx` for `transaction`, `db.prepare` for `stmt`, ambient transactions so callers need not thread a handle; keep
- `CHAT_TABLES`; delete `installMirrorTriggers()`/the TEMP-trigger block and `initDb({ captureMirror })` gating
- (decision 1/7 — the real triggers are already in the migration).
-2. `npm test`: add `test/helpers/pg-preload.mjs` (copy `OpenVibe.Media/test/helpers/pg-preload.mjs`, `service:'chat'`)
- and load it with `--import` from `test/run.js` (`store: process.env.OV_TEST_STORE || 'pglite'`).
-3. Convert every caller to `await` + `async` (~467 sites in 40 server files — `calls/call-server.js` 53, `rooms/rooms.js`
- 46, `live-context.js` 43, `chat/dm.js` 35, `chat/routes.js` 24, `chat/chat-server.js`/`chat/audio-queue.js`/
- `chat/account-data.js` 23 each, …; `test/helpers.js` and ~40 test files), replacing `CURRENT_TIMESTAMP` value sites
- with `ov_now()`/`datetime('now')`, and the account-export / subject-merge / live-mirror dynamic SQL with static
- statements per table ordered by the primary key (decision 7).
-4. Then, on the converted tree: audio atomic claim (decision 3), mirror `SET LOCAL ov.mirror_skip` on the bridge apply
- path, `n-1:record` from the PostgreSQL schema, and dropping `better-sqlite3` (decision 11, with a guard test that
- nothing under `server/` requires it).
-
-## For Opus
-
-Nothing — no page, styling, copy or icon change was needed by this attempt.
-
-
-Report written to `/home/workstation/openvibe/agents/jobs/t3-chat-pg.out.a3.report.md`.
-
-This attempt finished step 9 (the N-1 skip line in `test/n-1.test.js:37-48`), verified the whole tree, and left it green: `npm test` and `npm run test:pg` both read `44/45 test files passed, 1 skipped (n-1.test.js: …engine switch to PostgreSQL…)`. I measured the history-page p95 before/after (60k and 200k, PGlite and PostgreSQL): roughly 13→9.6 ms and 16→13.5 ms on PGlite, 7.5→6.4 ms (60k) and 9.5→9.4 ms (200k) on PG — the real win is no Sort node, and the plan collapses to ~290 ms when the owner `ANALYZE` is skipped.
-
-I deliberately did not start the engine move (steps 2–4): it is atomic across ~467 call sites in 80 files, and a half-converted tree would be worse. Steps 6, 7, 11 and the N-1 `record` PG path all wait on it; the report lists exactly how to do it. No UI changes needed. (The SDK's shared test containers are left up.)
\ No newline at end of file
\ No newline at end of file
deleted file mode 100644
deleted file mode 100644
@@ -1,138 +0,0 @@
@@ -1,138 +0,0 @@
-# [cloud] t3-chat-pg-engine: Chat serves from PostgreSQL (openvibe-sdk/db; PGlite for dev and npm test)
-
-Plan T3, last step: the engine move (brief steps 2–4 and what follows from them). One coherent change on top of
-`83497e7` (a1–a3: SDK v0.25.2, `migrations/0001_initial.sql`, the import script, the Valkey limiter, the N-1 skip).
-
-## What I built
-
-**Database layer** — `server/db/database.js` on `openvibe-sdk/db`:
-- `openDb/initDb/getDb/setDb` in Media's shape: `DATABASE_URL` (runtime role through PgBouncer, `DATABASE_POOL_MAX`
- optional) with `migrations/` applied first as the owner on `DATABASE_DIRECT_URL`; outside production without
- `DATABASE_URL`, an embedded PGlite in `data/pglite` (`CHAT_PGLITE_DIR`). Production refuses to start without both URLs.
-- `run/get/all/transaction` (and `tx`) are async with better-sqlite3's shapes: `run()` answers
- `{ changes, lastInsertRowid }` — an `INSERT` into a table with an `id` column gets `RETURNING id` appended — so
- no caller, bridge answer or HTTP body changes shape. `transaction(fn)` is `db.tx` with ambient transactions.
-- `installMirrorTriggers()`, the TEMP-trigger code, `ADDED_COLUMNS`/`PRAGMA` patching and `initDb({ captureMirror })`
- are gone; the four runtime schema creators (`rooms`, `token_revocations`, `network_blocks`,
- `account_data_events`) are no-ops (their tables are in the migration). `server/db/schema.sql` is removed.
-- Boot awaits `initDb()`; background loops that now await the database log a rejection instead of ending the process.
-
-**Every call site async** — `tools/asyncify` of OpenVibe.SDK over `server/**`, `scripts/**`, `test/**` (config
-with `asyncGlobals` for the helpers and `apis` for `db`/`ctx`/`chatServer`/…); a scratch patch to the tool taught it
-class methods (`this.x()`), and the rest by hand: `db.transaction(…)` → `db.tx`, the MANUAL sites
-(`.filter/.some` callbacks → loops or one `= ANY(?)` read), computed calls in the bridge (`db[fn]`, `chatServer[op]`),
-a getter that became async (`slowModeByStream()`), factory instances (`mirror.pending()`, `eventsConsumer.apply()`),
-and write loops the tool had turned into `Promise.all` put back in order. The Events inbox is the SDK's
-`createPgInbox`. Two orderings that used to be one synchronous tick are kept: a call's session row ends before
-anyone hears `call-ended`, and the channel leaves memory before the database is written.
-
-**SQL dialect** — `CURRENT_TIMESTAMP` → `ov_now()` (text in SQLite's shape, so comparisons with stored text stay
-right); `INSERT OR IGNORE/REPLACE` → `ON CONFLICT`; `LIKE` → `ILIKE`; `COLLATE NOCASE` → `lower()`; `IS ?` →
-`IS NOT DISTINCT FROM`; `MAX(a, ?)` → `GREATEST`; `"window"` quoted; `datetime(?, 'unixepoch')` → `to_timestamp`;
-`HAVING` on expressions and complete `GROUP BY`; `SUM` cast to bigint; derived-table aliases; qualified columns in
-`ON CONFLICT DO UPDATE`; a safe anon-id cast; `ORDER BY id` where SQLite's rowid order was relied on.
-
-**Decisions**
-1. Mirror: the migration's triggers capture all twelve tables; `live-mirror.js` reads rows with one static statement
- per table; with `LIVE_MIRROR` off the queue is emptied on the relay's cadence (as when capture was off). The
- importers set `ov.mirror_skip`. The bridge's `stagedApply` apply path was deleted on main before this change, so
- no bridge op applies Live's own rows any more (see For Opus).
-2. History: `id DESC` page (a2), and `deletedIds()` now windows by `id DESC` too.
-3. Audio queue: `claim(id)` = `UPDATE … SET state='playing', attempts=attempts+1 WHERE id=? AND state='queued'
- RETURNING *`; the synth runs outside any transaction; skip/clear of a row still being made send no skip frame;
- enqueue: caps counted in the transaction, dedupe by the unique `(room, dedupe_key)` + `ON CONFLICT DO NOTHING`.
- Boot keeps today's handling of rows left `playing`. `test/audio-claim.test.js`: two pumps (two module instances,
- separate pool connections on PostgreSQL) over 40 rooms → every request played exactly once (PostgreSQL: pump a
- 29–33, pump b 7–11 across runs), one claim wins, concurrent enqueues of one key store one row.
-5. Valkey limiter (a1) unchanged; `/ready` valkey check skips when unset.
-6. N-1: `npm run n-1:record` records a release on PostgreSQL (booted on its own PGlite dir; its applied migrations
- with their text; its statements as PostgreSQL received them, hooked on `openvibe-sdk/db`; source literals
- compiled to `$n`); `test/n-1.test.js` skips while the fixture is from a SQLite release (the same line as a3) and
- replays a PostgreSQL fixture by itself (rebuilds N-1's database from its migrations, this release migrates and
- serves it, every N-1 statement `PREPARE`s, no INSERT misses a new required column). Verified locally: recorded
- from this commit → 319 statements, all checks pass; a migration dropping `relay_users.display_name` fails it. The
- committed fixture is still the SQLite one, so this release reports the skip.
-7. Account export/erase, subject merge and the mirror are static statements per table (`= ANY(@param)`), export
- "most recent N" by primary key.
-8. `relay_users.id` is a real identity; `CHAT_TABLES.relay_users` keys by `id`.
-10. `scripts/import-sqlite-to-pg.js` reads the file with `node:sqlite`, applies `migrations/` first (also for
- `--url`), then imports, `ANALYZE`s and records the report in `import_runs`.
-11. better-sqlite3 leaves `package.json` (the lock keeps it only as an optional peer of another package, never
- installed). One-time tools read SQLite through `scripts/lib/sqlite.js` (`node:sqlite`, better-sqlite3's shape).
- `test/no-sqlite.test.js` guards it. `scripts/import-from-live.js` (Live snapshot → Chat) is ported too: a
- savepoint per row, `setval` instead of `sqlite_sequence`, and a JSON copy of the tables it writes instead of
- `VACUUM INTO`.
-
-**Migration changes** (`0001_initial.sql` is not on main yet, so it was still editable): identities
-`GENERATED BY DEFAULT` (the bridge, the importers and tests keep ids); `moderation_actions.scope_id` and
-`chat_messages.deleted_by` are `text` — SQLite's INTEGER columns held ids *and* names (a room slug, a purge's display
-name), and an id reads back as a number wherever a row leaves Chat (`moderationRow`/`chatMessageRow`).
-
-**Tests** — `test/helpers/pg-preload.mjs` (`node --import`, from `test/run.js`) gives every test process one migrated
-database: an in-memory PGlite, or with `OV_TEST_STORE=pg` a schema and roles of its own through PgBouncer. A Chat or a
-script the test spawns reaches the same database (`h.childDbEnv()`: the run's URLs, or this process's PGlite served
-over the wire protocol by `@electric-sql/pglite-socket`, new dev dependency, with a one-connection pool). Tests that
-opened SQLite files read through that handle; `ready.test.js` changes the schema as the owner. Docs: README storage
-section, STATUS.json runtime, `docs/cutover.md` (PostgreSQL switch runbook refreshed, rollback flush command).
-
-## How I tested it
-
-PostgreSQL 16 (port 55432, superuser `ov`, database `ovtest`), PgBouncer 1.22 in transaction mode with `auth_query`
-(56432) and Valkey 7.2 (56379), installed with apt in the session:
-
- export OV_TEST_PG_URL=postgres://ov:…@127.0.0.1:56432/ovtest
- export OV_TEST_PG_DIRECT_URL=postgres://ov:…@127.0.0.1:55432/ovtest
- export OV_TEST_VALKEY_URL=redis://127.0.0.1:56379/0
-
- npm test
- 45/47 test files passed, 2 skipped (message-deleted.test.js: message-deleted end-to-end: skipped (no OpenVibe.Events with redaction at /home/user/OpenVibe.Events) | n-1.test.js: n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys))
-
- npm run test:pg
- 45/47 test files passed, 2 skipped (message-deleted.test.js: message-deleted end-to-end: skipped (no OpenVibe.Events with redaction at /home/user/OpenVibe.Events) | n-1.test.js: n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys))
-
- N1_LIVE_REPO=<Live clone> npm run n-1:record # then node test/n-1.test.js on that fixture → "n-1: all checks passed" (fixture not committed)
-
-Every test file was also run alone with every failing statement logged: the only SQL errors are the expected ones
-(the import dry run's foreign-key holds, `ready.test.js`'s renamed table). The message-deleted end-to-end half skips
-because there is no OpenVibe.Events checkout here (it skipped before this change too).
-
-**History page p95, 200 000 rows** (the global page of `history-store.js`, `LIMIT 60`, 200 repetitions after 10
-warm-up reads, `ANALYZE` as the owner; one chat message in 50 deleted):
-
-| store | query | p50 | p95 | plan |
-| --- | --- | --- | --- | --- |
-| BEFORE: SQLite (main, better-sqlite3, in-process) | `ORDER BY timestamp DESC, id DESC` | 0.91 ms | **1.19 ms** | `SCAN cm USING INDEX idx_chat_ts_deleted` + temp B-tree for the ORDER BY |
-| PostgreSQL 16 via PgBouncer | old order | 2.38 ms | 3.39 ms | Limit → Incremental Sort → Index Scan Backward `idx_chat_timestamp` |
-| AFTER: PostgreSQL 16 via PgBouncer | `ORDER BY id DESC` | 1.99 ms | **2.74 ms** | Limit → Index Scan `idx_chat_page_live`, no Sort |
-| PGlite (dev, `npm test`) | old order | 5.62 ms | 8.08 ms | Incremental Sort |
-| AFTER: PGlite | `ORDER BY id DESC` | 4.88 ms | **7.11 ms** | Index Scan `idx_chat_page_live`, no Sort |
-
-The move adds a network round trip (≈1.5 ms here, loopback) to an in-process read; the new order removes the Sort.
-Readers see it at most once per 2 s per room (the page memo in front of it).
-
-## For Opus
-
-- **Push/PR access**: this session could not push (`OpenVibers/OpenVibe.Chat` is not in its authorized repository
- set; the git proxy refused a credential) and `codeload.github.com` is blocked by egress policy (npm installed the
- three GitHub-tag dependencies from git clones at the same tags; `package.json`/lock keep the codeload URLs). The
- branch is delivered as `t3-chat-pg.bundle` (see the session's message).
-- **The cutover** (not attempted): `docs/cutover.md` § PostgreSQL switch — drain the mirror, stop, keep
- `chat.db.pre-pg`, `node scripts/import-sqlite-to-pg.js --sqlite /var/lib/openvibe-chat/chat.db --url
- "$DATABASE_DIRECT_URL"` (migrates, imports, verifies per table, `ANALYZE`, writes `import_runs`), check counts,
- set `DATABASE_URL`/`DATABASE_DIRECT_URL`/`VALKEY_URL`/`VALKEY_PREFIX` in `/etc/openvibe/chat.env` (roles from Host's
- `roles/data add-service.sh chat`; the runtime role needs `USAGE, SELECT, UPDATE` on sequences), start,
- `/ready`, then `npm run n-1:record` from the deployed commit and commit the fixture. Rollback: previous release on
- `chat.db.pre-pg` (loses Chat-only writes made after the switch: rooms, calls, audio queue).
-- **Decision 1, the bridge half**: `SET LOCAL ov.mirror_skip` is on the importers only. The bridge no longer has an
- apply path for Live's own rows (`stagedApply` went with f7bf6a6); its `db` ops are Chat writes made for Live's
- current release, which the old per-connection TEMP triggers also captured, so they are still mirrored.
-- **Decision 8 and Live's mirror**: mirror changes for `relay_users` now carry `pk: { id }`; upserts still carry
- `platform`/`username` in the row, and Chat never deletes `relay_users` rows, but Live's applier should key that
- table by `(platform, username)` (its copy has no `id`).
-- **Two column types changed from the brief's migration** (`moderation_actions.scope_id`, `chat_messages.deleted_by`
- → text), because production rows hold names in them; identities are `BY DEFAULT`. Both need your nod since the
- migration becomes immutable once merged.
-- **Erasure**: account deletion now erases the person's own chat-AI summaries/timeline (`scope='user'`, by Live id).
- On SQLite that statement compared a bigint to `usr_…` subjects and matched nothing.
-- `/ready` and behaviour notes: a restart no longer resets per-actor limits when `VALKEY_URL` is set (a1); an
- unhandled rejection in a background loop is now logged instead of ending the process.
deleted file mode 100644
deleted file mode 100644
@@ -1,342 +0,0 @@
@@ -1,342 +0,0 @@
-# OpenVibe.Chat → PostgreSQL + Valkey, and taking over Live's six staged tables (plan track T3)
-
-Read-only survey. Every claim is `file:line`. Nothing in the repository was edited.
-
-The brief under review is `brief-chat-pg.out.a3.report.md` (249 lines, written by a weaker model). Everything below was re-derived from the code. Where the draft was right, it is kept; where it drifted, the corrected line number is given and the reason is in the Corrections table.
-
-## Corrections (what the previous draft got wrong)
-
-| # | Claim in draft | Verified truth |
-|---|---|---|
-| **The blocking one** | "The data layer to use: `openvibe-sdk/db` — `src/db/index.js`… `src/db/sqlite-import.js`… `tools/asyncify`…" | **Chat pins `openvibe-sdk` v0.12.0 (`package.json:32`), which has no `db` module, no `src/db/`, no `tools/`, no `src/testing/`.** `openvibe-sdk/db` first shipped in **v0.14.0** (SDK commit `1a93eeb`); ambient transactions (the draft's whole "no handle threading" argument) arrived in **v0.18.0** (SDK commit `4762731`). SDK HEAD is **v0.22.0** (`/home/workstation/OpenVibers/OpenVibe.SDK/package.json:3`), with `src/db/{index,migrate,prepare,sqlite-import,sql,sqlite-cli}.js`. **Raising the SDK pin is an unstated prerequisite.** |
-| | (unstated) | **`pg`, `@electric-sql/pglite`, `iovalkey` are not dependencies of Chat** — not in `package.json:23-35`, not installed in `node_modules`. Media has them. The plan needs them added. |
-| H3/H9 | `Media/server/db/database.js:22-49` (openDb) and `:22-63` (shape) | `openDb` **:28-46** (`:22-27` is its comment), `initDb` :49-53, `getDb` :56-61, `setDb` :64. |
-| H3 | `Media/migrations/0001_initial.sql:1-33` and `:8-33` prelude; `datetime` `:16-22` | Prelude **:6-30**; `datetime` **:17-24**; `:1-5` are comments; `:32` starts `CREATE TABLE apps`. |
-| H3 | `Media/test/helpers/pg-preload.mjs:1-25` | File is **20 lines**; one db :12, sequences→100000 :15-17, `globalThis.__ovMediaTestDb` :19. Env var is **`MEDIA_TEST_STORE`**, not `OV_TEST_STORE`. |
-| H3/§6 | Media `test/helpers/db.js:18-40` (`createTestDb`, `createTestValkey`) | **No such file** — `OpenVibe.Media/test/helpers/` holds only `pg-preload.mjs` and `run-script.js`. `createTestDb`/`createTestValkey` live in **`openvibe-sdk/testing`** (`src/testing/db.js:26`, `:85`, exports `:91`), which the Media preload already requires at `:11`. |
-| H3 | SDK `src/db/index.js:6-12`, `:20-24`, `:24-25` | Dialect/PgBouncer doc **:5-8**; ambient **:16-20** (right); `db.prepare` **:22-23**; pooler rule **:25-26**; `tx` retry doc :13, `RETRYABLE` :33, `ISOLATION` :32; `afterCommit` impl :204, handle :233. |
-| H3 | `src/db/sqlite-import.js:1-16` | Verification + sequences at **:17-19** (range `:1-19`). |
-| H3 | `src/db/sql.js:3-16` | Doc **:3-12**, class :15. |
-| H3 | `Media/package.json:10` test:pg | **`:12`** (`:10` is `test`). |
-| H3 | `tools/asyncify/README.md:1-50` covers `sqlite-schema-to-pg.js` | That tool is documented at **README `:56-58`**; sqlfix/asyncify order `:10-29`; dialect list `:34-35`; hand-work list `:38`; Blog order `:42-52`. |
-| §1.1 | `config.js:39` dbPath | **`server/config.js:42`**. |
-| §1.1 | `ADDED_COLUMNS` `:113-119` | **`:116-121`**. |
-| §1.1 | `rooms.js:84-129` second owner | `let ready` :82, `ensureSchema` **:83**, `exec()` **:86-132**; rooms :87-101, room_members :102-109, room_messages :111-120, room_attachments :122-131; the `PRAGMA table_info`/ADD COLUMN tail :134-137. |
-| §1.2 | `CHAT_TABLES` :28-41, `STAGED_TABLES` :47-54, `STAGED_KEYS` :57-64 | **`:25-38`, `:44-51`, `:53-60`**. |
-| §1.2 | `token_revocations` "not in schema.sql" | Right that it is absent from `schema.sql`, but it **is created** on demand at **`server/auth/revocations.js:19`**. |
-| §1.1/1.2 | "rooms.js is **a** second schema owner" + implicit `token_revocations` | **Four** modules create tables on first use: `rooms.js:86-132`, `revocations.js:19`, **`server/chat/network-blocks.js:28`** (`network_blocks` + `idx_network_blocks_blocked` :36), **`server/chat/account-data.js:39`** (`account_data_events`, DDL :38-46). **`network_blocks` and `account_data_events` are wholly absent from the draft's table inventory.** |
-| §1.3 | `server/chat/rooms.js` | **Does not exist** — it is `server/rooms/rooms.js`. |
-| §1.3 | `account-data.js:97` table_authority | **`:96`** (`chatWrites`). `:84` (`ORDER BY rowid DESC`) is right. |
-| §1.3 | `database.js:17` "the one handle" | :17 is `require('better-sqlite3')`; the **open is `:66`** (`new Database(_dbPath)`), inside `getDb()` :62-77. |
-| §1.2 | schema.sql table ranges | Several drifted: `chat_timeline_events` **296-305**; `table_authority` **308-312**; `ctx_sync` **368-372**; `events_outbox` **375-385**; `live_mirror_outbox` **389-395**; `bridge_applied` **399-403**; `bridge_refs` **436-442**; `chat_meta` **464-467**; `deploy_releases` **474-483**; `chat_event_inbox` **488-493**. (`chat_messages` 24-52, the `dm_*`/staged ranges, `calls` 501-518 and `idx_calls_open` :518 are right.) |
-| §1.3 | `test/n-1/preload.js:16` | The require is **`:13`**. |
-| §1.2 final note | "all 60+ tables in `server/db/schema.sql`" | **`schema.sql` is 518 lines with 35 `CREATE TABLE` and 39 `CREATE … INDEX` statements (37 plain + 2 unique).** 12 chat + 6 staged + 17 bookkeeping = 35 tables. With `rooms*` (4), `token_revocations`, `network_blocks`, `account_data_events`, Chat owns ~42 tables. |
-| §2 table | rooms 44, dm 34 | **rooms 43** (16 `run`, 15 `get`, 8 `all`, 4 `db.transaction`), **dm 33** (11/12/8/1). The other 13 rows' counts check out. |
-| §2 | Missing modules | **`server/chat/account-data.js` (23 `d.prepare` + 1 `d.exec` + 1 `d.transaction`), `server/chat/subject-merge.js` (15 `d.prepare` calls, 12 lines, + 1 `d.transaction`), and `server/prefs/from-live.js` (raw `liveDb.prepare` over `sqlite_master`) are not in the table.** Add them; the real conversion surface is **219 `prepare`/`exec`/helper sites across 20 files**, not ~170. |
-| §2 | "`db.transaction(() => …)` callbacks at `database.js:213,916,932,1147,1154,1180`" | database.js uses a **local `transaction(fn)` helper (`:101-102`) at :210, 329, 348, 364, 380, 419, 440, 687, 910, 920, 932, 1044, 1059, 1076, 1084, 1094, 1103, 1111, 1126, 1153, 1177** — 21 callbacks. Also `deploy-notice:80`→**:86**; **`lifecycle.js:75` is not a transaction — lifecycle.js has no transaction at all.** (dm 168, rooms 202/320/357/407, audio-queue 187, live-context 218, live-bridge 217, outbox 68/101 are right.) |
-| §2 | "15 are inside `transaction()`" | Not reproducible. **34 `transaction(() =>` callbacks** in `server/` (21 database.js + 4 rooms + 2 outbox + 1 each in dm, live-context, live-bridge, audio-queue, deploy-notice, account-data, subject-merge); 11 of them are `db.transaction(`. |
-| §2 | live-context `COLLATE NOCASE` `:331-332` | **Only `:235-236`**; there is no COLLATE at 331-332. |
-| §2 | live-context upsertRows :210-224; syncTable :736-748; syncActiveStreams :750-758; schedule :760-770 | **:212-225; :737-748; :749-758; :760-773.** |
-| §2 | audio-queue pump :207-240; recover :311-316 | pump **:209-241**; recover **:311-319**; `perform` awaited :225, defined near :222. |
-| §2 | deploy-notice claimRelease :78-90; `ORDER BY id DESC` :66 | tx at **:86** (function :84); the newest-row read is **:62**. |
-| §2 | live-bridge `plain()` :198-201; array-map :186; setTableAuthority op :263-264 | plain **:199-202**; array-map **:190**; **`:263-264` is op `tableAuthority`, `setTableAuthority` is `:265-266`** (op list documented :32-36). |
-| §2/§4 | database.js PRAGMA :129; `INSERT OR IGNORE` :1157; `INSERT OR REPLACE` :1192; mirrorPending :872-875; removeChannelModerator :919-928; setEmoteMedia end :1100; grantUserTag :1102-1108; addChatTimelineEvents end :1171; sliceHash end :1217; installMirrorTriggers :143-160; subjectFor :167-171 | **:128**; **:1159**; **:1188**; **:872-873**; **:918-927**; **:1092-1098**; **:1101-1107**; **:1150-1167**; **:1209-1212**; **:143-161**; **:167-170**. (database.js `:534`, `:1226`, `:878`, `:911`, `:896-897` and all of §4's chat-server refs are right.) |
-| §2 | history-store 4 sites | `db.get` **:80**, `db.all` **:102, :110, :165** — right, but note `:80` is inside `tipId()`'s try/catch. |
-| §3 | history-store ranges | parseRoom **:62-67**; `GLOBAL_TYPES` :26; GLOBAL_SELECT **:27-41**; CHANNEL_SELECT **:43-55**; page **:117-130**; delta **:137-146**; deletedIds **:156-170**; clampLimit **:57-60**; readDelta **:105-111**; readPage **:97-103**; tipId **:79-81**; visibility predicate **:40-41, :54-55, :168**; `PAGE_MEMO_MS` :23, `MAX_LIMIT` :24, memo **:85-94**, key :122; invalidate **:173-176**; `DELETED_WINDOW = 2*MAX_LIMIT` :155. Callers routes.js:516-517, 640, 660-661 and pages.js:146 (`limit: 60`) are **right**. |
-| §5 | table_authority :308-315 | **:308-312**. buildChanges **:37-54**. applyStagedChanges end **:1203**, invalidation tail **:1196-1201**. `live-context` `reloadPolicy` is **:402** (called :891) — :354/:396/:889/:899 are `_chatWrites` guards, not `reloadPolicy`. `import-from-live.js --tables` is parsed at **:52-53** (not :19-31). |
-| §5 | Live-side keys/endpoint | `chat_table_authority` **chat-tables.js:57**, `chat_table_dual_read` **:58**; **`chat_table_relay_paused` is `chat-tables-sync.js:36`** (not chat-tables.js). Handoff endpoint is Live **`server/internal/routes.js:29/43`** (`POST /internal/chat-tables/:table`). `write()` is **:137**. `chat_staged_outbox` DDL **chat-tables-sync.js:46-52**; `chat_dual_read_stats` DDL **:53-63**. (OPS map chat-tables.js:44-55, `authority()` :81 are right.) |
-| §5/§7 | `docs/staged-tables-cutover.md` calendar `:78-95` | Table is **:76-87**; D0 2026-09-28, the flip order/dates (timeline-events 10-05, ai-summaries 10-06, user-tags 10-07, emotes 10-08, moderation-settings 10-09, moderators 10-12) and "not before 2026-12-18" all **right**; rollback **:273-284**. |
-| §6 | `test/run.js:16` | **`:15`** (`{ dir: __dirname, timeoutMs: 60000, pad: 32, parallel: 1 }`); `--strict` documented :9. 40 test files — right. |
-| §6 | staged-tables cases | File is 239 lines — right, but the cases sit at restart **:47-55**, refuse-at-live **:57-68**, idempotent **:70-97**, dual-read slice :99. |
-| §6 | `message-deleted.test.js:23,141` is a better-sqlite3 check | **`:23`** only (`['server/index.js','server/redaction.js','node_modules/better-sqlite3'].every(fs.existsSync)` on the Events checkout). `:141` is a purge-preview assertion, not a driver check. |
-| §6 | `app.js` table_authority | **:149** (`stagedAuthorities()`); `/ready` handler **:118-154**, `SELECT MAX(id)` :121 — right. |
-| §6 | `helpers.js:338` "the live module" | **:338 is `h.db`** (`require('../server/db/database')`); the live module is **:339** (`h.ctx`). `:288` `CHAT_DB_PATH` and `:36` `sqliteNow()` are right. |
-| §6 | "128 `h.db.*` across 26 files" | **137 `h.db.*` across 21 test files.** |
-| §6 | `actor-limits.js:26-27` comment | **:21-22**. |
-| §6 | history-store memo `:80-91` | **:85-94**; `PAGE_MEMO_MS` :23. |
-| §6/§7 | "containers with `OV_TEST_STORE=pg`" | Media's preload reads **`MEDIA_TEST_STORE`** (`pg-preload.mjs:2,12`); the SDK helper defaults to `OV_TEST_STORE` (`openvibe-sdk/src/testing/db.js:26`) and needs `OV_TEST_PG_URL`/`OV_TEST_PG_DIRECT_URL` (`:23,33`). |
-| §7 | `import-from-live.js:14-16` pre-write copy | **`:10-12`** (the doc comment). `server/index.js` `db.initDb()` is **:30**. `rooms.js` DDL :87-129 → **:86-132**. `docs/cutover.md` §Rollback **:174**. |
-| §8 | risk 2 "`deploy-notice.js:76` `res.lastID \|\| res.id`" | The line is `Number(res && (res.lastInsertRowid \|\| res.lastID \|\| res.id))` — it checks **`lastInsertRowid` first**. `live-bridge.js` plain is **:199-202**, `database.js` `_staged` sniffer :896-897. |
-| §8 | risk 5 "read-modify-write" for `attempts` | The attempts bump is **already atomic** — `UPDATE audio_requests SET attempts = attempts + 1 WHERE id = ?` at **audio-queue.js:221**. The race is only over the `state='queued'` claim. Risk 11: CI records the previous release into **`test/fixtures/n-1`** (`.github/workflows/ci.yml:7-8`), so the harness is fixture-based as well as `sqlite_master`-derived. |
-| §9 | "`package.json:26` loses better-sqlite3" | **`:24`**. |
-| §6 env note | "`npm test` fails 36/40 at `new Database`" | **Confirmed live:** `process.versions.modules` = **137** on node v24.19.0, against a `better_sqlite3.node` built for 127. Not a code defect; a `npm rebuild better-sqlite3` fixes it and installs are denied in this job. |
-| (missed) | The draft's §1.3 never lists `server/chat/sounds-routes.js:285` | It reads `result.lastInsertRowid` from `createChannelSound` and puts it straight into the HTTP response. |
-| (missed) | `scripts/parity.js` treated as SQLite-touching | `scripts/parity.js` (731 lines) is the **product-level** parity driver (real protocol, WebSocket) and touches **no** SQLite; only `scripts/parity-check.js:35-40` opens files. The draft's §1.3 list is right, but the distinction matters when repointing the CLIs. |
-
-
-**Model service:** OpenVibe.Media — `server/db/database.js:28-46` (`openDb`: `DATABASE_URL` through PgBouncer, `DATABASE_DIRECT_URL` for migrations, embedded PGlite in dev), `migrations/0001_initial.sql:6-30` (SQLite-compat prelude `ov_ts`/`ov_now`/`ov_now_iso`/`datetime`/`julianday`/`json_valid`/`json_extract`/`json_type`/`instr`; `datetime` :17-24), `test/helpers/pg-preload.mjs:1-20` (one migrated PGlite per test process, identity sequences set to 100000), Valkey for shared counters: `server/config.js:32`, `server/actor-limits.js:58-62`, `server/observability.js:132`, and `openvibe-sdk/testing` `createTestDb`/`createTestValkey` (`src/testing/db.js:26,85`; the `store:'pg'` path at :33-80 with the runtime role's `statement_timeout = 15s` / `lock_timeout = 5s` at :49-50).
-
-**The data layer to use — and the prerequisite the draft omitted:** `openvibe-sdk/db` (≥ **v0.14.0** for `createDb`/`sql`/`tx`/`importSqlite`; ≥ **v0.18.0** for ambient transactions; HEAD v0.22.0) — `src/db/index.js:5-8` (PostgreSQL only; PgBouncer transaction mode; `DATABASE_URL`), `:13,32-33,147` `tx(fn, opts)` with `isolation` and 40001/40P01 retry, `:16-20` ambient transactions via AsyncLocalStorage, `:22-23` `db.prepare(...)` better-sqlite3-shaped but async, `:204,:233` `db.afterCommit`; `src/db/sqlite-import.js:1-19` (`importSqlite`: parents first, batched multi-row inserts, identity columns keep their values and their sequences move past the max, verification by row count + a canonicalised per-row checksum walked in PK order, lost columns refused unless dropped); `src/db/sql.js:3-12` (`sql` tagged template; only `sql.raw` puts text into the statement). Toolchain (SDK repo, **not** the npm package — `tools/asyncify/README.md:3` says so): `tools/asyncify/README.md:56-58` (`sqlite-schema-to-pg.js`: `INTEGER PRIMARY KEY AUTOINCREMENT` → identity, `TEXT` → `text COLLATE "C"`, `INTEGER` → `bigint`, `REAL` → `double precision`, `BLOB` → `bytea`, no `IF NOT EXISTS`, `lower(x)` for `COLLATE NOCASE` indexes, an immutable-row trigger becomes PL/pgSQL, any other trigger refused), `:34-35` the dialect list (`INSERT OR IGNORE/REPLACE` → `ON CONFLICT`, `COLLATE NOCASE` → `lower()`, `LIKE` → `ILIKE`, `IS ?` → `IS NOT DISTINCT FROM`, `PRAGMA`/`sqlite_master` → `information_schema`, `RETURNING` for `lastInsertRowid`), `:38` the by-hand list (`db.transaction(fn)()` → `db.tx(fn)`, scalar `MAX(a,b)` → `GREATEST`, `rowid` tiebreaks → an identity column, `setImmediate` → `db.afterCommit`), `:10-29` `asyncify.js` (adds `await`, makes functions async, iterates to a fixed point, and **reports** `MANUAL` for array callbacks instead of changing them). **Chat must bump `package.json:32` and add `pg`/`@electric-sql/pglite`/`iovalkey` before any of this can be required.**
-
-
-## 1. Every database, table and file
-
-### 1.1 The database
-
-One SQLite file. `server/config.js:42` `dbPath: process.env.CHAT_DB_PATH || './data/chat.db'`; production `deploy/systemd/openvibe-chat.service:38-39` (`StateDirectory=openvibe-chat`, `CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db`). Opened in `server/db/database.js:62-77` with `journal_mode=WAL` (:67), `foreign_keys=ON` (:68), `busy_timeout=5000` (:69), `synchronous=NORMAL`/`cache_size`/`temp_store` (:71-73), a prepared-statement cache (`stmt()` :79-88, cleared past 500 at :83), and helpers `run` :89, `get` :93, `all` :97, `transaction` :101-102, `close` :105-109.
-
-`initDb()` (`server/db/database.js:123-141`) executes `server/db/schema.sql` verbatim on every boot (:125; every statement is `CREATE … IF NOT EXISTS`), patches four late columns (`ADDED_COLUMNS` **:116-121**: `emotes.media_url`, `emotes.media_asset_id`, `ctx_users.subject_id`, `channel_moderation_settings.sub_only`) with a `PRAGMA table_info` probe at :128, creates `idx_ctx_users_subject` (:132), seeds `table_authority` for the 12 chat tables (:133-134) and the six staged tables as `live` (:135-137), resets the 1 s authority cache (:138) and optionally installs the mirror TEMP triggers (:139, gated by `captureMirror`).
-
-**There are four on-demand schema creators, not one.** Each does `db.getDb().exec()` of SQLite DDL on first use:
-
-- `server/rooms/rooms.js:83-138` — `ensureSchema` (`let ready` :82) creates `rooms` :87-101, `room_members` :102-109 (+ `idx_room_members_user` :108), `room_messages` :111-120 (+ `idx_room_messages_room` :119), `room_attachments` :122-131; the additive-column tail at :134-137.
-- `server/auth/revocations.js:17-…` — `token_revocations` (DDL :19).
-- `server/chat/network-blocks.js:26-38` — `network_blocks` (:28) + `idx_network_blocks_blocked` (:36).
-- `server/chat/account-data.js:38-46` — `account_data_events` (:39), whose `applied_at` default is `strftime('%Y-%m-%dT%H:%M:%fZ','now')` (:44).
-
-On PostgreSQL every one of these must become a migration; `exec()` of SQLite DDL cannot run. **Three of the four are invisible in the draft.**
-
-### 1.2 The tables (server/db/schema.sql)
-
-**`server/db/schema.sql` is 518 lines: 35 `CREATE TABLE`, 39 `CREATE … INDEX` (37 plain, 2 unique).** Chat-owned (authority from the W6 cutover, ids kept from Live; `CHAT_TABLES` map at `server/db/database.js:25-38`):
-
-| Table | schema.sql | key |
-| --- | --- | --- |
-| `chat_messages` | `:24-52` (8 indexes) | `id` |
-| `dm_conversations` | `:54-61` | `id` |
-| `dm_participants` | `:63-74` | `id` |
-| `dm_messages` | `:76-86` | `id` |
-| `dm_blocks` | `:88-97` | `id` |
-| `tts_voice_overrides` | `:99-107` | `identity_key` |
-| `channel_sounds` | `:109-126` | `id` |
-| `relay_users` | `:128-136` | `(platform, username)` |
-| `hidden_relay_users` | `:138-148` | `id` |
-| `pending_ip_messages` | `:150-163` | `id` |
-| `stream_first_chats` | `:165-171` | `(chatter_key, channel_user_id)` |
-| `moderation_actions` | `:173-186` | `id` |
-
-Staged copies of tables Live still writes (C-04; `STAGED_TABLES` `server/db/database.js:44-51`, `STAGED_KEYS` `:53-60`):
-
-| Table | schema.sql | key |
-| --- | --- | --- |
-| `channel_moderators` | `:190-199` | `id` |
-| `channel_moderation_settings` | `:201-246` | `channel_id` |
-| `emotes` | `:248-266` (incl. `idx_emotes_channel_code`, unique on `COALESCE(channel_owner_id, user_id), code`, :266) | `id` |
-| `user_tags` | `:268-276` | `id` |
-| `chat_ai_summaries` | `:278-294` | `id` |
-| `chat_timeline_events` | `:296-305` (unique `idx_chat_tl_dedup` on `(scope, subject_id, ts, label)`) | `id` |
-
-Bookkeeping: `table_authority` `:308-312`, projections `ctx_users` `:317-332`, `ctx_streams` `:334-348`, `ctx_managed_streams` `:350-359`, `ctx_channels` `:361-366`, `ctx_sync` `:368-372`, `events_outbox` `:375-385`, `live_mirror_outbox` `:389-395`, `bridge_applied` `:399-403`, `audio_requests` `:409-434`, `bridge_refs` `:436-442`, `import_hold` `:445-453`, `import_runs` `:455-462`, `chat_meta` `:464-467`, `deploy_releases` `:474-483`, `chat_event_inbox` `:488-493`, `calls` `:501-518` (`idx_calls_open`, a partial index `WHERE state IN ('pending','ringing','active')`, :518).
-
-**Not in schema.sql — 7 more tables Chat owns:** `rooms`, `room_members`, `room_messages`, `room_attachments` (`rooms.js:87-131`), `token_revocations` (`revocations.js:19`), `network_blocks` (`network-blocks.js:28`), `account_data_events` (`account-data.js:39`). **42 tables in all.**
-
-### 1.3 Files that open SQLite directly (they break first)
-
-- `server/db/database.js:17` (the require) / `:66` (the one service handle).
-- `server/chat/account-data.js` — `cols()` does `d.prepare('PRAGMA table_info(...)')` at :34; `d.exec` DDL at :39; **23 raw `d.prepare(...)` sites**, among them `ORDER BY rowid DESC` at **:84** and a `table_authority` read at **:96**; one `d.transaction` at :109.
-- `server/chat/subject-merge.js:28-70` — `d = db.getDb()` :29, **15 raw `d.prepare(...)` calls** (12 lines, incl. `PRAGMA table_info` :18, `UPDATE ${t} SET ${col}` :35, dynamic table names :47-49, `network_blocks` :68-70), one `d.transaction` :34. **Missing from the draft.**
-- `server/prefs/from-live.js:47-54` — `liveDb.prepare` over `sqlite_master` and `PRAGMA table_info(linked_accounts)` (reads Live's file; a one-off migration tool, so it stays SQLite on purpose).
-- `server/chat/audio-queue.js` uses only `db.run/get/transaction` (`:110, :120, :187, :197` via `require('../db/database')` :34) — it never calls `getDb()`.
-- `server/live-context.js:419` uses the `sqliteNow()` helper (SQLite `YYYY-MM-DD HH:MM:SS` text), compared against stored text at :428.
-- Everything that `require('better-sqlite3')` literally: `scripts/import-from-live.js:39`, `scripts/parity-check.js:37`, `scripts/migrate-chat-preferences.js:73`, `scripts/n-1-record.js:19`; `test/audio-queue.test.js:14,236`, `test/import.test.js:17,37,85+`, `test/migrate-preferences.test.js:18,45,75,110`, `test/n-1.test.js:29`, `test/n-1/preload.js:13`, `test/restart-resume.test.js:16,31`, `test/message-deleted.test.js:23` (the Events checkout's copy, not Chat's).
-- `scripts/import-from-live.js` additionally reads `rowid` (:244-245) and `sqlite_sequence` (:279-281) and opens Live's file read-only at :105; `scripts/parity-check.js:35-40` opens both files. `scripts/parity.js` (731 lines) is the product-level driver and opens **no** SQLite.
-
-
-## 2. Sync call sites by module (the asyncify surface)
-
-Counted as `db.run(` / `db.get(` / `db.all(` / `db.transaction(` / `getDb().prepare(` / `d.prepare(` / `d.exec(`:
-
-| Module | sites | notes |
-| --- | --- | --- |
-| `server/rooms/rooms.js` | 47 | own `exec()` DDL :86; `db.transaction` :202,320,357,407; `lastInsertRowid` :205,206,323,324,325; scalar `MAX(last_read_id, ?)` **:348** → `GREATEST` |
-| `server/chat/dm.js` | 33 | `db.transaction` :168; `lastInsertRowid` :55,183,185,192; `CURRENT_TIMESTAMP` :116,174 |
-| `server/chat/account-data.js` | 25 | 23 `d.prepare` + `d.exec` :39 + `d.transaction` :109 — **missing from the draft** |
-| `server/live-context.js` | 22 | `upsertRows` :212-225 (prepared stmt :216, `db.transaction` :218); `syncTable` :737-748; `syncActiveStreams` :749-758; `getChannelModerationSettings`/`isChannelModerator` :365-378; `COLLATE NOCASE` :235-236; `SCHEDULE` :760-773 |
-| `server/chat/audio-queue.js` | 21 | enqueue transaction :187-203, `pump` :209-241, `recover` :311-319 |
-| `server/chat/subject-merge.js` | 13 | 12 lines of `d.prepare` (15 calls), `PRAGMA table_info` :18, one `d.transaction` :34 — **missing from the draft** |
-| `server/calls/lifecycle.js` | 11 | state transitions :75,83-86,134-188 (`:56` is a read helper; **no transactions**) |
-| `server/bridge/live-bridge.js` | 7 | idempotency transaction :217-223, `sweepRefs` :157-158, `rememberRef` :164, `lookupRef` :172 |
-| `server/chat/deploy-notice.js` | 6 | `claimRelease` transaction :86; `lastInsertRowid` read at :76 (`res.lastInsertRowid \|\| res.lastID \|\| res.id`) |
-| `server/events/outbox.js` | 6 | `markFailed` :66-69, `flush` :72-108 (`getDb().prepare`+`transaction` at :67/:68 and :100/:101) |
-| `server/chat/network-blocks.js` | 5 | DDL :28, run :51, get :61, all :78 and :86 |
-| `server/chat/history-store.js` | 4 | `db.get` :80, `db.all` :102, :110, :165 |
-| `server/bridge/live-mirror.js` | 4 | `pending` :35, `buildChanges` :37-54, `flush` :56-100 |
-| `server/events/consumer.js` | 3 | :102, :206 |
-| `server/auth/revocations.js` | 3 | DDL :19, read :33, write :45 |
-| `server/chat/routes.js` | 2 | :253, :613 |
-| `server/auth/network-session.js` | 1 | :57 |
-| `server/chat/chat-server.js` | 1 | `db.get` :778 (the rest are `db.*` domain functions, all of which become async) |
-| `server/db/database.js` | 9 | its own `run/get/all/stmt/transaction` surface (:79-102) and 9 direct `prepare`/`exec` sites |
-
-**Total ≈ 219 call sites across 20 files** (the draft said ~170 across 15 files). Of those, **41 lines read `lastInsertRowid` or `.changes`** — 27 `lastInsertRowid` lines and 17 `.changes` lines across 14 files (`rooms.js`, `live-bridge.js`, `audio-queue.js`, `chat-server.js`, `routes.js`, `deploy-notice.js`, `dm.js`, `sounds-routes.js`, `database.js`, `lifecycle.js`, `account-data.js`, `subject-merge.js`, `network-blocks.js`, `consumer.js`). The draft's "15 are inside `transaction()`" is wrong: there are **34 `transaction(() =>` callbacks** in `server/` (21 in database.js, 4 in rooms, 2 in outbox, 1 each in dm, live-context, live-bridge, audio-queue, deploy-notice, account-data, subject-merge), of which 11 are `db.transaction(`. Also note `server/chat/sounds-routes.js:285` — the only DB-adjacent read there — hands `result.lastInsertRowid` straight into the JSON response, so it is an id-shaped API contract, not just a local variable.
-
-`openvibe-sdk/db` gives `db.prepare(sql)` with the same `.get/.all/.run` shape (`src/db/index.js:22-23`), so the prepared-statement cache in `database.js:79-88` and every `db.get(...)` call site keeps its SQL verbatim; only `await`, `RETURNING` and the dialect rewrites change. The ambient transaction (`src/db/index.js:16-20`) means the `db.transaction(…)` callbacks (rooms.js:202,320,357,407; dm.js:168; audio-queue.js:187; live-context.js:218; live-bridge.js:217; outbox.js:68,101) and the local `transaction(…)` callbacks at `database.js:210,329,348,364,380,419,440,687,910,920,932,1044,1059,1076,1084,1094,1103,1111,1126,1153,1177` (helper :101-102), plus `account-data.js:109` and `subject-merge.js:34`, all become `db.tx(async () => …)` and the inner calls need no handle.
-
-
-## 3. The history store and its cursors
-
-`server/chat/history-store.js` (178 lines):
-
-- Rooms: `'global'`, `'channel:<userId>'` (`parseRoom` :62-67; `'stream:<id>'` only in `deletedIds` :156-170, regex :159). `MAX_LIMIT=500` :24, `PAGE_MEMO_MS=2000` :23, `DELETED_WINDOW = 2*MAX_LIMIT` :155.
-- Two joined reads: `GLOBAL_SELECT` **:27-41** (five `ctx_*` LEFT JOINs, `message_type IN ${GLOBAL_TYPES}` :40 with the list at :26), `CHANNEL_SELECT` **:43-55**.
-- Cursors: `page(room, {limit, before, channelUsername, decorate})` **:117-130** returns `{messages, latest_id}`; `delta(room, {afterId, limit, …})` **:137-146** returns `{messages, latest_id, complete, deleted_ids}`; `deletedIds(room, afterId)` **:156-170** returns ids at or under the cursor that are deleted or past auto-delete within `DELETED_WINDOW`. `clampLimit` **:57-60**, delta default 200 (:139).
-- Reads use **the integer primary key as the cursor**: `readDelta` **:105-111** `WHERE cm.id > ? ORDER BY cm.id ASC LIMIT ?` with `limit+1`; `readPage` **:97-103** `ORDER BY cm.timestamp DESC, cm.id DESC` reversed. The page is a **timestamp cursor**, the delta a **PK range scan**. `tipId()` **:79-81** `SELECT MAX(id) FROM chat_messages` doubles as the memo key (in a try/catch, falling back to `Date.now()`).
-- A 2 s page memo **:85-94** keyed on `room|limit|channel|tipId` (key built :122); `invalidate(room)` **:173-176**.
-- Visibility predicate repeated in three places: `is_deleted = 0 AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)` (**:40-41**, **:54-55**, and negated with `COALESCE` at **:168**). `datetime()` is a SQLite function; Media's `migrations/0001_initial.sql:17-24` provides a PostgreSQL `datetime(text, modifier)` with the same semantics, so the predicate can stay verbatim.
-- Callers: `server/chat/routes.js:516-517,640,660-661` (REST `global/history`, `:streamId/replay`, `channel/:userId/history`) and `server/web/pages.js:146` (the openvibe.chat first paint, `limit: 60`). The **WebSocket does not read history-store per message**; it reads the joined page/delta on join/reconnect only.
-
-**Ordering contract to preserve:** `chat_messages.id` must stay **monotonic in insert order** (gap-free is not required, but the client splices by `id`). A PostgreSQL identity satisfies that; the danger is `ORDER BY timestamp, id` in `readPage` and in `deploy-notice.js:62` if timestamps tie. Second danger: two writers (Chat and, for the staged tables, Live via the bridge) can interleave ids — a single global sequence per table is required, not per writer.
-
-
-## 4. WebSocket paths that touch the database per message (latency)
-
-`server/chat/chat-server.js`, the `chat.message.send` path (all line refs verified correct):
-
-1. `:772-800` IP-approval check: `db.get('SELECT 1 FROM chat_messages … JOIN ctx_streams …')` `:778-785` and, if the user has never chatted there, `db.holdMessageForApproval` `:792-800` — **one read, and a write, on every message from an unapproved IP in an ip_approval_mode channel**.
-2. `:826-842` reply-to: `db.getChatMessageById(replyToId)` `:831`.
-3. `:899` VIP badge: `db.subjectFor(client.channelUserId)`.
-4. `:906-925` **the write**: `db.saveChatMessage` `:907` → `database.js:202-236` — one `transaction` (:210) doing the `INSERT` (:211-215), a `subjectFor` read at :209 (inside the call, before the tx) and `_outbox().enqueue` (`server/events/outbox.js:31-50`, a `contract validate()` at :46 plus an `events_outbox` INSERT at :48). The id comes back via `res.lastInsertRowid` (`database.js:220-222`).
-5. `:944-953` welcome: `db.isFirstChatInChannel` `:949` + `db.recordFirstChat` `:950` — **two more statements per stream message**.
-6. `:1531-1543` and `:2169-2181`: the same `saveChatMessage` for a channel sound and a soundboard clip, then `audioQueue.enqueue` (`audio-queue.js:183-204`, its own transaction with up to three reads and one insert).
-7. Background: `db.deleteExpiredChatMessages(500)` `:2581` on a timer (`database.js:400-433`).
-
-**Latency budget on PostgreSQL.** Today every one of these is an in-process synchronous SQLite call — sub-100 µs each, so a message costs one WAL commit. On PostgreSQL through PgBouncer each becomes a network round trip plus a commit: 5 statements × ~0.3–1 ms ≈ 2–5 ms per message, of which 2–3 are avoidable:
-
-- Steps 5 and the `subjectFor` read should move **outside** the write transaction or be cached (`ctx` already caches users, `live-context.js:195-201`), so the transaction holds only the `INSERT chat_messages` + `INSERT events_outbox` — one round trip, one commit.
-- The message path must never wait on `enqueue`'s contract validation twice or on a mirror flush; `live-mirror.js:56-100` already runs on its own timer.
-- The bridge path (`live-bridge.js:217-223`) adds one more round trip for Live's remaining writers, already async.
-
-Rules to hold: never issue a `SELECT` before the `INSERT` on the hot path unless it is served from `ctx`; keep the per-message transaction to two statements; keep the identity next to the client so the id is known before the broadcast, not after a round trip.
-
-
-## 5. The staged-table bridge and mirror code the cutover deletes
-
-**Chat side, live today:**
-
-- Authority store: `table_authority` (`schema.sql:308-312`), read cached 1 s in `tableAuthority()` `server/db/database.js:853-859` (cache object :852), written by `setTableAuthority` `:860-866`; `stagedAuthorities()` `:868-870`; `mirrorPending(table)` `:872-873`. Seeding and "a restart never moves it back" is `initDb` `:133-138`.
-- The gate on writes: `_assertChatWrites(table)` `:883-888` throws `table.not_chat` (:886) — called by every staged writer (`:909,919,931,1043,1058,1070,1083,1093,1102,1110,1120,1151`).
-- The answer shape Live consumes: `_staged(value, table, rows, deletedPks)` `:895-905` — `{ value, mirror: [{table, op, row|pk}] }`, with the `{changes, lastInsertRowid}` sniff at :896-897.
-- The staged writers: `addChannelModerator` `:908-916`, `removeChannelModerator` `:918-927`, `upsertChannelModerationSettings` `:930-1040`, `setChannelAlertSound` `:1042-1054`, `createEmote` `:1057-1067`, `updateEmote` `:1069-1080`, `deleteEmote` `:1082-1089`, `setEmoteMedia` `:1092-1098`, `grantUserTag` `:1101-1107`, `revokeUserTag` `:1109-1117`, `upsertChatAiSummary` `:1119-1148`, `addChatTimelineEvents` `:1150-1167`.
-- **Live → Chat capture path (deleted at cutover):** `applyStagedChanges(changes)` `:1175-1203` — the `INSERT OR REPLACE` applier (:1188), guarded by `tableAuthority(c.table) !== 'live'` (:1181), with `_columns()` filtering (:1182, `:876-880`) and the invalidation tail `:1196-1201`. Its bridge op is `stagedApply` (`server/bridge/live-bridge.js:259-260`); Live's side of it is `chat_staged_outbox` (`chat-tables-sync.js:46-52`, fed by TEMP triggers at :78-82).
-- **Dual read (deleted):** `sliceHash(columns, rows)` `:1209-1212` and `stagedSlice(table, where, columns)` `:1219-1228` (exposed as bridge op `stagedSlice` `live-bridge.js:261-262`); Live's counters live in `chat_dual_read_stats` (`chat-tables-sync.js:53-63`, :218-232).
-- **The handoff op:** bridge op `setTableAuthority` `live-bridge.js:265-266` (`:263-264` is op `tableAuthority`; the op list is documented :32-36) → `handOver()` `:128-148` (to `chat` requires the mirror; back to `live` drains `mirrorPending` first, with a 15 s budget). Kept as the rollback lever even after the capture is gone.
-- **Read mirror (kept until C-01…C-03, 2026-11-06):** TEMP triggers `installMirrorTriggers()` `:143-161` — one `AFTER INSERT/UPDATE/DELETE` per table, the staged ones gated by `WHEN (SELECT authority FROM main.table_authority WHERE table_name = '…') = 'chat'` (:149), writing `(tbl, op, pk JSON)` into `live_mirror_outbox` (:155-159); the relay `server/bridge/live-mirror.js:29-114` (`BATCH` 200 :26, `MAX_BODY` 800 KB :27, `pending` :35, `buildChanges` :37-54 collapses per key and re-reads the row as it is now :50, `flush` :56-100, POSTs `/internal/chat-effects/mirror` :79 with `live.chat_mirror.write` :28). **On PostgreSQL TEMP triggers do not exist** — this is the single biggest rewrite in the move: either a real trigger on a per-session flag (`current_setting('chat.mirror_on')`), or (simpler and preferred) have every write path append to `live_mirror_outbox` inside the same transaction it already has, and drop the trigger mechanism entirely. Note the mirror is also why `initDb` takes `captureMirror` and why the importer calls `initDb({ captureMirror: false })` (`scripts/import-from-live.js:104`, `scripts/mirror-flush.js:18`).
-- **The conditional reads in `live-context.js`** — `_chatWrites(table)` `:364`, `getChannelModerationSettings` `:365-371`, `isChannelModerator` `:372-378`, `reloadPolicy` `:402` (called from the settings effect :891), and the settings/alert-sound effects `:889-905` — become unconditional direct reads after the flip.
-- **CLI:** `scripts/table-authority.js` (status :19-24, `set` :26-31), `scripts/mirror-flush.js:18-23`, `scripts/parity-check.js:35-40`, the `--tables` mode of `scripts/import-from-live.js:52-53`.
-- **On the Live side the cutover deletes:** `server/chat/chat-tables.js` capture/relay/dual-read (the file keeps `authority()` :81 and `write()` :137), `server/chat/chat-tables-sync.js` in full, `chat_staged_outbox`, `chat_dual_read_stats`, and the `OPS` map (`chat-tables.js:44-55`) that routes Live's ten writers to Chat.
-
-**The procedure is already written and does not change:** `docs/staged-tables-cutover.md` — calendar at **lines 76-87** (D0 = 2026-09-28 deploy/import/parity/dual-read; flips 2026-10-05 `chat_timeline_events` → 10-06 `chat_ai_summaries` → 10-07 `user_tags` → 10-08 `emotes` → 10-09 `channel_moderation_settings` → 10-12 `channel_moderators`; the last Live writers removed "not before 2026-12-18"; C-01…C-03 target 2026-11-06), rollback rows at **:273-284**. Live's per-table handoff endpoint is `POST /internal/chat-tables/:table` (`server/internal/routes.js:29,43`) with `GET /internal/chat-tables` (:32) and the relay-pause endpoint :35-36; the app_state keys are `chat_table_authority` / `chat_table_dual_read` (`chat-tables.js:57-58`) and `chat_table_relay_paused` (`chat-tables-sync.js:36`). The remote write is `write()` (`chat-tables.js:137`) over Chat's bridge.
-
-**What changes if T3 (PostgreSQL) lands first:** the six tables must be in PostgreSQL *and* in the same database as everything else before the flips, because a table split across two engines cannot be flipped one at a time — `applyStagedChanges` writes to whichever engine holds the copy, and `handOver()` drains one mirror. The cheapest sequencing is: **move all of Chat to PostgreSQL while every staged table is still `live` (they are read-only on Chat's side), then run the six flips unchanged.** After the last flip, `applyStagedChanges`, `stagedSlice`/`sliceHash` and the `INSERT OR REPLACE` semantics are dead code and can be deleted in one commit together with Live's sync file.
-
-
-## 6. Tests
-
-Runner: `test/run.js:15` (`openvibe-shared/test-runner`, `{ dir: __dirname, timeoutMs: 60000, pad: 32, parallel: 1 }`; `--strict` documented :9 makes a skip fail). **40 files** (`test/*.test.js`). CI (`.github/workflows/ci.yml:7-8`) runs it with `test/n-1.test.js` replaying the previous release's clients and its SQL against this one, **from `test/fixtures/n-1`**.
-
-**Database-coupled (must be ported, not deleted):**
-
-- `test/staged-tables.test.js` (239 lines) — the whole C-04 contract in 13 cases: boot with the mirror on :36, authority defaults and restart :47-55, every staged write refuses at `live` :57-68, Live's changes arrive idempotently and never flow back :70-97, the dual-read slice :99, the handoff needs the mirror :109, at `chat` the writers and return values :119/:138, every change reaches Live through the mirror :170, `/slow` persisted in place :182, flip-back waits for the mirror :206, `scripts/table-authority.js` :220.
-- `test/outbox-mirror.test.js:7-11` — events outbox + the Live read mirror against a stub Events and a stub Live.
-- `test/audio-queue.test.js` — rows, transitions, restart recovery; opens the DB file directly at :14 and :236 (`raw()` helper), restart case :237.
-- `test/bridge.test.js` — the bridge ops incl. `stagedApply`/`stagedSlice`/`setTableAuthority` and the idempotency key.
-- `test/import.test.js:17,37,85+` (opens the Live snapshot, the Chat db and the backup :94-98), `test/migrate-preferences.test.js:18,45,75,110`, `test/restart-resume.test.js:16,31` — open SQLite files and assert on them.
-- `test/message-deleted.test.js:23` — checks a `better-sqlite3` **in the sibling OpenVibe.Events checkout**, not Chat's; the redaction end-to-end already runs against PostgreSQL Events (per the eab0c20 commit message) and skips cleanly when the checkout is absent (:77).
-- `test/account-data.test.js` — export/erase through `account-data.js:34,39,84,96` (`rowid`, dynamic table names, `PRAGMA table_info`).
-- `test/subject-merge.test.js` — the raw-handle merge in `subject-merge.js`, including its `PRAGMA table_info` guard at :18.
-- `test/rooms.test.js`, `test/room-kinds.test.js` — the `rooms` DDL and every `rooms*` statement.
-- `test/n-1.test.js` (142 lines) + `test/n-1/*` (**1047 lines**) — the N-1 harness replays the **previous release's SQL** (from `test/fixtures/n-1`) against the current schema, and derives the N-1 schema from `sqlite_master` / `PRAGMA table_xinfo` / `PRAGMA table_list` (`test/n-1/harness.js:731,732,773,774,799`, harness 855 lines; the SQLite-capable preload is `test/n-1/preload.js:13`). It has to become a PG harness: record the previous release's SQL calls as fixtures and replay them against the migrated PostgreSQL schema. Media kept `scripts/n-1-record.js` on better-sqlite3 only for that (`OpenVibe.Media/scripts/n-1-record.js:19`); its N-1 story is separate.
-- `test/ready.test.js` — `/ready` `db` check (`server/app.js:121` `SELECT MAX(id) FROM chat_messages`) plus `table_authority` (`app.js:149`).
-- `test/live-context.test.js` — the projection and the staged-read switch.
-- `test/parity.test.js` + `scripts/parity.js` (731 lines) — end-to-end product parity over the real protocol; **no SQLite**, so it survives the move as-is, while `scripts/parity-check.js` (the row comparer) must be repointed at PG.
-- `test/calls.test.js`, `test/dm-inbox.test.js`, `test/network-blocks.test.js`, `test/sounds-tts.test.js`, `test/vip-badge.test.js`, `test/ws-protocol.test.js`, `test/rest.test.js`, `test/events-consumer.test.js` — all boot Chat and touch the DB through the harness.
-
-**Harness work:** `test/helpers.js:288` sets `CHAT_DB_PATH` per run; `:338` hands the test the **db** module (the live module is `h.ctx` at :339); `:36` `sqliteNow()` (also exposed as `h.sqliteNow` :440); **137 `h.db.*` uses across 21 test files** become `await h.db.*`. Model: `OpenVibe.Media/test/helpers/pg-preload.mjs:1-20` — a `node --import` preload giving every test process one migrated PGlite (or, with **`MEDIA_TEST_STORE=pg`**, the containers) — plus `OpenVibe.Media/package.json:12` a `test:pg` script. `createTestDb`/`createTestValkey` come from `openvibe-sdk/testing` (`src/testing/db.js:26,85`), not from a Media `test/helpers/db.js`.
-
-**Valkey:** the only in-process shared state is the per-actor rate limiter (`server/net/actor-limits.js:21-22` "Counters live in this process: a restart forgets them"). Move it exactly as Media did: `createValkeyLimitStore` behind `useValkey()` (`OpenVibe.Media/server/actor-limits.js:58-62`), `VALKEY_URL`/`VALKEY_PREFIX` in config (`OpenVibe.Media/server/config.js:32`), a `/ready` check that skips when unset (`OpenVibe.Media/server/observability.js:132`). The **history-store 2 s page memo** (`history-store.js:85-94`) and the `live-context` caches stay in-process: they are latency caches whose whole design is "stale by at most 2 s". Also in-process but out of scope: the WebSocket flood control and the audio-queue timers.
-
-**Environment note (not a code failure):** `npm test` in this checkout fails 36/40 files at `new Database(...)` — `node_modules/better-sqlite3/build/Release/better_sqlite3.node` was compiled for `NODE_MODULE_VERSION 127` and this runtime is **137** (node v24.19.0, checked live). A `npm rebuild better-sqlite3` fixes it; installs are denied in this job, so the suite cannot be run green here. The 4 that pass are the ones that never open the database.
-
-
-## 7. One-cutover data plan with count verification
-
-**Target:** one PostgreSQL database (`chat`), one import, one cutover, the six staged tables still at `live`.
-
-0. **Rehearse on a copy.** `sqlite3 /var/lib/openvibe-chat/chat.db ".backup /tmp/chat-pg-source.db"`, then `importSqlite({ sqlite: '/tmp/chat-pg-source.db', db: ownerDb, truncate: true })` (`openvibe-sdk/db`, `src/db/sqlite-import.js:1-19`) into a PGlite copy of the target schema. `importSqlite` verifies **row count and a canonicalised per-row checksum per table, walked in PK order** (:17-19) and refuses on a column mismatch unless `dropColumns` lists it. Record its report; that report is the baseline for step 6.
-1. **Generate the migration.** `openvibe-sdk/tools/asyncify/sqlite-schema-to-pg.js` (README `:56-58`) over `server/db/schema.sql` + `server/rooms/rooms.js:86-132` **+ the three runtime-created tables** (`token_revocations`, `network_blocks`, `account_data_events`) → `migrations/0001_initial.sql`, then take Media's prelude verbatim (`OpenVibe.Media/migrations/0001_initial.sql:6-30`) for `ov_ts`, `ov_now`, `ov_now_iso`, `datetime`, `julianday`, `json_valid`, `json_extract`, `json_type`, `instr` — Chat's stored timestamps are SQLite `YYYY-MM-DD HH:MM:SS` text and its queries use `datetime(...)` and `CURRENT_TIMESTAMP` (`history-store.js:40-41,54-55,168`, `live-context.js:419,428`, `rooms.js:205`). The converter refuses any trigger it does not understand, and Chat's `installMirrorTriggers` TEMP triggers are not in `schema.sql` at all, so they never reach it.
-2. **Dialect fixes by hand** (`tools/asyncify/README.md:34-35,38`): `INSERT OR IGNORE` → `ON CONFLICT DO NOTHING` (`database.js:911,1159`, `rooms.js`), `INSERT OR REPLACE` → `ON CONFLICT … DO UPDATE` (`database.js:1188`, `account-data.js:187`), `COLLATE NOCASE` → `lower()` (`live-context.js:235-236`), `LIKE` → `ILIKE` (`database.js:542`), `IS ?` → `IS NOT DISTINCT FROM` (`database.js:1226`), `PRAGMA table_info` → `information_schema.columns` (`database.js:128,878`, `account-data.js:34`, `subject-merge.js:18`, `rooms.js:134`), `sqlite_master` → `information_schema.tables`, `rowid` (`database.js:534`, `account-data.js:84`, `import-from-live.js:244`) → an identity column, `sqlite_sequence` (`import-from-live.js:279-281`) → `setval(pg_get_serial_sequence(...))`, scalar `MAX(last_read_id, ?)` → `GREATEST` (`rooms.js:348`), `strftime(...)` default in `account_data_events` → `ov_now_iso()`, `lastInsertRowid`/`.changes` → `RETURNING`/`rowCount` (41 lines, §2), the dynamic table interpolation in `account-data.js`/`subject-merge.js`/`live-mirror.js`, and the partial index `idx_calls_open` (`schema.sql:518`) as a real partial index.
-3. **Convert the code** with `tools/asyncify/sqlfix.py` then `asyncify.js` over `server/**`, `scripts/**` and `test/*.test.js`; fix the `MANUAL` array callbacks by hand — the ones visible are `live-context.js:218-224` (already a `for` loop), `live-context.js:755-756`, `audio-queue.js:311-316`, and `bridge/live-bridge.js:190` (`Array.isArray(value) ? value.map(...)` becomes `for…of` + `Promise.all`).
-4. **Boot async.** `server/index.js:30` `db.initDb()` → `await db.initDb(cfg)` shaped as `openDb`/`initDb`/`getDb` in `OpenVibe.Media/server/db/database.js:28-61`; production requires `DATABASE_URL` (owner role migrates with `DATABASE_DIRECT_URL`, runtime DML-only through PgBouncer); dev falls back to an embedded PGlite in `data/pglite`. `chatServer.init` (`:37`), `callServer.init` (:38) and `createApp` (`:43`) stay after the first `await`.
-5. **Rebuild the mirror capture without TEMP triggers** (§5) — the single non-mechanical change. Append to `live_mirror_outbox` in the write transactions (`database.js` writers, `_staged`), and keep the relay as it is.
-6. **Count verification at cutover** (all against the *same* source snapshot, all with the service drained):
- - Before: `SELECT COUNT(*)` per table from the SQLite file via the same query list `scripts/parity-check.js` prints (its `tableParity` :35-40 walks `STAGED_KEYS` and compares counts and `sliceHash`).
- - After import: `importSqlite`'s per-table count + checksum on the PostgreSQL side, compared to the same numbers from the SQLite snapshot (the importer does this internally — make the report land in `import_runs`, `schema.sql:455-462`).
- - Sequences: assert `last_value` of every identity ≥ the SQLite `sqlite_sequence` high-water mark, so a post-cutover id can never collide with an imported row. (Chat's own importer already reasons this way: `import-from-live.js:275-283` takes `max(liveMax + headroom, chatMax)` and writes `sqlite_sequence` — on PostgreSQL that is `setval`, and the `+ 10000` headroom becomes a `setval` above the imported max.)
- - After cutover: `GET /ready` (`server/app.js:118-154`) must be `ready` with `db.detail.max_message_id` equal to the pre-cutover `SELECT MAX(id) FROM chat_messages`, `table_authority` all six `live` (:149), `mirror.pending` 0 (:134).
- - Spot parity: `scripts/parity-check.js` (repointed at PG) on the six staged tables; the same command the plan already runs at `docs/staged-tables-cutover.md` step 2.
- - Then run the six flips **unchanged** (`docs/staged-tables-cutover.md` §4, calendar :76-87).
-7. **Backups.** `scripts/import-from-live.js:10-12` already takes a `VACUUM INTO` copy before writing; the same applies: the SQLite file is retained, read-only, for the rollback window; `docs/cutover.md` §Rollback (:174) plus `scripts/mirror-flush.js` cover the staged tables.
-
-
-## 8. Risks
-
-1. **Message ordering.** `chat_messages.id` is the client splice cursor (`history-store.js:105-111`) and `deploy-notice.js:62` reads "the newest row in ANY room" by `ORDER BY id DESC`. A PostgreSQL identity preserves insert order; but a **cache-pool sequence gap** is invisible to `delta()` (which only needs `id > afterId`) while `readPage`'s `ORDER BY timestamp DESC, id DESC` can reorder rows that share a second — `timestamp` is second-precision text in SQLite. Give `chat_messages.timestamp` sub-second precision in the migration, or make the page order `id DESC` alone.
-2. **Ids.** 41 lines read `lastInsertRowid`; several already tolerate a different shape (`deploy-notice.js:76` `res.lastInsertRowid || res.lastID || res.id`; `live-bridge.js:199-202` and `database.js:896-897` both sniff `{changes, lastInsertRowid}`). `sounds-routes.js:285` puts the raw `result.lastInsertRowid` into an HTTP body. `_staged` and the bridge `plain()` must keep returning *something* Live's `chat-tables.js:write()` applies — Live reads the id from the answer, so `RETURNING id` must be shaped back into that field until C-01…C-03 retire the mirror.
-3. **TEMP triggers.** `installMirrorTriggers()` (`database.js:143-161`) is per-connection, per-temp-schema, and gated on `main.table_authority`. None of that exists on PostgreSQL; a naive port loses the mirror silently — Live's copy goes stale and nobody notices until a rollback.
-4. **Transactions across statements.** `db.transaction(fn)()` is synchronous; the ambient `db.tx` in `openvibe-sdk/db` is async and retries 40001/40P01. `audio-queue.js:187-203` and `live-bridge.js:217-223` read-then-write; under `READ COMMITTED` with two chat processes (or a bridge caller) a retry can double-count `bridge_applied` unless the read and the insert are in the same retryable transaction. `bridge_applied`'s insert-on-conflict is the idempotency guarantee — keep it, and consider `SERIALIZABLE` for the `enqueue` dedupe check (`audio-queue.js:188`).
-5. **The audio-queue race the plan mentions.** `audio-queue.js:209-241` `pump()` reads the next queued row, increments `attempts` (already an atomic `UPDATE … attempts = attempts + 1` at :221), awaits the synth (`perform` at :225), then transitions to `playing` and schedules the finish timer. On SQLite the whole read-modify-write is synchronous and single-process. On PostgreSQL, with `await` inside the transaction and a second process (or a retry), two `pump()` invocations can both read the same `state='queued'` row and both play it — a duplicate TTS clip. The existing test tolerance (`test/audio-queue.test.js:237` restart case; the flake noted in commit 4c517a5) becomes a much wider window once the round trip is added. Fix by making the claim atomic, not by loosening the test: `UPDATE audio_requests SET state='playing', attempts=attempts+1 WHERE id=$1 AND state='queued' RETURNING *` inside the transaction, and treat zero rows as "someone else took it". `enqueue`'s dedupe and its per-room/per-requester caps (`audio-queue.js:188-201`) need the same treatment (`ON CONFLICT` on a unique `(room, dedupe_key)`, and the counts inside the tx).
-6. **Read latency in the page path.** `GLOBAL_SELECT` (`history-store.js:27-41`) is a five-join aggregate filtered on `message_type IN (…) AND is_deleted = 0 AND (auto_delete_at IS NULL OR datetime(...) > CURRENT_TIMESTAMP)` ordered by `timestamp, id`. On SQLite this ran on a local file; on PostgreSQL it is a remote query whose plan depends on `idx_chat_ts_deleted` (`schema.sql:51`) and `idx_chat_channel_user_ts` (`:48`) surviving the conversion. Measure `page('global')` p95 before and after; if it regresses, add a partial index on `(timestamp DESC, id DESC) WHERE is_deleted = 0` or move the auto-delete filter to a retention sweep (there is already `deleteExpiredChatMessages`, `database.js:400-433`, on a timer) so the read is a plain range.
-7. **PgBouncer in transaction mode forbids session state** (`src/db/index.js:25-26`): no `SET`, no `LISTEN`, no advisory locks held across transactions. Chat's pragmas (`database.js:67-73`) disappear; any `statement_timeout`/`lock_timeout` must come from the role (as `createTestDb` does, `openvibe-sdk/src/testing/db.js:49-50`).
-8. **`ctx_*` projection write amplification.** `live-context.js:212-225` upserts every Live page inside one transaction; on PostgreSQL the transaction is fine, but `SCHEDULE` (`:760-773`) runs 9 steps every 10 s–30 min against a remote server. It is already off the message path, so this is a load question, not a latency one.
-9. **Valkey only fixes the rate limiter.** Making the limiter shared changes the numbers people see after a deploy (a restart no longer resets the budget) — that is the point, but it is a user-visible change and needs a line in the release note.
-10. **The six staged tables straddle two engines if T3 lands mid-flip.** `handOver()` (`live-bridge.js:128-148`) drains `live_mirror_outbox` for one table; if that table's copy is in SQLite and the relay reads a PostgreSQL connection, the flip silently no-ops. Sequence the work so **all** tables move to PostgreSQL before the first flip (see §7), and keep `docs/staged-tables-cutover.md` as the authority for the flip itself.
-11. **The N-1 harness** replays the previous release's SQL (from `test/fixtures/n-1`) against the current schema, deriving the old schema from `sqlite_master` (`harness.js:731,774`); after the move there is no `sqlite_master`, and the previous release's SQL is SQLite dialect. The recording format must change (record calls, not a schema) or the mixed-version guarantee CI runs is lost.
-12. **A version/dependency prerequisite is missing from the draft.** Chat's `openvibe-sdk` pin v0.12.0 has no `db`, no `src/testing` helpers, and no `tools`; `pg`/`pglite`/`iovalkey` are not installed. Landing the plan as written cannot even import its data layer. Bump the SDK (≥0.18.0; ideally 0.22.0) and add the three drivers as step 0, and treat the SDK's own release notes as the contract for `createDb`/`importSqlite`/`asyncify`.
-13. **Dynamic SQL is a real dialect hazard, not a mechanical one.** `account-data.js:34,84,106-153`, `subject-merge.js:18,35,47-70`, `live-mirror.js:50` and `applyStagedChanges` (`database.js:1185-1188`) all interpolate table and column names into SQL text. The asyncify tools leave those alone, and `openvibe-sdk/db`'s safe path (`sql`, `sql.ident`) is a tagged template — so these are hand conversions with no codemod behind them, and they are the places where a "converted" service silently changes behaviour.
-
-
-## 9. Step-by-step plan with tests
-
-Each step is one commit; `npm test` (and `npm run test:pg` once it exists) after every one.
-
-0. **Prerequisites.** Raise `openvibe-sdk` (`package.json:32`) to a version that ships `openvibe-sdk/db` and ambient transactions (≥0.18.0), add `pg`, `@electric-sql/pglite`, `iovalkey`, and vendor/checkout `tools/asyncify` from the SDK repo. **Test:** `node -e "require('openvibe-sdk/db')"` loads; the existing suite still passes on SQLite.
-1. **Migrations scaffold.** `migrations/0001_initial.sql` from `sqlite-schema-to-pg.js` over `schema.sql` + `rooms.js:86-132` + the three runtime tables, plus Media's SQLite-compat prelude. `server/config.js` gains `db: { url, directUrl }` and `valkey: { url, prefix }`. Boot stays SQLite. **Test:** `test/ready.test.js` unchanged; a new `test/pg-schema.test.js` that opens a PGlite, migrates, and asserts every one of the 35 tables and 39 indexes (from a list, not from `sqlite_master`) exists.
-2. **`openDb`/`initDb`/`getDb` + `test/helpers/pg-preload.mjs`.** Media's `server/db/database.js:28-61` verbatim in shape; `CHAT_DB_PATH` still works as the SQLite path behind a `CHAT_STORE=sqlite|pglite|pg` switch for the transition. **Test:** every existing test file passes on PGlite; add `test/pg-boot.test.js` asserting `/ready`'s `db` check is `ok` on both stores.
-3. **Dialect fixes in SQL only, SQLite still serving.** `sqlfix.py` rules, `lastInsertRowid` → `RETURNING` behind a helper that returns `{ changes, lastInsertRowid }` on both engines so no caller changes shape — including `sounds-routes.js:285`, which puts it in an HTTP body. **Test:** the whole suite on PGlite; `test/import.test.js` and `test/restart-resume.test.js` ported to read through the same abstraction.
-4. **Asyncify.** `asyncify.js` over `server/**`, `scripts/**`, `test/*.test.js`; the `MANUAL` array callbacks fixed by hand, and the dynamic-SQL sites of risk 13 rewritten. **Test:** the whole suite, no test file changed in meaning; `test/bridge.test.js` and `test/staged-tables.test.js` are the sensitive ones (their HTTP bodies must not change).
-5. **History store on PostgreSQL.** `history-store.js` async, `readPage` ordered by `id` (or a new `(timestamp, id)` index with sub-second `timestamp`). **Test:** a new `test/history-latency.test.js` measuring `page('global')` and `delta` over 200 k imported rows on PGlite, asserting the p95 and the plan (`EXPLAIN` shows an index scan, not a seq scan); `test/rest.test.js` covers the routes.
-6. **Audio queue claim atomicity** (risk 5). **Test:** `test/audio-queue.test.js` unchanged **plus** a new case that runs two `pump()` loops concurrently over the same room on a real PG connection and asserts exactly one play per request; the restart case gets an `await` between the claim and the synth so the window is deterministic instead of load-dependent.
-7. **Mirror capture without TEMP triggers.** Appends to `live_mirror_outbox` in the write transactions; `installMirrorTriggers` deleted; `scripts/mirror-flush.js` and `scripts/table-authority.js` repointed. **Test:** `test/outbox-mirror.test.js` and the mirror half of `test/staged-tables.test.js` pass on PGlite, including "Live's changes never come back" and "pending 0 after a flush".
-8. **Live-side staged sync deletion, part 1.** With every table still `live`: remove `stagedSlice`/`sliceHash`/dual-read from both sides, keep `applyStagedChanges` + the `stagedApply` relay, add the PG identity key of the capture. **Test:** `test/staged-tables.test.js` loses the dual-read assertions (:99) and gains a PG-specific capture test; Live's `chat-tables-sync.js` relay interval and pause behaviour unchanged.
-9. **The data move and the cutover**, run once, in the order of `docs/staged-tables-cutover.md` §0–§3: import (with the counts/checksums of §7), parity, dual-read for one release. **Test:** `scripts/parity-check.js` on PG; `/ready` `table_authority` all six `live`.
-10. **The six flips**, one per day, unchanged procedure. **Test:** `test/staged-tables.test.js` extended to cover the flip-back with a non-empty mirror queue (`live-bridge.js:138-145`) on PG.
-11. **Delete the bridge and mirror code** (C-01…C-03, 2026-11-06; the staged capture not before 2026-12-18). **Test:** the suite with `applyStagedChanges`, `sliceHash`, `stagedSlice`, the `stagedApply`/`stagedSlice` bridge ops and Live's `chat-tables-sync.js` removed; `docs/staged-tables-cutover.md` gains a closing section.
-12. **Valkey for the rate limiter.** Media's `createValkeyLimitStore` + `useValkey` + `VALKEY_URL`/`VALKEY_PREFIX` + a `/ready` check that skips when unset. **Test:** `test/actor-limits.test.js` extended with two handles sharing one budget against `createTestValkey`, and a skip line when the Valkey URL is absent.
-13. **N-1 harness on PostgreSQL** (risk 11). **Test:** `test/n-1.test.js` replays the recorded previous-release calls against the migrated schema and reports the same `MANUAL` cases.
-14. **Drop better-sqlite3 from the service.** `package.json:24` loses it; `CHAT_DB_PATH` becomes `DATABASE_URL`. **Test:** nothing in `server/` requires it — assert that in `test/security-secrets.test.js` or a new guard test.
-
-
-## Open questions for Opus
-
-1. Does T3 land **before** the first staged flip (my recommendation, §7) or is there schedule pressure that forces a flip first? If forced, which of the six goes first and does `handOver()` get a two-engine mode?
-2. Is the mirror allowed to become an explicit `live_mirror_outbox` append in every write transaction, or must it stay a database trigger (on PostgreSQL a real trigger gated by a session GUC, and then a per-transaction `SET LOCAL`, which `asyncify.js` will not insert for us)?
-3. Should `chat_messages.timestamp` gain sub-second precision in the migration, or should `readPage` order by `id` alone? This decides whether the global page keeps its `idx_chat_ts_deleted`.
-4. The audio-queue claim: is an atomic `UPDATE … WHERE state='queued' RETURNING *` acceptable, or does the queue need a lease (a `claimed_until` column) because a synth can outlive `statement_timeout = 15 s` on the runtime role?
-5. Which `openvibe-sdk` version is the target (HEAD is v0.22.0; the data layer assumes ≥0.18.0), and does that bump ride with the move or land first on its own?
-6. Is the `MAX(last_read_id, ?)` in `rooms.js:348` the only scalar-`MAX` site? I checked every `MAX(` in `server/rooms/rooms.js` and the other two (`SELECT MAX(id) … room_messages` at :241 and :346) are aggregates, so yes — but a repo-wide `MAX(` sweep has not been done.
-7. Does Valkey also take the history-store memo, or does that stay a 2 s in-process cache (my assumption: it stays)?
-8. `test/n-1/*` records the previous release's SQL and derives its schema from `sqlite_master` (from `test/fixtures/n-1`). Is the accepted replacement "record the calls and replay them against the migrated schema" (a rewrite of ~1000 lines of harness), or is the N-1 guarantee allowed to lapse for one release while the engine moves?
-9. `server/chat/account-data.js:34,84,96` and `subject-merge.js:18,35` interpolate table and column names and read `PRAGMA table_info`; account export orders by `rowid`. Are dedicated, static queries acceptable there, or must account export keep the "most recent N by insertion order" semantics exactly?
-10. The `relay_users` table has no id column — its key is `(platform, username)` and Chat uses `rowid AS id` (`database.js:534`). Does the PG migration give it a real identity column and keep the rowid-shaped answer, or does `CHAT_TABLES.relay_users` change?
-11. Who runs the production import: is a Chat-maintained `scripts/import-sqlite-to-pg.js` (wrapping `openvibe-sdk/db.importSqlite`) acceptable, or does the move use `OpenVibe.Host roles/data` tooling like Media's `add-service.sh`?
-
-
-*Method note: this is a read-only job. Nothing in `/home/workstation/OpenVibers/OpenVibe.Chat` was edited (working tree clean). The brief under review is `/home/workstation/openvibe/agents/jobs/brief-chat-pg.out.a3.report.md` (249 lines). Every `file:line` in the Corrections table was re-checked against the code, against `openvibe-sdk` HEAD (v0.22.0) and `openvibe-media`, and against `openvibe-live`'s `server/chat/chat-tables{,-sync}.js` and `server/internal/routes.js`. The `npm test` ABI failure was reproduced live (`process.versions.modules` = 137 vs a 127 build).*
-
-
-The report is written to `/home/workstation/openvibe/agents/jobs/check-brief-chat-pg.out.a2.report.md`.
-
-I re-verified every `file:line`, count and "exists / does not exist" claim in the draft against the code, SDK HEAD v0.22.0, Media and Live. The Corrections table has ~50 rows. The blocking error: Chat pins `openvibe-sdk` **v0.12.0**, which has no `db` module, no `tools/asyncify` and no `src/testing` — those arrived in 0.14.0 and 0.18.0 — and `pg`/`pglite`/`iovalkey` are not dependencies at all. Also corrected: the draft invents a Media `test/helpers/db.js` that does not exist, claims ~170 call sites when there are 219, and misses `network_blocks`, `account_data_events` and `token_revocations` as runtime-created tables (four schema owners, not two). I added what the draft missed: every SQLite call-site group, the 34 transaction callbacks, and the 13 test cases of the staged-tables contract.
\ No newline at end of file
\ No newline at end of file
@@ -9,7 +9,6 @@
@@ -9,7 +9,6 @@
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js",
"test": "node test/run.js",
"test:pg": "OV_TEST_STORE=pg node test/run.js",
"test:pg": "OV_TEST_STORE=pg node test/run.js",
- "import-from-live": "node scripts/import-from-live.js",
"n-1:record": "node scripts/n-1-record.js"
"n-1:record": "node scripts/n-1-record.js"
},
},
"keywords": [
"keywords": [
deleted file mode 100644
deleted file mode 100644
@@ -1,295 +0,0 @@
@@ -1,295 +0,0 @@
-#!/usr/bin/env node
-/**
- * Copy OpenVibe.Live's chat tables into OpenVibe.Chat's database.
- *
- * node scripts/import-from-live.js --live-db <copy of live.db> [--apply [--backup <path> | --no-backup]]
- * [--headroom <n>] [--tables a,b] [--no-projections]
- *
- * Chat's database is the service's (DATABASE_URL / DATABASE_DIRECT_URL, or the development PGlite); Live's
- * snapshot is read with node:sqlite (scripts/lib/sqlite.js).
- *
- * Run it against a SNAPSHOT (sqlite3 live.db ".backup /tmp/live-snapshot.db"), never the live file.
- *
- * - A dry run unless --apply: the report says what a run would do and nothing is written. --apply
- * first copies the rows of every table it may write (the twelve chat tables, the ctx_* projections,
- * import_hold, import_runs) to a JSON file (./data/chat-pre-import-<time>.json, or --backup <path>)
- * and says where in the report. A whole-database backup is Host's (pg_dump).
- *
- * - Idempotent: a row already in Chat is recognised by its primary key and never written twice;
- * running it again after the cutover copies only what Live wrote since the first run.
- * - Ids are kept (a message, DM or sound keeps its Live id). Chat's own id sequences start
- * `--headroom` (default 10000) above Live's highest id (setval), so rows Live writes between the
- * first import and the flag flip still fit below and a second pass can bring them over.
- * - Nothing it writes is queued for Live's read mirror (ov.mirror_skip) or announced to Events.
- * - Never drops a row. What cannot be represented goes to import_hold with the reason:
- * an id already used by a DIFFERENT row, a constraint the row breaks, rows of Live's
- * transient *_new migration tables.
- * - Chat's tables: an existing Chat row always wins (after the cutover Chat is the authority and may
- * have edited it). The six tables Chat owns (channel moderators/settings, emotes, user tags,
- * chat-AI summaries/timeline; C-04 done) are not imported — Chat is their only writer.
- * - New columns: *subject_id is filled from Live's linked_accounts (the Network subject).
- * - media_requests / media_request_settings stay in Live (decision: docs/cutover.md) and are
- * only reported.
- * - Also seeds the ctx_* projections (users, streams, slots, channels) so a rehearsal can read
- * history before Chat's first sync (--no-projections to skip).
- *
- * Prints counts per table as JSON and records the run in import_runs. Exit code 1 when Live has
- * columns Chat does not know (schema drift: fix the schema first, nothing is written).
- */
-'use strict';
-
-const fs = require('fs');
-const path = require('path');
-const { openSqlite } = require('./lib/sqlite');
-
-function parseArgs(argv) {
- const out = { dryRun: true, backup: true, headroom: 10000, projections: true, tables: null };
- for (let i = 0; i < argv.length; i++) {
- const a = argv[i];
- if (a === '--live-db') out.liveDb = argv[++i];
- else if (a === '--dry-run') out.dryRun = true;
- else if (a === '--apply') out.dryRun = false;
- else if (a === '--backup') out.backupPath = argv[++i];
- else if (a === '--no-backup') out.backup = false;
- else if (a === '--headroom') out.headroom = Math.max(0, parseInt(argv[++i], 10) || 0);
- else if (a === '--tables') out.tables = String(argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
- else if (a === '--no-projections') out.projections = false;
- else if (a === '--help' || a === '-h') out.help = true;
- else throw new Error(`unknown argument ${a}`);
- }
- return out;
-}
-
-// Columns Live keeps for itself on a table Chat owns (not moved; they stay in Live's copy).
-const LIVE_ONLY_COLUMNS = {
- channel_sounds: ['media_url', 'media_asset_id'],
-};
-// Columns that never change once a row exists: equal = the same row, different = an id conflict.
-const IDENTITY = {
- chat_messages: ['stream_id', 'user_id', 'anon_id', 'message', 'timestamp'],
- dm_conversations: ['created_by', 'created_at'],
- dm_participants: ['conversation_id', 'user_id'],
- dm_messages: ['conversation_id', 'sender_id', 'message', 'created_at'],
- dm_blocks: ['blocker_id', 'blocked_id'],
- channel_sounds: ['channel_owner_id', 'url', 'created_at'],
- hidden_relay_users: ['platform', 'external_username', 'created_at'],
- pending_ip_messages: ['channel_id', 'ip_address', 'message', 'created_at'],
- moderation_actions: ['action_type', 'actor_user_id', 'created_at'],
-};
-// Subject columns filled from the author's Live user id.
-const SUBJECT_COLUMNS = {
- chat_messages: [['subject_id', 'user_id']],
- dm_messages: [['sender_subject_id', 'sender_id']],
- dm_participants: [['subject_id', 'user_id']],
- dm_blocks: [['blocker_subject_id', 'blocker_id']],
- moderation_actions: [['actor_subject_id', 'actor_user_id']],
- channel_sounds: [['created_by_subject_id', 'created_by']],
-};
-const TRANSIENT = ['chat_messages_new', 'emotes_new', 'channel_sounds_new'];
-const NOT_MOVED = ['media_requests', 'media_request_settings'];
-const PROJECTIONS = ['ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels'];
-class Rollback extends Error {}
-
-// Table and column names come from db.CHAT_TABLES and the two schemas, never from input; still, only plain names.
-const ident = (n) => { if (!/^[a-z_][a-z0-9_]*$/.test(n)) throw new Error(`unexpected identifier ${n}`); return n; };
-// A value read back from PostgreSQL equals Live's: numbers and text compare as text where a column changed type
-// (chat_messages.deleted_by, moderation_actions.scope_id).
-const same = (a, b) => a === b || (a == null && b == null) || (a != null && b != null && String(a) === String(b));
-
-/** Copy the rows of every table this run may write, before it writes (a JSON file; the whole database is pg_dump's). */
-async function backupChat(chat, tables, target) {
- const file = path.resolve(target || path.join('data', `chat-pre-import-${new Date().toISOString().replace(/[:.]/g, '-')}.json`));
- if (fs.existsSync(file)) throw new Error(`backup ${file} already exists`);
- const out = { taken_at: new Date().toISOString(), tables: {} };
- for (const t of tables) out.tables[t] = await chat.prepare(`SELECT * FROM ${ident(t)}`).all();
- fs.mkdirSync(path.dirname(file), { recursive: true });
- fs.writeFileSync(file, JSON.stringify(out));
- return file;
-}
-
-async function run(opts) {
- if (!opts.liveDb) throw new Error('--live-db <snapshot> is required');
- const db = require('../server/db/database');
- await db.initDb();
- const chat = db.getDb();
- const live = openSqlite(path.resolve(opts.liveDb));
- // Rows written here came from Live: never queued back into Live's read mirror.
- const noMirror = () => chat.query("SET LOCAL ov.mirror_skip = '1'");
-
- const liveTables = new Set(live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
- const colsOfLive = (t) => live.prepare(`PRAGMA table_info(${ident(t)})`).all().map((c) => c.name);
- const colsOfChat = async (t) => (await chat.prepare(`SELECT column_name FROM information_schema.columns
- WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position`).all(t)).map((c) => c.column_name);
-
- // The six tables Chat owns (C-04 done) are not imported: Chat is their only writer.
- const plan = [
- ...Object.entries(db.CHAT_TABLES).map(([t, pk]) => ({ table: t, pk: t === 'relay_users' ? ['platform', 'username'] : pk })),
- ].filter((p) => !opts.tables || opts.tables.includes(p.table));
-
- // Schema drift check first: nothing is written when Live has columns Chat cannot hold. (relay_users' id is
- // Chat's own identity column: Live's rows have none.)
- const drift = [];
- for (const p of plan) {
- if (!liveTables.has(p.table)) continue;
- const chatCols = new Set(await colsOfChat(p.table));
- const extra = colsOfLive(p.table).filter((c) => !chatCols.has(c) && !(LIVE_ONLY_COLUMNS[p.table] || []).includes(c));
- if (extra.length) drift.push(`${p.table}: ${extra.join(', ')}`);
- }
- if (drift.length) {
- live.close();
- const err = new Error(`Live has columns Chat does not know — add them in a migration first:\n ${drift.join('\n ')}`);
- err.code = 'SCHEMA_DRIFT';
- throw err;
- }
-
- // Network subjects by Live user id (newest link wins, as Live resolves them).
- const subjects = new Map();
- if (liveTables.has('linked_accounts') && colsOfLive('linked_accounts').includes('subject_id')) {
- for (const r of live.prepare("SELECT user_id, subject_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NOT NULL ORDER BY id").iterate()) subjects.set(r.user_id, r.subject_id);
- }
-
- const holdStmt = chat.prepare('INSERT INTO import_hold (source_table, source_pk, reason, row_json) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING');
- const hold = async (table, pkObj, reason, row) => (await holdStmt.run(table, JSON.stringify(pkObj), String(reason).slice(0, 300), JSON.stringify(row))).changes;
-
- const report = { live_db: path.resolve(opts.liveDb), dry_run: !!opts.dryRun, backup: null, tables: {} };
- if (!opts.dryRun && opts.backup) report.backup = await backupChat(chat, [...Object.keys(db.CHAT_TABLES), ...PROJECTIONS, 'import_hold', 'import_runs'], opts.backupPath);
-
- for (const p of plan) {
- const r = { live: 0, inserted: 0, identical: 0, chat_kept: 0, held: 0 };
- report.tables[p.table] = r;
- if (!liveTables.has(p.table)) { r.missing_in_live = true; continue; }
- const chatCols = new Set(await colsOfChat(p.table));
- const cols = colsOfLive(p.table).filter((c) => chatCols.has(c)).map(ident);
- const subjectCols = (SUBJECT_COLUMNS[p.table] || []).filter(([sc]) => chatCols.has(sc));
- const insertCols = cols.concat(subjectCols.map(([sc]) => ident(sc)));
- const where = p.pk.map((k) => `${ident(k)} = ?`).join(' AND ');
- const getChat = chat.prepare(`SELECT * FROM ${ident(p.table)} WHERE ${where}`);
- const ins = chat.prepare(`INSERT INTO ${ident(p.table)} (${insertCols.join(', ')}) VALUES (${insertCols.map(() => '?').join(', ')})`);
- const identity = IDENTITY[p.table] || null;
- const orderBy = p.pk.map(ident).join(', ');
-
- const apply = async () => {
- for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${ident(p.table)} ORDER BY ${orderBy}`).iterate()) {
- r.live++;
- const pkVals = p.pk.map((k) => row[k]);
- const pkObj = Object.fromEntries(p.pk.map((k) => [k, row[k]]));
- const existing = await getChat.get(...pkVals);
- if (!existing) {
- // A savepoint per row: a row that breaks a constraint is held, the rest of the table still goes.
- try {
- await chat.tx(() => ins.run(...cols.map((c) => row[c]), ...subjectCols.map(([, uidCol]) => subjects.get(row[uidCol]) || null)));
- r.inserted++;
- } catch (err) {
- r.held += await hold(p.table, pkObj, `insert failed: ${err.message}`, row);
- }
- continue;
- }
- if (cols.every((c) => same(existing[c], row[c]))) { r.identical++; continue; }
- if (identity && !identity.every((c) => !cols.includes(c) || same(existing[c], row[c]))) {
- // Same id, different row: Chat's stays, Live's is held for a person to look at.
- r.held += await hold(p.table, pkObj, 'id already used by a different row in chat', row);
- continue;
- }
- r.chat_kept++;
- }
- };
-
- try {
- await chat.tx(async () => { await noMirror(); await apply(); if (opts.dryRun) throw new Rollback(); });
- } catch (err) {
- if (!(err instanceof Rollback)) throw err;
- }
- }
-
- // Transient migration tables (Live rebuilds a table through <name>_new): never expected to
- // hold rows; if a snapshot caught one mid-rebuild, keep every row for review.
- for (const t of TRANSIENT) {
- if (!liveTables.has(t)) continue;
- const r = { live: 0, held: 0 };
- report.tables[t] = r;
- try {
- await chat.tx(async () => {
- const pkCol = colsOfLive(t).includes('id') ? 'id' : 'rowid';
- for (const row of live.prepare(`SELECT rowid AS _rowid, * FROM ${ident(t)}`).iterate()) {
- r.live++;
- r.held += await hold(t, { [pkCol]: pkCol === 'id' ? row.id : row._rowid }, 'row of a transient migration table (Live rebuilds tables through <name>_new)', row);
- }
- if (opts.dryRun) throw new Rollback();
- });
- } catch (err) { if (!(err instanceof Rollback)) throw err; }
- }
- for (const t of NOT_MOVED) {
- if (liveTables.has(t)) report.tables[t] = { live: live.prepare(`SELECT COUNT(*) AS n FROM ${ident(t)}`).get().n, not_moved: 'stays in Live (media queue is Live-owned in W6)' };
- }
-
- if (!opts.dryRun) {
- // Chat's new ids start above Live's highest id plus headroom (never lowered): the identity's sequence.
- await chat.tx(async () => {
- for (const [t] of Object.entries(db.CHAT_TABLES)) {
- // (only the tables this run imported: --tables leaves the others as they are)
- if (!report.tables[t] || !liveTables.has(t) || !colsOfLive(t).includes('id')) continue;
- const liveMax = live.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${ident(t)}`).get().m;
- const chatMax = (await chat.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${ident(t)}`).get()).m;
- const seq = (await chat.prepare("SELECT pg_get_serial_sequence(?, 'id') AS s").get(t)).s;
- const cur = Number((await chat.prepare('SELECT pg_sequence_last_value(?::regclass) AS v').get(seq)).v) || 0;
- const target = Math.max(liveMax + opts.headroom, chatMax, cur);
- if (target > cur) await chat.prepare('SELECT setval(?::regclass, ?)').get(seq, target);
- report.tables[t].next_id = target + 1;
- }
- });
-
- if (opts.projections) {
- const seed = async (table, sql, cols) => {
- if (!liveTables.has(sql.table)) return 0;
- const have = new Set(colsOfLive(sql.table));
- const pick = cols.filter((c) => have.has(c.from || c.to));
- const st = chat.prepare(`INSERT INTO ${ident(table)} (${pick.map((c) => ident(c.to)).join(', ')}, synced_at) VALUES (${pick.map(() => '?').join(', ')}, 0)
- ON CONFLICT(id) DO NOTHING`);
- let n = 0;
- await chat.tx(async () => {
- for (const row of live.prepare(`SELECT ${pick.map((c) => ident(c.from || c.to)).join(', ')} FROM ${ident(sql.table)}`).iterate()) {
- n += (await st.run(...pick.map((c) => (c.to === 'subject_id' ? (subjects.get(row.id) || null) : row[c.from || c.to])))).changes;
- }
- });
- return n;
- };
- const c = (to, from) => ({ to, from });
- report.projections = {
- ctx_users: await seed('ctx_users', { table: 'users' }, [c('id'), c('username'), c('display_name'), c('avatar_url'), c('profile_color'), c('role'), c('is_banned'), c('ban_reason'), c('is_owner'), c('created_at'), c('subject_id', 'id')]),
- ctx_streams: await seed('ctx_streams', { table: 'streams' }, [c('id'), c('user_id'), c('channel_id'), c('managed_stream_id'), c('title'), c('is_live'), c('started_at'), c('ended_at'), c('created_at')]),
- ctx_managed_streams: await seed('ctx_managed_streams', { table: 'managed_streams' }, [c('id'), c('user_id'), c('slug'), c('title'), c('sort_order'), c('created_at')]),
- ctx_channels: await seed('ctx_channels', { table: 'channels' }, [c('id'), c('user_id'), c('title')]),
- };
- }
- await chat.prepare('INSERT INTO import_runs (live_db, dry_run, counts, finished_at) VALUES (?, 0, ?, ov_now())').run(report.live_db, JSON.stringify(report.tables));
- }
- report.held_total = (await chat.prepare('SELECT COUNT(*) AS n FROM import_hold').get()).n;
- live.close();
- return report;
-}
-
-const USAGE = 'usage: node scripts/import-from-live.js --live-db <snapshot> [--apply [--backup <path> | --no-backup]] [--headroom <n>] [--tables a,b] [--no-projections]';
-
-/** The command line: → { code, stdout, stderr } (exit 0; 1 schema drift; 2 usage; 3 failure). */
-async function cli(argv) {
- let opts;
- try { opts = parseArgs(argv); } catch (err) { return { code: 2, stdout: '', stderr: `${err.message}\n` }; }
- if (opts.help || !opts.liveDb) return { code: opts.help ? 0 : 2, stdout: `${USAGE}\n`, stderr: '' };
- try {
- const report = await run(opts);
- return { code: 0, stdout: `${JSON.stringify(report, null, 2)}\n`, stderr: '' };
- } catch (err) {
- return { code: err.code === 'SCHEMA_DRIFT' ? 1 : 3, stdout: '', stderr: `${err.message}\n` };
- }
-}
-
-if (require.main === module) {
- cli(process.argv.slice(2)).then(async (r) => {
- process.stdout.write(r.stdout);
- process.stderr.write(r.stderr);
- try { await require('../server/db/database').close(); } catch { /* */ }
- process.exit(r.code);
- });
-}
-
-module.exports = { run, cli, parseArgs, IDENTITY, SUBJECT_COLUMNS, LIVE_ONLY_COLUMNS };
deleted file mode 100644
deleted file mode 100644
@@ -1,89 +0,0 @@
@@ -1,89 +0,0 @@
-#!/usr/bin/env node
-/**
- * The one-time move of Chat's SQLite data into its PostgreSQL schema (plan T3, decision 10).
- *
- * node scripts/import-sqlite-to-pg.js --sqlite /var/lib/openvibe-chat/chat.db \
- * --url "$DATABASE_DIRECT_URL" # the owner role, a direct connection
- * node scripts/import-sqlite-to-pg.js --sqlite ./data/chat.db --pglite ./data/pglite # a local dry run
- *
- * It wraps openvibe-sdk/db importSqlite: parents first, batched inserts, identity columns keep their
- * ids and their sequences move past the maximum, and every table is verified (row count + a checksum
- * walked in primary-key order). The per-table report is written to import_runs and printed. The
- * session runs with ov.mirror_skip = '1', so the rows (which came from Live) are never queued back
- * into Live's read mirror.
- */
-'use strict';
-
-const path = require('path');
-const { createDb, importSqlite } = require('openvibe-sdk/db');
-const { openSqlite } = require('./lib/sqlite');
-
-const MIGRATIONS = path.join(__dirname, '..', 'migrations');
-
-function usage(msg) {
- if (msg) console.error(`import-sqlite-to-pg: ${msg}`);
- console.error('usage: import-sqlite-to-pg --sqlite <file> (--url <postgres-url> | --pglite <dir>) [--truncate] [--no-verify]');
- process.exit(2);
-}
-
-async function main(argv) {
- const args = { sqlite: '', url: '', pglite: '', truncate: false, verify: true };
- for (let i = 0; i < argv.length; i++) {
- const a = argv[i];
- if (a === '--sqlite') args.sqlite = argv[++i];
- else if (a === '--url') args.url = argv[++i];
- else if (a === '--pglite') args.pglite = argv[++i];
- else if (a === '--truncate') args.truncate = true;
- else if (a === '--no-verify') args.verify = false;
- else usage(`unknown argument ${a}`);
- }
- if (!args.sqlite) usage('--sqlite is required');
- if (!args.url && !args.pglite) args.url = process.env.DATABASE_DIRECT_URL;
- if (!args.url && !args.pglite) usage('--url, --pglite or DATABASE_DIRECT_URL is required');
-
- // The owner handle (or an embedded PGlite for a dry run). One connection, so the session setting below holds.
- const db = args.pglite
- ? createDb({ pglite: args.pglite, service: 'chat-import', max: 1 })
- : createDb({ url: args.url, service: 'chat-import', max: 1 });
- try {
- // The schema first (the owner applies migrations/; a no-op where the release already did).
- await db.migrate({ dir: MIGRATIONS, log: { log() {}, info() {}, warn: console.warn, error: console.error } });
- // The import's rows came from Live: they must not queue in the read mirror (decision 1).
- await db.query(`SET ov.mirror_skip = '1'`);
-
- const started = new Date().toISOString().replace('T', ' ').slice(0, 19);
- // Read with node:sqlite (scripts/lib/sqlite.js): better-sqlite3 is no longer a dependency (decision 11).
- const source = openSqlite(args.sqlite);
- let report;
- try { report = await importSqlite({ sqlite: source, db, truncate: args.truncate, verify: args.verify }); } finally { source.close(); }
-
- // Statistics for the serving role (plan T3, decision 2): the importer is the owner, and the runtime
- // role is DML-only, so its ANALYZE is silently skipped and it would have no column statistics until
- // autovacuum runs. Without them the planner mis-estimates the history page and picks a bitmap scan
- // + Sort (p95 ≈ 280 ms at 60k rows); with them it is an Index Scan Backward (p95 ≈ 2 ms). See
- // test/history-latency.test.js.
- await db.query('ANALYZE');
-
- // The per-table report, one line each, in the same shape import_runs stores it.
- for (const t of report.tables) {
- console.log(`[import] ${t.table.padEnd(28)} ${String(t.rows).padStart(8)} rows${t.checksum ? ` checksum ${t.checksum}` : ''} (${t.ms} ms)`);
- }
- for (const p of report.problems) console.error(`[import] PROBLEM ${p.table}: ${p.problem}`);
- console.log(`[import] ${report.tables.length} tables, ${report.tables.reduce((n, t) => n + t.rows, 0)} rows, ${report.ok ? 'ok' : 'FAILED'}`);
-
- // Record the run (best effort: a failure here does not change the import's verdict).
- try {
- await db.prepare(`INSERT INTO import_runs (live_db, dry_run, counts, started_at, finished_at) VALUES (?, ?, ?, ?, ?)`)
- .run(args.sqlite, args.pglite ? 1 : 0, JSON.stringify({ ok: report.ok, tables: report.tables, problems: report.problems }), started, new Date().toISOString().replace('T', ' ').slice(0, 19));
- } catch (err) { console.warn(`[import] could not record the run: ${err.message}`); }
-
- await db.query(`SET ov.mirror_skip = '0'`).catch(() => {});
- if (!report.ok) process.exitCode = 1;
- } finally {
- await db.close().catch(() => {});
- }
-}
-
-if (require.main === module) main(process.argv.slice(2)).catch((err) => { console.error(`[import] ${err && err.stack || err}`); process.exit(1); });
-
-module.exports = { main };
deleted file mode 100644
deleted file mode 100644
@@ -1,56 +0,0 @@
@@ -1,56 +0,0 @@
-'use strict';
-/**
- * Read (or, for a test fixture, write) a SQLite file without better-sqlite3 (plan T3 decision 11: the service no
- * longer depends on it). node:sqlite (Node 22) behind the better-sqlite3 shape the one-time tools use:
- * prepare(sql).all/get/run/iterate, exec, pragma, close — so openvibe-sdk/db importSqlite takes it as its `sqlite`
- * source. BLOBs come back as Buffers, as they did.
- *
- * const { openSqlite } = require('./lib/sqlite');
- * const live = openSqlite('/tmp/live-snapshot.db'); // read-only, must exist
- * const fixture = openSqlite(file, { readonly: false, create: true });
- */
-const fs = require('fs');
-
-let quieted = false;
-function quietExperimentalWarning() {
- // node:sqlite prints an ExperimentalWarning on first use in Node 22; the tools' output is their report.
- if (quieted) return;
- quieted = true;
- const emit = process.emitWarning;
- process.emitWarning = function (warning, ...rest) {
- const type = typeof rest[0] === 'string' ? rest[0] : rest[0] && rest[0].type;
- if (type === 'ExperimentalWarning' && /SQLite/i.test(String(warning && warning.message ? warning.message : warning))) return;
- return emit.call(process, warning, ...rest);
- };
-}
-
-function fixRow(row) {
- if (!row) return row;
- const out = {};
- for (const [k, v] of Object.entries(row)) out[k] = v instanceof Uint8Array && !Buffer.isBuffer(v) ? Buffer.from(v) : v;
- return out;
-}
-
-function openSqlite(file, { readonly = true, create = false } = {}) {
- if (!create && !fs.existsSync(file)) throw new Error(`${file}: no such SQLite file`);
- quietExperimentalWarning();
- const { DatabaseSync } = require('node:sqlite');
- const d = new DatabaseSync(file, { readOnly: readonly });
- return {
- name: file,
- prepare(sql) {
- const st = d.prepare(sql);
- return {
- all: (...a) => st.all(...a).map(fixRow),
- get: (...a) => fixRow(st.get(...a)),
- run: (...a) => { const r = st.run(...a); return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) }; },
- *iterate(...a) { for (const r of st.iterate(...a)) yield fixRow(r); },
- };
- },
- exec: (sql) => d.exec(sql),
- pragma: (p) => d.exec(`PRAGMA ${p}`),
- close: () => d.close(),
- };
-}
-
-module.exports = { openSqlite };
deleted file mode 100644
deleted file mode 100644
@@ -1,82 +0,0 @@
@@ -1,82 +0,0 @@
-#!/usr/bin/env node
-/**
- * Move the chat preferences Live kept (user_preferences.chat_settings) into the OpenVibe.Network user
- * module chat.preferences, which Chat owns. One-off and idempotent; logic in server/prefs/from-live.js.
- *
- * node scripts/migrate-chat-preferences.js --live-db <live.db> # dry run (default)
- * node scripts/migrate-chat-preferences.js --live-db <live.db> --apply --backup <file> # write
- * node scripts/migrate-chat-preferences.js --rollback <file> [--apply] # undo a run
- *
- * Options:
- * --live-db <file> Live's database (a snapshot is best: sqlite3 live.db ".backup /tmp/live.db"), opened read-only
- * --apply write; without it nothing is written and the report says what would be
- * --backup <file> required with --apply; must not exist. Written (0600) and read back before any
- * write: every record the run will create (none existed before) and its data; the
- * revision each creation got is added when the run ends. --rollback takes it.
- * --rollback <file> delete the records that run created, each only while still at the revision it
- * got (a record the person changed since is kept). Dry run unless --apply.
- *
- * Reads the same environment as the server (.env / /etc/openvibe/chat.env): OV_NETWORK_INTERNAL_URL,
- * OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET. Chat's principal needs network.modules.read and
- * network.modules.write on chat.preferences (Network default grants). Every write goes through Network,
- * which validates it, gives it a revision and emits network.module.updated.
- *
- * Only choices move (a value equal to Live's default is not one): showTimestamps, compactMode, fontSize,
- * showBadges. Prints a JSON report. Exit codes: 0 done (or dry run), 2 refused (bad options), 1 failed.
- */
-'use strict';
-
-const fs = require('fs');
-
-function usage(msg, code = msg ? 2 : 0) {
- if (msg) console.error(msg);
- console.error('usage: node scripts/migrate-chat-preferences.js --live-db <live.db> [--apply --backup <file>]\n'
- + ' node scripts/migrate-chat-preferences.js --rollback <file> [--apply]');
- process.exit(code);
-}
-
-function parseArgs(argv) {
- const out = { liveDb: null, apply: false, backup: null, rollback: null };
- const value = (i, name) => { const v = argv[i]; if (!v || v.startsWith('--')) usage(`${name} needs a value`); return v; };
- for (let i = 0; i < argv.length; i++) {
- const a = argv[i];
- if (a === '--live-db') out.liveDb = value(++i, a);
- else if (a === '--backup') out.backup = value(++i, a);
- else if (a === '--rollback') out.rollback = value(++i, a);
- else if (a === '--apply') out.apply = true;
- else if (a === '--dry-run') out.apply = false;
- else if (a === '-h' || a === '--help') usage();
- else usage(`unknown argument ${a}`);
- }
- if (out.rollback) {
- if (out.liveDb || out.backup) usage('--rollback takes only the backup file (and --apply)');
- if (!fs.existsSync(out.rollback)) usage(`no backup at ${out.rollback}`);
- return out;
- }
- if (!out.liveDb) usage('missing --live-db');
- if (!fs.existsSync(out.liveDb)) usage(`no Live database at ${out.liveDb}`);
- if (out.apply && !out.backup) usage('--apply needs --backup <file>: what the run creates is recorded there first');
- if (out.backup && fs.existsSync(out.backup)) usage(`${out.backup} exists already; pick a new backup file`);
- return out;
-}
-
-async function main() {
- const args = parseArgs(process.argv.slice(2));
- const config = require('../server/config');
- if (!config.oauth.clientSecret) usage('OV_OAUTH_CLIENT_SECRET is not set (Chat\'s service principal writes the records)');
- const migration = require('../server/prefs/from-live');
- if (args.rollback) {
- const report = await migration.rollback(args.rollback, { apply: args.apply });
- console.log(JSON.stringify(report, null, 2));
- return;
- }
- const { openSqlite } = require('./lib/sqlite');
- const liveDb = openSqlite(args.liveDb);
- let planned;
- try { planned = await migration.plan(liveDb); } finally { liveDb.close(); }
- const report = await migration.migrate(planned, { apply: args.apply, backup: args.backup, liveDbPath: args.liveDb, log: (m) => console.error(m) });
- console.log(JSON.stringify(report, null, 2));
- if (!args.apply) console.error('dry run: nothing written (add --apply --backup <file> to write)');
-}
-
-main().catch((err) => { console.error(`migrate-chat-preferences failed: ${err.message}`); process.exit(1); });
@@ -13,17 +13,13 @@
@@ -13,17 +13,13 @@
* them) is replayed against it; a service with sockets also replays the messages its clients send.
* them) is replayed against it; a service with sockets also replays the messages its clients send.
* Writes test/fixtures/n-1/client.json and worker.json; commit them.
* Writes test/fixtures/n-1/client.json and worker.json; commit them.
*
*
- * A release on PostgreSQL (plan T3: migrations/, openvibe-sdk/db) boots on a PGlite directory of its own; its
- * schema is recorded as the migrations it applied (name and text: append-only, so the test rebuilds exactly that
- * database), and its statements as PostgreSQL received them. A release on SQLite is recorded as before (that needs
- * better-sqlite3 in node_modules, which this checkout no longer installs).
+ * The release boots on PGlite; the recorder stores its migrations and PostgreSQL statements.
*/
*/
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const h = require('../test/n-1/harness');
const h = require('../test/n-1/harness');
const svc = require('../test/n-1/service');
const svc = require('../test/n-1/service');
-const { onPostgres } = require('../test/n-1/engine');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -32,15 +28,14 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -32,15 +28,14 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
const ref = process.argv[2] || 'HEAD';
const ref = process.argv[2] || 'HEAD';
const wt = h.worktree(ROOT, ref);
const wt = h.worktree(ROOT, ref);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-record-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-record-'));
- const pg = onPostgres(wt.dir);
- const dbPath = pg ? path.join(tmp, 'db', 'pglite') : path.join(tmp, 'db', `${svc.service}.db`);
+ const dbPath = path.join(tmp, 'db', 'pglite');
const dataDir = path.join(tmp, 'data');
const dataDir = path.join(tmp, 'data');
const sqlOut = path.join(tmp, 'sql.json');
const sqlOut = path.join(tmp, 'sql.json');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
let server = null;
let server = null;
try {
try {
- console.log(`n-1:record: ${svc.service} at ${wt.sha.slice(0, 12)} (${ref}), on ${pg ? 'PostgreSQL' : 'SQLite'}`);
+ console.log(`n-1:record: ${svc.service} at ${wt.sha.slice(0, 12)} (${ref}), on PostgreSQL`);
// 1. The client: its calls, and the names it reads.
// 1. The client: its calls, and the names it reads.
const files = svc.clientFiles(wt.dir);
const files = svc.clientFiles(wt.dir);
@@ -108,7 +103,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -108,7 +103,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };
const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };
const clients = svc.clientReleases ? svc.clientReleases() : null;
const clients = svc.clientReleases ? svc.clientReleases() : null;
const writeClient = () => fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);
const writeClient = () => fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);
- if (pg) {
+ {
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
const db = createDb({ pglite: dbPath, service: 'n-1-record', log: { warn() {}, error: console.error } });
const db = createDb({ pglite: dbPath, service: 'n-1-record', log: { warn() {}, error: console.error } });
try {
try {
@@ -131,28 +126,6 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -131,28 +126,6 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
} finally { await db.close().catch(() => {}); }
} finally { await db.close().catch(() => {}); }
return;
return;
}
}
- const Database = require('better-sqlite3');
- const db = new Database(dbPath);
- const schema = h.schemaDDL(db);
- const ledger = {};
- for (const t of svc.ledgerTables || []) {
- try { ledger[t] = db.prepare(`SELECT * FROM "${t}"`).all(); } catch { /* not there */ }
- }
- const userVersion = db.pragma('user_version', { simple: true });
- const serverFiles = h.readTree(wt.dir, svc.sqlDirs);
- // What N-1 creates itself on first use or at a full boot: in production it exists before N deploys.
- const lazy = h.lazyDDL(serverFiles, db);
- const ran = fs.existsSync(sqlOut) ? JSON.parse(fs.readFileSync(sqlOut, 'utf8')) : [];
- const candidates = [...new Set([...ran.map(h.normalizeSql).filter((s) => /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i.test(s)), ...h.sqlLiterals(serverFiles)])].sort();
- const failing = new Set(h.prepareProblems(db, candidates).map((p) => p.sql));
- const statements = candidates.filter((s) => !failing.has(s));
- db.close();
- console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${schema.length} schema objects, ${lazy.length} created on first use`);
-
- fs.mkdirSync(OUT, { recursive: true });
- writeClient();
- fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);
- console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
} finally {
} finally {
if (server) await server.close();
if (server) await server.close();
wt.remove();
wt.remove();
deleted file mode 100644
deleted file mode 100644
@@ -1,77 +0,0 @@
@@ -1,77 +0,0 @@
-#!/usr/bin/env node
-/**
- * Compare what Live and Chat answer for the same chat reads (cutover rehearsal and flip checks).
- *
- * node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 \
- * [--before "2026-09-22 10:00:00"] [--stream 123]... [--channel 45]... [--token <jwt>]
- *
- * --before pins history pages to messages older than the snapshot, so rows written since do not
- * count as differences. --token (a Network JWT of a test account) adds the DM routes. Prints one
- * line per path (same / DIFFERENT with the first differing keys) and exits 1 on any difference.
- * Fields that legitimately move between two reads (latest_id of a room that grew, request ids)
- * are ignored.
- */
-'use strict';
-
-function parseArgs(argv) {
- const o = { streams: [], channels: [] };
- for (let i = 0; i < argv.length; i++) {
- const a = argv[i], v = argv[i + 1];
- if (a === '--live') { o.live = v; i++; } else if (a === '--chat') { o.chat = v; i++; } else if (a === '--before') { o.before = v; i++; } else if (a === '--stream') { o.streams.push(v); i++; } else if (a === '--channel') { o.channels.push(v); i++; } else if (a === '--token') { o.token = v; i++; } else throw new Error(`unknown argument ${a}`);
- }
- if (!o.live || !o.chat) throw new Error('--live and --chat are required');
- return o;
-}
-
-const IGNORE = new Set(['request_id', 'trace_id', 'latest_id']);
-function diff(a, b, at = '', out = []) {
- if (out.length > 5) return out;
- if (typeof a !== typeof b || Array.isArray(a) !== Array.isArray(b)) { out.push(`${at || '/'}: ${JSON.stringify(a)?.slice(0, 60)} ≠ ${JSON.stringify(b)?.slice(0, 60)}`); return out; }
- if (Array.isArray(a)) {
- if (a.length !== b.length) out.push(`${at}: length ${a.length} ≠ ${b.length}`);
- for (let i = 0; i < Math.min(a.length, b.length); i++) diff(a[i], b[i], `${at}[${i}]`, out);
- return out;
- }
- if (a && typeof a === 'object') {
- for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { if (!IGNORE.has(k)) diff(a[k], b[k], `${at}.${k}`, out); }
- return out;
- }
- if (a !== b) out.push(`${at}: ${JSON.stringify(a)} ≠ ${JSON.stringify(b)}`);
- return out;
-}
-
-async function get(base, p, token) {
- const res = await fetch(`${base.replace(/\/+$/, '')}${p}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
- let body = null; try { body = await res.json(); } catch { /* */ }
- return { status: res.status, body };
-}
-
-async function main() {
- const o = parseArgs(process.argv.slice(2));
- const before = o.before ? `&before=${encodeURIComponent(o.before)}` : '';
- const paths = [
- `/api/chat/global/history?limit=200${before}`,
- '/api/chat/filters/friendly',
- '/api/chat/gif/providers',
- '/api/tts/settings',
- '/api/tts/voices',
- ...o.streams.map((s) => `/api/chat/${s}/history?limit=200${before}`),
- ...o.streams.map((s) => `/api/sounds/all/${s}`),
- ...o.channels.map((c) => `/api/chat/channel/${c}/history?limit=200${before}`),
- ...o.channels.map((c) => `/api/sounds/channel/${c}`),
- ];
- const authed = o.token ? ['/api/dm/conversations', '/api/dm/unread', '/api/dm/blocks'] : [];
- let bad = 0;
- for (const p of [...paths, ...authed]) {
- const tok = authed.includes(p) ? o.token : null;
- const [a, b] = await Promise.all([get(o.live, p, tok), get(o.chat, p, tok)]);
- const d = a.status !== b.status ? [`status ${a.status} ≠ ${b.status}`] : diff(a.body, b.body);
- if (d.length) { bad++; console.log(`DIFFERENT ${p}\n ${d.join('\n ')}`); } else console.log(`same ${p}`);
- }
- console.log(bad ? `\n${bad} path(s) differ` : '\nall paths answer the same');
- process.exit(bad ? 1 : 0);
-}
-
-if (require.main === module) main().catch((err) => { console.error(err.message); process.exit(2); });
-
-module.exports = { parseArgs };
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
* OpenVibe.Chat — HTTP app and WebSocket upgrade handling.
* OpenVibe.Chat — HTTP app and WebSocket upgrade handling.
*
*
* Browsers reach Chat on Live's origin: nginx sends /ws/chat and the chat REST prefixes
* Browsers reach Chat on Live's origin: nginx sends /ws/chat and the chat REST prefixes
- * (/api/chat/, /api/dm/, /api/tts/, /api/sounds) here (docs/cutover.md), and at the calls cutover
+ * (/api/chat/, /api/dm/, /api/tts/, /api/sounds) here, and at the calls cutover
* /ws/call and the call routes under /api/streams (docs/calls-cutover.md), so the guards Live ran
* /ws/call and the call routes under /api/streams (docs/calls-cutover.md), so the guards Live ran
* in front of those routes run here too, with Live's values: credentialed CORS for an explicit
* in front of those routes run here too, with Live's values: credentialed CORS for an explicit
* list of origins (never a wildcard), the /api rate limit, IP/network bans (admins exempt) and the ov_banned cookie, the
* list of origins (never a wildcard), the /api rate limit, IP/network bans (admins exempt) and the ov_banned cookie, the
@@ -191,5 +191,5 @@ module.exports = {
@@ -191,5 +191,5 @@ module.exports = {
maxPerUploaderPerChannel: int(process.env.MAX_SOUNDS_PER_UPLOADER_PER_CHANNEL, 10),
maxPerUploaderPerChannel: int(process.env.MAX_SOUNDS_PER_UPLOADER_PER_CHANNEL, 10),
},
},
// Where Chat keeps its own caches (101soundboards clips, TTS preview clips).
// Where Chat keeps its own caches (101soundboards clips, TTS preview clips).
- cacheDir: process.env.CHAT_CACHE_DIR || path.join(path.dirname(process.env.CHAT_DB_PATH || './data/chat.db'), 'cache'),
+ cacheDir: process.env.CHAT_CACHE_DIR || path.join('data', 'cache'),
};
};
@@ -85,8 +85,7 @@ function getDb() {
@@ -85,8 +85,7 @@ function getDb() {
/** Tests and tools: use this handle as the process-wide database (null forgets it). */
/** Tests and tools: use this handle as the process-wide database (null forgets it). */
function setDb(handle) { db = handle; _stmts.clear(); }
function setDb(handle) { db = handle; _stmts.clear(); }
-// An INSERT into a table with an `id` column returns it, so run() answers { changes, lastInsertRowid } as
-// better-sqlite3 did and no caller changes shape (decision: RETURNING in place of lastInsertRowid).
+// An INSERT into a table with an `id` column returns it, preserving the run() result shape.
const INSERT_INTO = /^\s*INSERT\s+INTO\s+([a-z_]+)/i;
const INSERT_INTO = /^\s*INSERT\s+INTO\s+([a-z_]+)/i;
function withReturning(sql) {
function withReturning(sql) {
if (/\bRETURNING\b/i.test(sql)) return sql;
if (/\bRETURNING\b/i.test(sql)) return sql;
@@ -104,7 +103,7 @@ function stmt(sql) {
@@ -104,7 +103,7 @@ function stmt(sql) {
return s;
return s;
}
}
-/** → { changes, lastInsertRowid }, better-sqlite3's shape (callers pass it on, some into answers). */
+/** → { changes, lastInsertRowid } (callers pass it on, some into answers). */
async function run(sql, params = []) {
async function run(sql, params = []) {
const r = await stmt(sql).run(...(Array.isArray(params) ? params : [params]));
const r = await stmt(sql).run(...(Array.isArray(params) ? params : [params]));
return { changes: r.changes, lastInsertRowid: r.lastInsertRowid };
return { changes: r.changes, lastInsertRowid: r.lastInsertRowid };
deleted file mode 100644
deleted file mode 100644
@@ -1,152 +0,0 @@
@@ -1,152 +0,0 @@
-/**
- * One-off migration of the chat preferences Live kept server-side into the Network user module
- * chat.preferences (scripts/migrate-chat-preferences.js is the command line).
- *
- * Source: Live's user_preferences.chat_settings — the browser's whole chatSettings object, synced by
- * public/js/chat.js through PUT /api/auth/preferences — and the person's Network subject from Live's
- * linked_accounts (as scripts/import-from-live.js does). Live's database is opened read-only.
- *
- * What moves (chat.preferences v1 has five fields; the rest of chatSettings has no field yet):
- * showTimestamps true → timestamps: true
- * compactMode true → compact: true
- * fontSize small | large → font_scale 0.88 | 1.18 (Live's .chat-msg 0.75rem / 1rem over its 0.85rem)
- * showBadges false → show_badges: false
- * Only choices move: a value equal to Live's default (CHAT_SETTINGS_DEFAULTS) is not written, and a
- * person with nothing but defaults gets no record.
- *
- * Writes go through Network with Chat's service token (never into Network's database), create-only
- * (If-Match: 0): a person who already has a record keeps it, so a second run changes nothing. Before
- * writing, the backup file records what each target looked like (always "no record" for what gets
- * created) and what will be written; after, the revision each creation got. rollback() deletes the
- * records a run created, only while each is still at that revision (If-Match).
- */
-'use strict';
-
-const fs = require('fs');
-const path = require('path');
-const { modules } = require('openvibe-contracts');
-const client = require('./network-modules');
-
-const NAMESPACE = 'chat.preferences';
-const FONT_SCALE = Object.freeze({ small: 0.88, large: 1.18 });
-const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-
-/** Live's chatSettings → chat.preferences data (choices only). */
-function fromLiveChatSettings(settings) {
- const s = settings && typeof settings === 'object' && !Array.isArray(settings) ? settings : {};
- const out = {};
- if (s.showTimestamps === true) out.timestamps = true;
- if (s.compactMode === true) out.compact = true;
- if (Object.prototype.hasOwnProperty.call(FONT_SCALE, s.fontSize)) out.font_scale = FONT_SCALE[s.fontSize];
- if (s.showBadges === false) out.show_badges = false;
- return out;
-}
-
-/** Read Live's rows and decide what each becomes. Returns { rows: [...], counts }. No network. */
-function plan(liveDb) {
- const has = (t) => !!liveDb.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t);
- if (!has('user_preferences')) throw new Error('Live database has no user_preferences table');
- const laCols = has('linked_accounts') ? liveDb.prepare('PRAGMA table_info(linked_accounts)').all().map((c) => c.name) : [];
- const subjectSql = laCols.includes('subject_id')
- ? `(SELECT la.subject_id FROM linked_accounts la WHERE la.user_id = up.user_id AND la.subject_id IS NOT NULL
- ORDER BY (la.service = 'network') DESC, la.id LIMIT 1)`
- : 'NULL';
- const rows = liveDb.prepare(`SELECT up.user_id, up.chat_settings, up.updated_at, ${subjectSql} AS subject_id
- FROM user_preferences up ORDER BY up.user_id`).all();
- const counts = { live_rows: rows.length, unreadable: 0, no_subject: 0, defaults_only: 0, invalid: 0, candidates: 0, fields: {} };
- const out = [];
- for (const r of rows) {
- let settings;
- try { settings = JSON.parse(r.chat_settings || '{}'); } catch { counts.unreadable++; continue; }
- if (!USER_SUBJECT.test(String(r.subject_id || ''))) { counts.no_subject++; continue; }
- const data = fromLiveChatSettings(settings);
- if (!Object.keys(data).length) { counts.defaults_only++; continue; }
- if (!modules.validateData(NAMESPACE, data).valid) { counts.invalid++; continue; }
- for (const k of Object.keys(data)) counts.fields[k] = (counts.fields[k] || 0) + 1;
- counts.candidates++;
- out.push({ live_user_id: r.user_id, subject: r.subject_id, live_updated_at: r.updated_at || null, data });
- }
- return { rows: out, counts };
-}
-
-/** The current record of each candidate on Network: null (none), the record, or throws when unreachable. */
-async function current(subject) {
- const r = await client.request('GET', NAMESPACE, subject);
- if (r.status === 200 && r.body) return { revision: r.body.revision, data: r.body.data, updated_by: r.body.updated_by || null };
- if (r.status === 404 && r.body && r.body.code === 'modules.not_found') return null;
- throw new Error(`reading ${subject} on Network: ${r.status}${r.error ? ` ${r.error}` : ''}${r.body && r.body.code ? ` ${r.body.code}` : ''}`);
-}
-
-function writeJsonAtomic(file, value) {
- const tmp = `${file}.tmp-${process.pid}`;
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
- fs.renameSync(tmp, file);
-}
-
-/**
- * Check every candidate against Network; with apply, write the backup first, then create the records.
- * Returns the report. `backup` must name a file that does not exist yet (required with apply).
- */
-async function migrate(planned, { apply = false, backup = null, liveDbPath = null, log = () => {} } = {}) {
- if (apply && !backup) throw new Error('--apply needs --backup <file>');
- const report = { mode: apply ? 'apply' : 'dry-run', ...planned.counts, exists: 0, would_create: 0, created: 0, raced: 0, refused: 0 };
- const targets = [];
- for (const row of planned.rows) {
- const before = await current(row.subject);
- if (before) { report.exists++; continue; }
- targets.push({ ...row, before: null });
- }
- report.would_create = targets.length;
- if (!apply) return report;
-
- const file = path.resolve(backup);
- if (fs.existsSync(file)) throw new Error(`${file} exists already; pick a new backup file`);
- const doc = { kind: 'openvibe-chat/chat-preferences-migration', version: 1, namespace: NAMESPACE, created_at: new Date().toISOString(),
- live_db: liveDbPath, targets: targets.map((t) => ({ live_user_id: t.live_user_id, subject: t.subject, before: null, data: t.data })), results: null };
- writeJsonAtomic(file, doc);
- const check = JSON.parse(fs.readFileSync(file, 'utf8'));
- if (!check || !Array.isArray(check.targets) || check.targets.length !== targets.length) throw new Error(`backup ${file} did not read back; nothing written`);
- log(`backup written: ${file} (${targets.length} target(s))`);
-
- const results = [];
- for (const t of targets) {
- const r = await client.request('PUT', NAMESPACE, t.subject, { data: t.data, revision: 0 });
- if (r.status === 201 && r.body) { report.created++; results.push({ subject: t.subject, outcome: 'created', revision: r.body.revision }); }
- else if (r.status === 412) { report.raced++; results.push({ subject: t.subject, outcome: 'exists' }); }
- else if (r.status === 0) {
- doc.results = results; writeJsonAtomic(file, doc);
- throw new Error(`Network became unreachable after ${results.length} write(s): ${r.error}; the backup lists what was done, re-run to continue`);
- } else { report.refused++; results.push({ subject: t.subject, outcome: 'refused', status: r.status, code: (r.body && r.body.code) || null }); }
- }
- doc.results = results;
- doc.finished_at = new Date().toISOString();
- writeJsonAtomic(file, doc);
- return report;
-}
-
-/**
- * Undo a run from its backup: delete each record it created, only while still at the revision it got
- * (for a run that stopped before recording results: only while the data is exactly what it wrote).
- */
-async function rollback(file, { apply = false } = {}) {
- const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
- if (!doc || doc.kind !== 'openvibe-chat/chat-preferences-migration' || !Array.isArray(doc.targets)) throw new Error(`${file} is not a chat-preferences migration backup`);
- const byResult = new Map((doc.results || []).map((r) => [r.subject, r]));
- const report = { mode: apply ? 'apply' : 'dry-run', targets: doc.targets.length, deleted: 0, would_delete: 0, gone: 0, changed_since: 0, not_created: 0 };
- for (const t of doc.targets) {
- const res = byResult.get(t.subject);
- if (res && res.outcome !== 'created') { report.not_created++; continue; }
- const cur = await current(t.subject);
- if (!cur) { report.gone++; continue; }
- const ours = res ? cur.revision === res.revision : JSON.stringify(cur.data) === JSON.stringify(t.data) && cur.updated_by === 'svc:chat';
- if (!ours) { report.changed_since++; continue; }
- if (!apply) { report.would_delete++; continue; }
- const r = await client.request('DELETE', NAMESPACE, t.subject, { revision: cur.revision });
- if (r.status === 204) report.deleted++;
- else if (r.status === 412 || r.status === 404) report.changed_since++;
- else throw new Error(`deleting ${t.subject}: Network answered ${r.status}${r.error ? ` ${r.error}` : ''}`);
- }
- return report;
-}
-
-module.exports = { NAMESPACE, FONT_SCALE, fromLiveChatSettings, plan, migrate, rollback };
deleted file mode 100644
deleted file mode 100644
@@ -1,26 +0,0 @@
@@ -1,26 +0,0 @@
-'use strict';
-/**
- * The T3 closeout record must exist, keep its checks, and the README must keep describing the
- * database as PostgreSQL (plan T3; a silent re-draft would lose the evidence). Fails loudly if either regresses.
- */
-const assert = require('assert');
-const fs = require('fs');
-const path = require('path');
-const { suite } = require('./helpers');
-
-const t = suite('cutover-evidence');
-const ROOT = path.join(__dirname, '..');
-
-t('docs/cutover-evidence-t3.md records the T3 checks', () => {
- const md = fs.readFileSync(path.join(ROOT, 'docs', 'cutover-evidence-t3.md'), 'utf8');
- for (const key of ['health chat', 'DATABASE_URL', 'Import and row parity', 'Valkey ACL', 'Reproduce']) {
- assert.ok(md.includes(key), `docs/cutover-evidence-t3.md is missing "${key}"`);
- }
-});
-
-t('README.md describes Chat\'s database as PostgreSQL', () => {
- const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8');
- assert.ok(readme.includes('Own database on PostgreSQL'), 'README.md no longer says "Own database on PostgreSQL"');
-});
-
-t.run();
deleted file mode 100644
deleted file mode 100644
@@ -1,373 +0,0 @@
@@ -1,373 +0,0 @@
-CREATE TABLE "chat_messages" (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- stream_id INTEGER,
- user_id INTEGER, -- NULL for anon
- anon_id TEXT, -- 'anon12345' format
- username TEXT,
- message TEXT NOT NULL,
- message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip')),
- metadata TEXT, -- JSON sidecar for rich events (donation/goal-reached)
- is_global INTEGER DEFAULT 0,
- is_deleted INTEGER DEFAULT 0,
- is_filtered INTEGER DEFAULT 0,
- reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL,
- source_platform TEXT,
- deleted_by INTEGER,
- deleted_at DATETIME,
- auto_delete_at DATETIME,
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, channel_user_id INTEGER,
- FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
-);
-CREATE INDEX idx_chat_stream_id ON chat_messages(stream_id);
-CREATE INDEX idx_chat_timestamp ON chat_messages(timestamp);
-CREATE INDEX idx_chat_user_id ON chat_messages(user_id);
-CREATE INDEX idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp);
-CREATE INDEX idx_chat_stream_ts ON chat_messages(stream_id, timestamp);
-CREATE INDEX idx_chat_autodelete ON chat_messages(auto_delete_at);
-CREATE INDEX idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted);
-CREATE TABLE dm_conversations (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT,
- is_group INTEGER DEFAULT 0,
- created_by INTEGER NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
- );
-CREATE TABLE dm_participants (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- conversation_id INTEGER NOT NULL,
- user_id INTEGER NOT NULL,
- last_read_at DATETIME DEFAULT '1970-01-01 00:00:00',
- joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(conversation_id, user_id),
- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
-CREATE INDEX idx_dm_participants_conv ON dm_participants(conversation_id);
-CREATE INDEX idx_dm_participants_user ON dm_participants(user_id);
-CREATE TABLE dm_messages (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- conversation_id INTEGER NOT NULL,
- sender_id INTEGER NOT NULL,
- message TEXT NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,
- FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE
- );
-CREATE INDEX idx_dm_messages_conv ON dm_messages(conversation_id, created_at);
-CREATE INDEX idx_dm_messages_sender ON dm_messages(sender_id);
-CREATE TABLE dm_blocks (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- blocker_id INTEGER NOT NULL,
- blocked_id INTEGER NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(blocker_id, blocked_id),
- FOREIGN KEY (blocker_id) REFERENCES users(id) ON DELETE CASCADE,
- FOREIGN KEY (blocked_id) REFERENCES users(id) ON DELETE CASCADE
- );
-CREATE INDEX idx_dm_blocks_blocker ON dm_blocks(blocker_id);
-CREATE INDEX idx_dm_blocks_blocked ON dm_blocks(blocked_id);
-CREATE TABLE tts_voice_overrides (
- identity_key TEXT PRIMARY KEY,
- voice TEXT,
- pitch INTEGER,
- speed INTEGER,
- gap INTEGER DEFAULT 0,
- set_by INTEGER,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-CREATE TABLE "emotes" (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- code TEXT NOT NULL,
- url TEXT NOT NULL,
- animated INTEGER DEFAULT 0,
- width INTEGER DEFAULT 28,
- height INTEGER DEFAULT 28,
- is_global INTEGER DEFAULT 0,
- is_approved INTEGER DEFAULT 1,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- channel_owner_id INTEGER,
- size INTEGER DEFAULT 100,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
-CREATE INDEX idx_emotes_user ON emotes(user_id);
-CREATE INDEX idx_emotes_global ON emotes(is_global);
-CREATE INDEX idx_emotes_code ON emotes(code);
-CREATE INDEX idx_emotes_channel_owner ON emotes(channel_owner_id);
-CREATE UNIQUE INDEX idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code);
-CREATE TABLE channel_sounds (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- channel_owner_id INTEGER NOT NULL, -- streamer whose channel this sound belongs to
- command TEXT NOT NULL, -- trigger word (without leading '!'), lowercased
- url TEXT NOT NULL, -- served path of the audio file on disk
- mime TEXT DEFAULT 'audio/mpeg',
- duration_seconds REAL DEFAULT 0,
- created_by INTEGER, -- uploader user id (NULL if removed user)
- created_by_name TEXT DEFAULT '',
- is_approved INTEGER DEFAULT 1,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP, emote_code TEXT DEFAULT '',
- -- Multiple sounds may share a command; playback picks one at random.
- FOREIGN KEY (channel_owner_id) REFERENCES users(id) ON DELETE CASCADE
-);
-CREATE INDEX idx_channel_sounds_owner ON channel_sounds(channel_owner_id);
-CREATE INDEX idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);
-CREATE TABLE media_requests (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- streamer_id INTEGER NOT NULL,
- stream_id INTEGER,
- user_id INTEGER NOT NULL,
- username TEXT NOT NULL,
- input TEXT NOT NULL,
- canonical_url TEXT NOT NULL,
- embed_url TEXT,
- provider TEXT NOT NULL CHECK(provider IN ('youtube', 'vimeo', 'audio', 'video')),
- title TEXT NOT NULL,
- thumbnail_url TEXT,
- duration_seconds INTEGER,
- cost INTEGER NOT NULL DEFAULT 25,
- currency TEXT DEFAULT 'opencoins',
- queue_position INTEGER DEFAULT 0,
- status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'playing', 'played', 'skipped', 'removed', 'failed')),
- requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- started_at DATETIME,
- ended_at DATETIME,
- last_error TEXT, stream_url TEXT, download_status TEXT DEFAULT 'none' CHECK(download_status IN ('none','extracting','downloading','ready','failed')), file_path TEXT, playback_position REAL DEFAULT 0, refunded INTEGER DEFAULT 0,
- FOREIGN KEY (streamer_id) REFERENCES users(id) ON DELETE CASCADE,
- FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE SET NULL,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-CREATE INDEX idx_media_requests_streamer_status ON media_requests(streamer_id, status, queue_position, requested_at);
-CREATE INDEX idx_media_requests_user_status ON media_requests(user_id, status, requested_at);
-CREATE INDEX idx_media_requests_canonical ON media_requests(streamer_id, canonical_url, status);
-CREATE TABLE media_request_settings (
- user_id INTEGER PRIMARY KEY,
- enabled INTEGER DEFAULT 1,
- request_cost INTEGER DEFAULT 25,
- max_per_user INTEGER DEFAULT 3,
- max_duration_seconds INTEGER DEFAULT 600,
- allow_youtube INTEGER DEFAULT 1,
- allow_vimeo INTEGER DEFAULT 1,
- allow_direct_media INTEGER DEFAULT 1,
- auto_advance INTEGER DEFAULT 1,
- currency TEXT DEFAULT 'opencoins' CHECK(currency IN ('free','vibes','opencoins','points')),
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, cost_mode TEXT DEFAULT 'flat' CHECK(cost_mode IN ('flat','per_minute')), cost_per_minute INTEGER DEFAULT 5, allow_live INTEGER DEFAULT 0, download_mode TEXT DEFAULT 'stream' CHECK(download_mode IN ('stream','download')),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-);
-CREATE TABLE moderation_actions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- scope_type TEXT NOT NULL DEFAULT 'site',
- scope_id INTEGER,
- actor_user_id INTEGER,
- target_user_id INTEGER,
- action_type TEXT NOT NULL,
- details TEXT DEFAULT '{}',
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
- FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL
- );
-CREATE INDEX idx_mod_actions_created ON moderation_actions(created_at DESC);
-CREATE INDEX idx_mod_actions_actor ON moderation_actions(actor_user_id);
-CREATE INDEX idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);
-CREATE TABLE channel_moderators (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- channel_id INTEGER NOT NULL,
- user_id INTEGER NOT NULL,
- added_by INTEGER NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(channel_id, user_id),
- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
- FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL
-);
-CREATE INDEX idx_channel_mods_channel ON channel_moderators(channel_id);
-CREATE INDEX idx_channel_mods_user ON channel_moderators(user_id);
-CREATE TABLE channel_moderation_settings (
- channel_id INTEGER PRIMARY KEY,
- slow_mode_seconds INTEGER DEFAULT 0,
- followers_only INTEGER DEFAULT 0,
- emote_only INTEGER DEFAULT 0,
- allow_anonymous INTEGER DEFAULT 1,
- links_allowed INTEGER DEFAULT 1,
- gifs_enabled INTEGER DEFAULT 1,
- account_age_gate_hours INTEGER DEFAULT 0,
- caps_percentage_limit INTEGER DEFAULT 0,
- aggressive_filter INTEGER DEFAULT 0,
- max_message_length INTEGER DEFAULT 500,
- slur_filter_enabled INTEGER DEFAULT 0,
- slur_filter_use_builtin INTEGER DEFAULT 1,
- slur_filter_terms TEXT DEFAULT '',
- slur_filter_regexes TEXT DEFAULT '',
- slur_filter_nudge_message TEXT DEFAULT '',
- slur_filter_disabled_categories TEXT DEFAULT '[]',
- ip_approval_mode INTEGER DEFAULT 0,
- soundboard_enabled INTEGER DEFAULT 1,
- soundboard_allow_pitch INTEGER DEFAULT 1,
- soundboard_allow_speed INTEGER DEFAULT 1,
- soundboard_banned_ids TEXT DEFAULT '',
- viewer_auto_delete_enabled INTEGER DEFAULT 1,
- viewer_delete_all_enabled INTEGER DEFAULT 1,
- custom_emotes_enabled INTEGER DEFAULT 1, -- viewers may upload gif/png emotes for this channel
- custom_sounds_enabled INTEGER DEFAULT 1, -- viewers may upload !sound commands for this channel
- max_sound_seconds INTEGER DEFAULT 10, -- max duration of an uploaded channel sound
- uploads_mods_only INTEGER DEFAULT 0, -- restrict emote/sound uploads to channel mods
- mods_can_edit_about INTEGER DEFAULT 0, -- allow channel mods to edit the streamer's About/panels
- donation_sound_url TEXT, -- on-disk path to the streamer's donation alert sound
- donation_sound_mime TEXT,
- goal_sound_url TEXT, -- optional override sound for goal-reached
- goal_sound_mime TEXT,
- emote_scale INTEGER DEFAULT 100, -- emote display size in chat, percent (50-300)
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, tts_max_length INTEGER DEFAULT 200, sound_min_speed REAL DEFAULT 0.5, sound_max_speed REAL DEFAULT 3.0, sound_min_pitch_cents INTEGER DEFAULT -1200, sound_max_pitch_cents INTEGER DEFAULT 1200, emote_size_min INTEGER DEFAULT 50, emote_size_max INTEGER DEFAULT 200, sounds_mods_only INTEGER DEFAULT 0,
- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
-);
-CREATE TABLE relay_users (
- platform TEXT NOT NULL,
- username TEXT NOT NULL,
- display_name TEXT,
- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
- message_count INTEGER DEFAULT 0,
- PRIMARY KEY (platform, username)
- );
-CREATE TABLE hidden_relay_users (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- channel_id INTEGER,
- platform TEXT NOT NULL,
- external_username TEXT NOT NULL,
- action TEXT DEFAULT 'hide' CHECK(action IN ('hide','ban')),
- reason TEXT,
- created_by INTEGER,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
- );
-CREATE INDEX idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform);
-CREATE TABLE pending_ip_messages (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- channel_id INTEGER NOT NULL,
- stream_id INTEGER,
- ip_address TEXT NOT NULL,
- user_id INTEGER,
- anon_id TEXT,
- username TEXT,
- message TEXT NOT NULL,
- status TEXT DEFAULT 'pending' CHECK(status IN ('pending','approved','denied')),
- reviewed_by INTEGER,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
- FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL
- );
-CREATE INDEX idx_pending_ip_channel ON pending_ip_messages(channel_id, status);
-CREATE TABLE stream_first_chats (
- chatter_key TEXT NOT NULL,
- channel_user_id INTEGER NOT NULL,
- first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (chatter_key, channel_user_id)
- );
-CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id);
-CREATE TABLE chat_ai_summaries (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- scope TEXT NOT NULL,
- subject_id INTEGER NOT NULL DEFAULT 0,
- window TEXT NOT NULL,
- overview TEXT DEFAULT '',
- memory_json TEXT DEFAULT '',
- timeline_json TEXT DEFAULT '[]',
- message_count INTEGER DEFAULT 0,
- window_message_count INTEGER DEFAULT 0,
- last_message_id INTEGER DEFAULT 0,
- window_label TEXT DEFAULT '',
- window_start DATETIME,
- window_end DATETIME,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(scope, subject_id, window)
- );
-CREATE INDEX idx_chat_ai_scope ON chat_ai_summaries(scope, subject_id, window);
-CREATE TABLE chat_timeline_events (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- scope TEXT NOT NULL DEFAULT 'global',
- subject_id INTEGER NOT NULL DEFAULT 0,
- ts DATETIME NOT NULL,
- label TEXT NOT NULL,
- detail TEXT DEFAULT '',
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-CREATE INDEX idx_chat_tl_scope_ts ON chat_timeline_events(scope, subject_id, ts DESC);
-CREATE UNIQUE INDEX idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label);
-CREATE TABLE linked_accounts (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- service TEXT NOT NULL,
- service_user_id TEXT NOT NULL,
- service_username TEXT,
- linked_at DATETIME DEFAULT CURRENT_TIMESTAMP, subject_id TEXT,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
- UNIQUE(service, service_user_id)
- );
-CREATE INDEX idx_linked_subject ON linked_accounts(subject_id);
-CREATE INDEX idx_linked_service ON linked_accounts(service, service_user_id);
-CREATE INDEX idx_linked_user ON linked_accounts(user_id);
-CREATE TABLE managed_streams (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- channel_id INTEGER,
- slug TEXT, -- optional unique string ID (must contain non-numeric chars)
- title TEXT DEFAULT 'Untitled Stream',
- description TEXT DEFAULT '',
- category TEXT DEFAULT 'irl',
- tags TEXT DEFAULT '[]',
- protocol TEXT DEFAULT 'webrtc' CHECK(protocol IN ('jsmpeg', 'webrtc', 'rtmp')),
- stream_key TEXT UNIQUE NOT NULL, -- per-stream stable key (reusable across sessions)
- is_nsfw INTEGER DEFAULT 0,
- control_config_id INTEGER,
- sort_order INTEGER DEFAULT 0,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, slot_clip_recording_enabled INTEGER DEFAULT 1, slot_clip_notify_enabled INTEGER DEFAULT 1, slot_powerchat_relay INTEGER DEFAULT 1, slot_powerchat_count_rs_views INTEGER DEFAULT 1, broadcast_settings TEXT DEFAULT '{}', streaming_method TEXT DEFAULT 'browser', browser_mode TEXT DEFAULT 'camera', pip_source_msid INTEGER, pip_defaults TEXT DEFAULT '{}', default_vod_visibility TEXT DEFAULT 'public', default_clip_visibility TEXT DEFAULT 'public', slot_vod_recording_enabled INTEGER DEFAULT 1, weather_zip TEXT DEFAULT NULL, weather_detail TEXT DEFAULT 'basic', weather_show_location INTEGER DEFAULT 0, mic_only_image TEXT DEFAULT NULL,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
- FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,
- FOREIGN KEY (control_config_id) REFERENCES control_configs(id) ON DELETE SET NULL
-);
-CREATE INDEX idx_managed_streams_user ON managed_streams(user_id);
-CREATE INDEX idx_managed_streams_slug ON managed_streams(slug);
-CREATE INDEX idx_managed_streams_key ON managed_streams(stream_key);
-
-CREATE TABLE user_tags (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- tag_id TEXT NOT NULL,
- source TEXT DEFAULT 'shop',
- granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(user_id, tag_id)
- );
-CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, email TEXT, password_hash TEXT NOT NULL, display_name TEXT, avatar_url TEXT, role TEXT DEFAULT 'user', stream_key TEXT, is_banned INTEGER DEFAULT 0, ban_reason TEXT, profile_color TEXT DEFAULT '#8b5cf6', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_owner INTEGER DEFAULT 0);
-CREATE TABLE streams (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, channel_id INTEGER, managed_stream_id INTEGER, title TEXT, is_live INTEGER DEFAULT 0, started_at DATETIME, ended_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
-CREATE TABLE channels (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, title TEXT);
@@ -46,7 +46,7 @@ let db, close, ownerUrl;
@@ -46,7 +46,7 @@ let db, close, ownerUrl;
// On the real containers the serving role is DML-only and its ANALYZE is silently skipped (no column
// On the real containers the serving role is DML-only and its ANALYZE is silently skipped (no column
// statistics, so the planner picked a bitmap scan + Sort); the owner runs it here, as autovacuum and
// statistics, so the planner picked a bitmap scan + Sort); the owner runs it here, as autovacuum and
-// scripts/import-sqlite-to-pg.js do in production. On PGlite the one role owns everything.
+// On PGlite the one role owns everything.
async function analyze() {
async function analyze() {
if (!ownerUrl) { await db.query('ANALYZE chat_messages'); return; }
if (!ownerUrl) { await db.query('ANALYZE chat_messages'); return; }
const owner = createDb({ url: ownerUrl, service: 'chat-latency-owner', max: 1 });
const owner = createDb({ url: ownerUrl, service: 'chat-latency-owner', max: 1 });
deleted file mode 100644
deleted file mode 100644
@@ -1,81 +0,0 @@
@@ -1,81 +0,0 @@
-'use strict';
-/**
- * The SQLite → PostgreSQL import (plan T3, decision 10): a SQLite fixture is imported into a migrated
- * PGlite database, the per-table report is checked (rows, no problems), the imported rows are readable
- * and were never queued in the Live mirror, and a post-import insert gets an id past the imported max.
- */
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const { openSqlite } = require('../scripts/lib/sqlite');
-const { createDb } = require('openvibe-sdk/db');
-const { suite } = require('./helpers');
-const { main } = require('../scripts/import-sqlite-to-pg');
-
-const t = suite('import-pg');
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-import-'));
-const fixture = path.join(tmp, 'chat.db');
-const pgliteDir = path.join(tmp, 'pglite');
-
-async function buildFixture() {
- const d = openSqlite(fixture, { readonly: false, create: true });
- d.exec(`
- CREATE TABLE chat_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, username TEXT, message TEXT NOT NULL, timestamp TEXT DEFAULT CURRENT_TIMESTAMP);
- CREATE TABLE relay_users (platform TEXT NOT NULL, username TEXT NOT NULL, display_name TEXT, message_count INTEGER DEFAULT 0, PRIMARY KEY (platform, username));
- CREATE TABLE rooms (id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, owner_id INTEGER NOT NULL, message_count INTEGER NOT NULL DEFAULT 0);
- CREATE TABLE room_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL, user_id INTEGER NOT NULL, message TEXT NOT NULL);
- CREATE TABLE chat_meta (key TEXT PRIMARY KEY, value TEXT);
- CREATE TABLE emotes (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, code TEXT NOT NULL, url TEXT NOT NULL, channel_owner_id INTEGER);
- `);
- const msg = d.prepare('INSERT INTO chat_messages (user_id, username, message, timestamp) VALUES (?, ?, ?, ?)');
- for (let i = 1; i <= 120; i++) await msg.run(1000 + i, `user${i}`, `message ${i}`, '2026-09-29 10:00:00');
- await d.prepare('INSERT INTO relay_users (platform, username, display_name, message_count) VALUES (?, ?, ?, ?)').run('twitch', 'nightbot', 'Nightbot', 42);
- await d.prepare('INSERT INTO rooms (slug, name, owner_id, message_count) VALUES (?, ?, ?, ?)').run('general', 'General', 1001, 5);
- await d.prepare('INSERT INTO room_messages (room_id, user_id, message) VALUES (?, ?, ?)').run(1, 1001, 'hi room');
- await d.prepare('INSERT INTO chat_meta (key, value) VALUES (?, ?)').run('deploy_head', 'abc123');
- await d.prepare('INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (?, ?, ?, ?)').run(1001, 'kek', '/e/kek.png', 1001);
- d.close();
-}
-
-let report, db;
-t('import the fixture', async () => {
- await buildFixture();
- await main(['--sqlite', fixture, '--pglite', pgliteDir]);
- assert.strictEqual(process.exitCode, undefined, 'the import reported ok');
- db = createDb({ pglite: pgliteDir, service: 'chat-import-test' });
-});
-
-t('the report has every table', async () => {
- const runs = await db.prepare('SELECT counts FROM import_runs ORDER BY id DESC LIMIT 1').get();
- const counts = JSON.parse(runs.counts);
- assert.strictEqual(counts.ok, true);
- const byTable = Object.fromEntries(counts.tables.map((x) => [x.table, x.rows]));
- assert.strictEqual(byTable.chat_messages, 120, 'all messages imported');
- assert.strictEqual(byTable.relay_users, 1);
- assert.strictEqual(byTable.rooms, 1);
- assert.strictEqual(byTable.chat_meta, 1);
- assert.strictEqual(byTable.emotes, 1);
- assert.strictEqual(counts.problems.length, 0, 'no problems');
-});
-
-t('a well-known row is readable', async () => {
- const row = await db.prepare("SELECT message, username FROM chat_messages WHERE message = 'message 120'").get();
- assert.strictEqual(row.username, 'user120');
- const meta = await db.prepare('SELECT value FROM chat_meta WHERE key = ?').get('deploy_head');
- assert.strictEqual(meta.value, 'abc123');
-});
-
-t('imported rows were never queued for the Live mirror', async () => {
- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM live_mirror_outbox').get()).n, 0);
-});
-
-t('a post-import insert is past the imported max', async () => {
- const max = (await db.prepare('SELECT MAX(id) AS id FROM chat_messages').get()).id;
- const res = await db.prepare('INSERT INTO chat_messages (message) VALUES (?) RETURNING id').get('after import');
- assert.ok(res.id > max, `new id ${res.id} is past ${max}`);
-});
-
-t('cleanup', async () => { await db.close().catch(() => {}); fs.rmSync(tmp, { recursive: true, force: true }); });
-
-t.run();
deleted file mode 100644
deleted file mode 100644
@@ -1,172 +0,0 @@
@@ -1,172 +0,0 @@
-'use strict';
-/**
- * scripts/import-from-live.js against a fake Live snapshot built from Live's real table
- * definitions (test/fixtures/live-chat-schema.sql): counts per table, a dry run unless --apply (and
- * it writes nothing), --apply backs Chat's database up first, ids kept, Network subjects filled in,
- * id headroom, projections seeded, idempotent re-runs, Chat's edits win on chat tables (the six
- * tables Chat owns are not imported at all), conflicting rows and transient-table rows held (never
- * dropped), rows Live wrote after the first pass brought over, schema drift refused.
- */
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const { openSqlite } = require('../scripts/lib/sqlite');
-const db = require('../server/db/database');
-const { suite } = require('./helpers');
-
-const t = suite('import');
-const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-import-'));
-const liveDb = path.join(tmp, 'live-snapshot.db');
-const SUBJ = 'usr_01J9SUBJECTSUBJECTSUBJECTS';
-
-// The importer's command line (scripts/import-from-live.js cli), in this process on this test's database.
-async function importer(args) {
- const cwd = process.cwd();
- process.chdir(tmp); // the default backup lands in ./data of the working directory
- let r;
- try { r = await require('../scripts/import-from-live').cli(['--live-db', liveDb, ...args]); } finally { process.chdir(cwd); }
- let report = null;
- try { report = JSON.parse(r.stdout); } catch { /* */ }
- return { code: r.code, report, stderr: r.stderr, stdout: r.stdout };
-}
-/** Chat's database, read by this process. */
-const chatDb = () => db.getDb();
-const lastId = async (t) => Number((await chatDb().prepare("SELECT pg_sequence_last_value(pg_get_serial_sequence(?, 'id')) AS v").get(t)).v);
-
-let live;
-t('build a Live snapshot with every chat table', async () => {
- await db.initDb();
- live = openSqlite(liveDb, { readonly: false, create: true });
- live.pragma('foreign_keys = OFF');
- live.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'live-chat-schema.sql'), 'utf8'));
- live.exec('ALTER TABLE channel_sounds ADD COLUMN media_url TEXT; ALTER TABLE channel_sounds ADD COLUMN media_asset_id INTEGER;');
- const u = live.prepare("INSERT INTO users (id, username, password_hash, display_name, role) VALUES (?, ?, '!', ?, ?)");
- await u.run(1, 'streamer', 'Streamer', 'streamer'); await u.run(2, 'alice', 'Alice', 'user'); await u.run(3, 'bob', 'Bob', 'user');
- await live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '77', ?)").run(SUBJ);
- await live.prepare("INSERT INTO channels (id, user_id, title) VALUES (5, 1, 'TV')").run();
- await live.prepare("INSERT INTO managed_streams (id, user_id, channel_id, slug, title, stream_key) VALUES (9, 1, 5, 'main', 'Main', 'k')").run();
- await live.prepare("INSERT INTO streams (id, user_id, channel_id, managed_stream_id, title, is_live) VALUES (40, 1, 5, 9, 'Show', 1)").run();
- const msg = live.prepare('INSERT INTO chat_messages (id, stream_id, channel_user_id, user_id, anon_id, username, message, message_type, metadata, is_global, is_deleted, reply_to_id, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
- await msg.run(101, 40, 1, 2, null, 'Alice', 'first', 'chat', null, 0, 0, null, '2026-09-01 10:00:00');
- await msg.run(102, 40, 1, 3, null, 'Bob', 'reply', 'chat', null, 0, 0, 101, '2026-09-01 10:00:05');
- await msg.run(103, null, null, null, 'anon4', 'anon4', 'global hi', 'chat', null, 1, 0, null, '2026-09-01 10:01:00');
- await msg.run(104, 40, 1, null, null, 'Donor', 'tipped 5 Vibes', 'donation', '{"kind":"donation","amount":5}', 0, 0, null, '2026-09-01 10:02:00');
- await msg.run(105, 40, 1, 2, null, 'Alice', 'deleted one', 'chat', null, 0, 1, null, '2026-09-01 10:03:00');
- await live.prepare("INSERT INTO dm_conversations (id, created_by, created_at) VALUES (7, 2, '2026-09-02 00:00:00')").run();
- await live.prepare('INSERT INTO dm_participants (conversation_id, user_id) VALUES (7, 2), (7, 3)').run();
- await live.prepare("INSERT INTO dm_messages (id, conversation_id, sender_id, message, created_at) VALUES (70, 7, 2, 'psst', '2026-09-02 00:00:01')").run();
- await live.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id) VALUES (3, 1)').run();
- await live.prepare("INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed) VALUES ('user:alice', 'en+f3', 60, 170)").run();
- await live.prepare("INSERT INTO channel_sounds (id, channel_owner_id, command, url, created_by, media_url, media_asset_id) VALUES (11, 1, 'honk', '/opt/openvibe.live/data/sounds/snd-1.mp3', 2, 'https://openvibe.media/a/1', 555)").run();
- await live.prepare("INSERT INTO relay_users (platform, username, display_name, message_count) VALUES ('twitch', 'zed', 'Zed', 3)").run();
- await live.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username) VALUES (5, 'twitch', 'spammer')").run();
- await live.prepare("INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, username, message) VALUES (5, 40, '203.0.113.5', 'anon9', 'let me in')").run();
- await live.prepare("INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES ('user:2', 1)").run();
- await live.prepare("INSERT INTO moderation_actions (scope_type, actor_user_id, target_user_id, action_type, details) VALUES ('channel', 2, 3, 'channel_ban', '{}')").run();
- await live.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (5, 2, 1)').run();
- await live.prepare('INSERT INTO channel_moderation_settings (channel_id, slow_mode_seconds) VALUES (5, 3)').run();
- await live.prepare("INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (1, 'pog', '/e/pog.png', 1)").run();
- await live.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (2, 'og')").run();
- await live.prepare("INSERT INTO chat_ai_summaries (scope, subject_id, window, overview) VALUES ('user', 2, '7d', 'nice')").run();
- await live.prepare("INSERT INTO chat_timeline_events (scope, ts, label) VALUES ('global', '2026-09-01 10:00:00', 'hype')").run();
- await live.prepare("INSERT INTO media_requests (streamer_id, user_id, username, input, canonical_url, provider, title) VALUES (1, 2, 'Alice', 'x', 'https://youtu.be/x', 'youtube', 'X')").run();
- live.exec("CREATE TABLE chat_messages_new AS SELECT * FROM chat_messages WHERE id = 101");
-});
-
-t('dry run reports and writes nothing (the default; --dry-run says it too)', async () => {
- for (const args of [[], ['--dry-run']]) {
- const r = await importer(args);
- assert.strictEqual(r.code, 0, r.stderr);
- assert.strictEqual(r.report.dry_run, true);
- assert.strictEqual(r.report.backup, null);
- }
- const r = await importer([]);
- assert.strictEqual(r.report.tables.chat_messages.inserted, 5);
- assert.strictEqual(r.report.tables.chat_messages_new.held, 1);
- const c = chatDb();
- assert.strictEqual((await c.prepare('SELECT COUNT(*) AS n FROM chat_messages').get()).n, 0);
- assert.strictEqual((await c.prepare('SELECT COUNT(*) AS n FROM import_hold').get()).n, 0);
- assert.strictEqual((await c.prepare('SELECT COUNT(*) AS n FROM import_runs').get()).n, 0);
-});
-
-t('import copies every table with ids kept, subjects filled, headroom set', async () => {
- const r = await importer(['--apply', '--headroom', '1000']);
- assert.strictEqual(r.code, 0, r.stderr);
- assert.ok(r.report.backup && fs.existsSync(r.report.backup), 'backed up first');
- const b = JSON.parse(fs.readFileSync(r.report.backup, 'utf8'));
- assert.strictEqual(b.tables.chat_messages.length, 0, 'the backup is the database before the run');
- const T = r.report.tables;
- const expect = { chat_messages: 5, dm_conversations: 1, dm_participants: 2, dm_messages: 1, dm_blocks: 1, tts_voice_overrides: 1, channel_sounds: 1, relay_users: 1, hidden_relay_users: 1, pending_ip_messages: 1, stream_first_chats: 1, moderation_actions: 1 };
- for (const [tbl, n] of Object.entries(expect)) {
- assert.strictEqual(T[tbl].live, n, `${tbl} live`);
- assert.strictEqual(T[tbl].inserted, n, `${tbl} inserted`);
- }
- assert.strictEqual(T.media_requests.not_moved.startsWith('stays in Live'), true);
- assert.strictEqual(T.chat_messages_new.held, 1);
- assert.strictEqual(T.chat_messages.next_id, 105 + 1000 + 1);
- const c = chatDb();
- const m = await c.prepare('SELECT * FROM chat_messages WHERE id = 102').get();
- assert.strictEqual(m.reply_to_id, 101);
- assert.strictEqual(m.user_id, 3);
- assert.strictEqual((await c.prepare('SELECT subject_id FROM chat_messages WHERE id = 101').get()).subject_id, SUBJ);
- assert.strictEqual((await c.prepare('SELECT is_deleted FROM chat_messages WHERE id = 105').get()).is_deleted, 1, 'deleted rows are kept, still deleted');
- assert.strictEqual((await c.prepare('SELECT metadata FROM chat_messages WHERE id = 104').get()).metadata, '{"kind":"donation","amount":5}');
- assert.strictEqual((await c.prepare('SELECT sender_subject_id FROM dm_messages WHERE id = 70').get()).sender_subject_id, SUBJ);
- assert.strictEqual((await c.prepare('SELECT actor_subject_id FROM moderation_actions').get()).actor_subject_id, SUBJ);
- assert.strictEqual(await lastId('chat_messages'), 1105);
- const hold = await c.prepare('SELECT * FROM import_hold').all();
- assert.strictEqual(hold.length, 1);
- assert.strictEqual(hold[0].source_table, 'chat_messages_new');
- assert.match(hold[0].reason, /transient migration table/);
- assert.strictEqual((await c.prepare('SELECT username FROM ctx_users WHERE id = 2').get()).username, 'alice', 'projections seeded');
- assert.strictEqual((await c.prepare('SELECT subject_id FROM ctx_users WHERE id = 2').get()).subject_id, SUBJ);
- assert.strictEqual((await c.prepare('SELECT slug FROM ctx_managed_streams WHERE id = 9').get()).slug, 'main');
- assert.strictEqual((await c.prepare('SELECT COUNT(*) AS n FROM live_mirror_outbox').get()).n, 0, 'imported rows are never mirrored back to Live');
- assert.strictEqual((await c.prepare('SELECT COUNT(*) AS n FROM events_outbox').get()).n, 0, 'imported history is not re-announced');
-});
-
-t('re-running is idempotent', async () => {
- const r = await importer(['--apply', '--no-backup', '--headroom', '1000']);
- assert.strictEqual(r.report.backup, null);
- assert.strictEqual(r.code, 0, r.stderr);
- for (const [tbl, v] of Object.entries(r.report.tables)) {
- if (v.inserted !== undefined) assert.strictEqual(v.inserted, 0, `${tbl} inserted again`);
- }
- assert.strictEqual(r.report.tables.chat_messages.identical, 5);
- assert.strictEqual(r.report.held_total, 1, 'holds are not duplicated');
-});
-
-t('after the cutover: Chat’s edits win, conflicts are held, late Live rows come over', async () => {
- const c = chatDb();
- await c.prepare('UPDATE chat_messages SET is_deleted = 1 WHERE id = 101').run(); // a moderator deleted it in Chat
- await c.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (106, 3, 'Bob', 'written in chat')").run();
- await live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (106, 2, 'Alice', 'a different 106', '2026-09-03 00:00:00')").run();
- await live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (107, 2, 'Alice', 'written in Live after the first pass', '2026-09-03 00:00:01')").run();
- const r = await importer(['--apply', '--no-backup', '--headroom', '1000']);
- assert.strictEqual(r.code, 0, r.stderr);
- const T = r.report.tables;
- assert.strictEqual(T.chat_messages.chat_kept, 1);
- assert.strictEqual(T.chat_messages.held, 1);
- assert.strictEqual(T.chat_messages.inserted, 1);
- const d = chatDb();
- assert.strictEqual((await d.prepare('SELECT is_deleted FROM chat_messages WHERE id = 101').get()).is_deleted, 1);
- assert.strictEqual((await d.prepare('SELECT message FROM chat_messages WHERE id = 106').get()).message, 'written in chat');
- assert.strictEqual((await d.prepare('SELECT message FROM chat_messages WHERE id = 107').get()).message, 'written in Live after the first pass');
- const held = await d.prepare("SELECT * FROM import_hold WHERE source_table = 'chat_messages'").get();
- assert.match(held.reason, /different row/);
- assert.strictEqual(JSON.parse(held.row_json).message, 'a different 106');
- assert.strictEqual(await lastId('chat_messages'), 1107, 'headroom follows Live’s new max, never lowered');
-});
-
-t('schema drift is refused before anything is written', async () => {
- live.exec('ALTER TABLE chat_messages ADD COLUMN surprise TEXT');
- await live.prepare("INSERT INTO chat_messages (id, username, message, surprise) VALUES (108, 'x', 'y', 'z')").run();
- const r = await importer([]);
- assert.strictEqual(r.code, 1);
- assert.match(r.stderr, /chat_messages: surprise/);
- const d = chatDb();
- assert.strictEqual((await d.prepare('SELECT COUNT(*) AS n FROM chat_messages WHERE id = 108').get()).n, 0);
-});
-
-t.run(() => { try { live.close(); } catch { /* */ } fs.rmSync(tmp, { recursive: true, force: true }); });
@@ -20,7 +20,7 @@ const { boot, suite } = require('./helpers');
@@ -20,7 +20,7 @@ const { boot, suite } = require('./helpers');
const t = suite('message-deleted');
const t = suite('message-deleted');
const EVENTS_DIR = process.env.OPENVIBE_EVENTS_DIR || path.join(__dirname, '..', '..', 'OpenVibe.Events');
const EVENTS_DIR = process.env.OPENVIBE_EVENTS_DIR || path.join(__dirname, '..', '..', 'OpenVibe.Events');
-const haveEvents = ['server/index.js', 'server/redaction.js', 'node_modules/better-sqlite3'].every((f) => fs.existsSync(path.join(EVENTS_DIR, f)));
+const haveEvents = ['server/index.js', 'server/redaction.js', 'package.json'].every((f) => fs.existsSync(path.join(EVENTS_DIR, f)));
const SECRET = 'my number is 555-0199';
const SECRET = 'my number is 555-0199';
let h, events, eventsDb, eventsPort, admin, alice, streamer, streamId;
let h, events, eventsDb, eventsPort, admin, alice, streamer, streamId;
@@ -64,12 +64,11 @@ t('boot (a real OpenVibe.Events on a reserved port when one is checked out next
@@ -64,12 +64,11 @@ t('boot (a real OpenVibe.Events on a reserved port when one is checked out next
const { load } = require(path.join(EVENTS_DIR, 'server', 'config'));
const { load } = require(path.join(EVENTS_DIR, 'server', 'config'));
const { start } = require(path.join(EVENTS_DIR, 'server', 'index'));
const { start } = require(path.join(EVENTS_DIR, 'server', 'index'));
const quiet = { log() {}, warn() {}, error() {} };
const quiet = { log() {}, warn() {}, error() {} };
- // Events is on PostgreSQL (ADR-035): its own test/db.js hands out a migrated database (PGlite, or the
- // containers under EVENTS_TEST_STORE=pg); an older SQLite checkout ignores the handle and uses the path.
+ // Events' own test/db.js hands out a migrated database when available.
const dbHelper = path.join(EVENTS_DIR, 'test', 'db.js');
const dbHelper = path.join(EVENTS_DIR, 'test', 'db.js');
eventsDb = fs.existsSync(dbHelper) ? await require(dbHelper).testDb() : null;
eventsDb = fs.existsSync(dbHelper) ? await require(dbHelper).testDb() : null;
events = await start({
events = await start({
- config: load({ NODE_ENV: 'test', PORT: String(eventsPort), EVENTS_DB_PATH: path.join(h.tmp, 'events.db'), OV_NETWORK_PUBLIC_KEY: h.keys.publicKey, EVENTS_WORKER: 'off' }),
+ config: load({ NODE_ENV: 'test', PORT: String(eventsPort), OV_NETWORK_PUBLIC_KEY: h.keys.publicKey, EVENTS_WORKER: 'off' }),
db: eventsDb ? eventsDb.db : null,
db: eventsDb ? eventsDb.db : null,
log: quiet,
log: quiet,
});
});
deleted file mode 100644
deleted file mode 100644
@@ -1,159 +0,0 @@
@@ -1,159 +0,0 @@
-'use strict';
-/**
- * scripts/migrate-chat-preferences.js (server/prefs/from-live.js): Live's user_preferences.chat_settings
- * → the Network user module chat.preferences, through Network with Chat's service token.
- *
- * - mapping: only choices move (showTimestamps, compactMode, fontSize, showBadges); defaults-only rows,
- * rows without a Network subject and unreadable rows are counted and skipped
- * - dry run by default: reads Network, writes nothing
- * - --apply requires --backup (a new file); the backup is written, 0600, before any write and gets
- * each creation's revision; writes are create-only (If-Match: 0), so a second run creates nothing
- * - --rollback deletes what the run created, keeping records changed since
- * - the command line: refusals exit 2, a dry run through the real script exits 0
- */
-const assert = require('assert');
-const fs = require('fs');
-const path = require('path');
-const { spawn } = require('child_process');
-const { openSqlite } = require('../scripts/lib/sqlite');
-const { boot, suite } = require('./helpers');
-
-const t = suite('migrate-preferences');
-const NS = 'chat.preferences';
-const S = {
- ann: 'usr_01J9ANN0000000000000000AAA', eve: 'usr_01J9EVE0000000000000000AAA', fay: 'usr_01J9FAY0000000000000000AAA',
- gus: 'usr_01J9GVS0000000000000000AAA', gusOld: 'usr_01J9GVS0000000000000000ZZZ', bob: 'usr_01J9B0B0000000000000000AAA',
-};
-const SCRIPT = path.join(__dirname, '..', 'scripts', 'migrate-chat-preferences.js');
-let h, mig, livePath;
-const liveDefaults = { showTimestamps: false, timestampFormat: '12h', fontSize: 'default', showAvatars: true, showBadges: true, compactMode: false, ttsEnabled: true, ttsVolume: 80, ttsSendModeV2: true };
-const record = (subject) => h.netModules.records.get(`${NS}|${subject}`) || null;
-const puts = () => h.netModules.calls.filter((c) => c.method === 'PUT');
-// Async: the stub Network answers from this process, so it must keep running while the script does.
-const run = (args) => new Promise((resolve) => {
- const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: path.join(__dirname, '..'), env: process.env });
- let stdout = ''; let stderr = '';
- child.stdout.on('data', (c) => { stdout += c; });
- child.stderr.on('data', (c) => { stderr += c; });
- child.on('close', (status) => resolve({ status, stdout, stderr }));
-});
-
-t('a Live database with every kind of row', async () => {
- h = await boot();
- mig = require('../server/prefs/from-live');
- livePath = path.join(h.tmp, 'live.db');
- const live = openSqlite(livePath, { readonly: false, create: true });
- live.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT);
- CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);
- CREATE TABLE user_preferences (user_id INTEGER PRIMARY KEY, chat_settings TEXT DEFAULT '{}', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
- const add = async (id, name, settings, links) => {
- await live.prepare('INSERT INTO users (id, username) VALUES (?, ?)').run(id, name);
- for (const [service, subject] of links) await live.prepare('INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, ?, ?, ?)').run(id, service, `x${id}`, subject);
- await live.prepare('INSERT INTO user_preferences (user_id, chat_settings) VALUES (?, ?)').run(id, typeof settings === 'string' ? settings : JSON.stringify(settings));
- };
- await add(1, 'ann', { ...liveDefaults, showTimestamps: true, fontSize: 'large', showBadges: false }, [['network', S.ann]]);
- await add(2, 'bob', liveDefaults, [['network', S.bob]]); // defaults only
- await add(3, 'cat', { ...liveDefaults, compactMode: true }, []); // no subject
- await add(4, 'dan', '{not json', [['network', 'usr_01J9DAN0000000000000000AAA']]); // unreadable
- await add(5, 'eve', { ...liveDefaults, compactMode: true, fontSize: 'small' }, [['network', S.eve]]); // has a record already
- await add(6, 'fay', { compactMode: true }, [['network', S.fay]]); // a partial old blob
- await add(7, 'gus', { ...liveDefaults, fontSize: 'small' }, [['hobostreamer', S.gusOld], ['network', S.gus]]); // the network link wins
- live.close();
- for (const s of Object.values(S)) h.netModules.subjects.add(s);
- h.netModules.set(NS, S.eve, { hide_emotes: true });
-});
-
-t('the mapping keeps choices only', () => {
- assert.deepStrictEqual(mig.fromLiveChatSettings({ ...liveDefaults, showTimestamps: true, fontSize: 'large', showBadges: false, compactMode: true }),
- { timestamps: true, compact: true, font_scale: 1.18, show_badges: false });
- assert.deepStrictEqual(mig.fromLiveChatSettings(liveDefaults), {});
- assert.deepStrictEqual(mig.fromLiveChatSettings({ fontSize: 'small', showTimestamps: 'yes' }), { font_scale: 0.88 });
- assert.deepStrictEqual(mig.fromLiveChatSettings(null), {});
-});
-
-t('plan: counts and targets from Live, read-only', () => {
- const live = openSqlite(livePath);
- const p = mig.plan(live);
- live.close();
- assert.deepStrictEqual(p.counts, { live_rows: 7, unreadable: 1, no_subject: 1, defaults_only: 1, invalid: 0, candidates: 4, fields: { timestamps: 1, font_scale: 3, show_badges: 1, compact: 2 } });
- assert.deepStrictEqual(p.rows.map((r) => [r.live_user_id, r.subject]), [[1, S.ann], [5, S.eve], [6, S.fay], [7, S.gus]]);
- assert.deepStrictEqual(p.rows[0].data, { timestamps: true, font_scale: 1.18, show_badges: false });
-});
-
-t('the command line refuses unsafe runs (exit 2) and nothing is written', async () => {
- let r = await run(['--live-db', livePath, '--apply']);
- assert.strictEqual(r.status, 2, r.stderr); assert.ok(r.stderr.includes('--backup'));
- const existing = path.join(h.tmp, 'exists.json');
- fs.writeFileSync(existing, '{}');
- r = await run(['--live-db', livePath, '--apply', '--backup', existing]);
- assert.strictEqual(r.status, 2); assert.ok(r.stderr.includes('exists already'));
- r = await run([]);
- assert.strictEqual(r.status, 2);
- r = await run(['--live-db', path.join(h.tmp, 'nope.db')]);
- assert.strictEqual(r.status, 2);
- assert.strictEqual(puts().length, 0);
-});
-
-t('dry run through the real script: a report, reads only', async () => {
- const r = await run(['--live-db', livePath]);
- assert.strictEqual(r.status, 0, r.stderr);
- const report = JSON.parse(r.stdout);
- assert.strictEqual(report.mode, 'dry-run');
- assert.strictEqual(report.exists, 1, 'eve has one');
- assert.strictEqual(report.would_create, 3);
- assert.strictEqual(puts().length, 0);
- assert.ok(h.tokenRequests.length, 'the script asked Network for a token as chat');
-});
-
-t('--apply --backup: backup first (0600), create-only writes, revisions recorded', async () => {
- const backup = path.join(h.tmp, 'prefs-backup-1.json');
- const live = openSqlite(livePath);
- const planned = mig.plan(live);
- live.close();
- await assert.rejects(mig.migrate(planned, { apply: true }), /--backup/);
- h.netModules.beforeWrite = () => {
- const doc = JSON.parse(fs.readFileSync(backup, 'utf8'));
- assert.strictEqual(doc.targets.length, 3, 'the backup exists before the first write');
- assert.strictEqual(doc.results, null);
- };
- const report = await mig.migrate(planned, { apply: true, backup, liveDbPath: livePath });
- assert.deepStrictEqual([report.created, report.exists, report.raced, report.refused], [3, 1, 0, 0]);
- assert.ok(puts().every((c) => c.ifMatch === '"0"'), 'create-only');
- assert.deepStrictEqual(record(S.ann).data, { timestamps: true, font_scale: 1.18, show_badges: false });
- assert.strictEqual(record(S.ann).updated_by, 'svc:chat');
- assert.deepStrictEqual(record(S.gus).data, { font_scale: 0.88 });
- assert.deepStrictEqual(record(S.eve).data, { hide_emotes: true }, 'an existing record is never overwritten');
- assert.strictEqual(record(S.gusOld), null, 'the network link is the subject');
- assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600);
- const doc = JSON.parse(fs.readFileSync(backup, 'utf8'));
- assert.deepStrictEqual(doc.targets.map((x) => [x.subject, x.before]), [[S.ann, null], [S.fay, null], [S.gus, null]]);
- assert.deepStrictEqual(doc.results.map((x) => [x.subject, x.outcome, x.revision]), [[S.ann, 'created', 1], [S.fay, 'created', 1], [S.gus, 'created', 1]]);
-});
-
-t('a second run creates nothing', async () => {
- const n = puts().length;
- const r = await run(['--live-db', livePath, '--apply', '--backup', path.join(h.tmp, 'prefs-backup-2.json')]);
- assert.strictEqual(r.status, 0, r.stderr);
- const report = JSON.parse(r.stdout);
- assert.deepStrictEqual([report.created, report.exists, report.would_create], [0, 4, 0]);
- assert.strictEqual(puts().length, n);
-});
-
-t('--rollback deletes what the run created, keeps what changed since', async () => {
- h.netModules.set(NS, S.fay, { compact: false }); // fay changed hers after the migration
- const backup = path.join(h.tmp, 'prefs-backup-1.json');
- let r = await run(['--rollback', backup]);
- assert.strictEqual(r.status, 0, r.stderr);
- assert.deepStrictEqual((({ would_delete, changed_since, deleted }) => ({ would_delete, changed_since, deleted }))(JSON.parse(r.stdout)), { would_delete: 2, changed_since: 1, deleted: 0 });
- assert.ok(record(S.ann), 'dry run');
- r = await run(['--rollback', backup, '--apply']);
- assert.strictEqual(r.status, 0, r.stderr);
- const report = JSON.parse(r.stdout);
- assert.deepStrictEqual([report.deleted, report.changed_since, report.gone], [2, 1, 0]);
- assert.strictEqual(record(S.ann), null); assert.strictEqual(record(S.gus), null);
- assert.deepStrictEqual(record(S.fay).data, { compact: false });
- assert.deepStrictEqual(record(S.eve).data, { hide_emotes: true }, 'never the migration\'s');
- assert.ok(h.netModules.calls.filter((c) => c.method === 'DELETE').every((c) => /^"\d+"$/.test(c.ifMatch)), 'deletes name the revision');
-});
-
-t.run(async () => { if (h) await h.close(); });
@@ -36,16 +36,7 @@ const FIX = path.join(__dirname, 'fixtures', 'n-1');
@@ -36,16 +36,7 @@ const FIX = path.join(__dirname, 'fixtures', 'n-1');
const client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));
const client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));
const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.json'), 'utf8'));
const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.json'), 'utf8'));
-// ── Skipped for the engine-switch release only: SQLite → PostgreSQL (plan T3, decision 6) ─────────
-// This release serves from PostgreSQL (/ready says pglite or postgresql, never a SQLite file), so an N-1 fixture
-// recorded from a release on *SQLite* — its statements in SQLite's dialect, its schema from sqlite_master — cannot be
-// prepared or replayed here. After this release deploys, `npm run n-1:record` records it (engine 'postgresql': the
-// migrations it applied and its statements as PostgreSQL received them) and the replay below resumes on its own.
-// Until then the run reports itself skipped (the ○ convention: neither green nor a failure). Keep the harness.
-if (worker.engine !== 'postgresql') {
- console.log('n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys)');
- process.exit(0);
-}
+if (worker.engine !== 'postgresql') throw new Error('N-1 fixture must use PostgreSQL');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
const quiet = { log() {}, info() {}, warn() {}, error: (...a) => console.error(...a) };
const quiet = { log() {}, info() {}, warn() {}, error: (...a) => console.error(...a) };
@@ -1,8 +1,7 @@
@@ -1,8 +1,7 @@
'use strict';
'use strict';
/**
/**
* Runs inside a Chat release (its directory is the cwd): boots it with that release's own
* Runs inside a Chat release (its directory is the cwd): boots it with that release's own
- * test/helpers.js (a stub Network and a stub Live, Chat in-process) on the database in N1_DB (a SQLite file for a
- * release on SQLite, a PGlite directory for one on PostgreSQL), seeds it,
+ * test/helpers.js (a stub Network and a stub Live, Chat in-process) on the database in N1_DB (a PGlite directory), seeds it,
* and prints one line `{"n1": { url, token, ids }}`. SIGTERM stops it (Chat's own shutdown).
* and prints one line `{"n1": { url, token, ids }}`. SIGTERM stops it (Chat's own shutdown).
*/
*/
const path = require('path');
const path = require('path');
@@ -12,7 +11,7 @@ const path = require('path');
@@ -12,7 +11,7 @@ const path = require('path');
const quiet = () => {};
const quiet = () => {};
console.warn = quiet;
console.warn = quiet;
const helpers = require(path.join(dir, 'test', 'helpers'));
const helpers = require(path.join(dir, 'test', 'helpers'));
- const h = await helpers.boot({ env: { CHAT_DB_PATH: process.env.N1_DB, CHAT_PGLITE_DIR: process.env.N1_DB } });
+ const h = await helpers.boot({ env: { CHAT_PGLITE_DIR: process.env.N1_DB } });
process.on('exit', () => { try { require('fs').rmSync(h.tmp, { recursive: true, force: true }); } catch { /* */ } });
process.on('exit', () => { try { require('fs').rmSync(h.tmp, { recursive: true, force: true }); } catch { /* */ } });
const star = h.addUser('n1star', { role: 'streamer' });
const star = h.addUser('n1star', { role: 'streamer' });
const fan = h.addUser('n1fan');
const fan = h.addUser('n1fan');
deleted file mode 100644
deleted file mode 100644
@@ -1,12 +0,0 @@
@@ -1,12 +0,0 @@
-'use strict';
-/** Which engine a Chat release (a checkout's directory) serves from: PostgreSQL from plan T3 on (migrations/, no better-sqlite3). */
-const fs = require('fs');
-const path = require('path');
-
-function onPostgres(dir) {
- let deps = {};
- try { deps = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).dependencies || {}; } catch { /* */ }
- return !deps['better-sqlite3'] && fs.existsSync(path.join(dir, 'migrations'));
-}
-
-module.exports = { onPostgres };
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
* their types).
* their types).
*
*
* SQL is every statement N-1 prepared while it served those calls, plus every literal SQL string in its
* SQL is every statement N-1 prepared while it served those calls, plus every literal SQL string in its
- * server code that prepares on its own schema. The N-1 schema is its sqlite_master after the replay.
+ * server code that prepares on its own schema.
*/
*/
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -722,33 +722,6 @@ function normalizeSql(sql) {
@@ -722,33 +722,6 @@ function normalizeSql(sql) {
return stripSqlComments(sql).replace(/\s+/g, ' ').trim();
return stripSqlComments(sql).replace(/\s+/g, ' ').trim();
}
}
-/**
- * The DDL N-1 runs itself when it first needs a table or column (a module's CREATE TABLE IF NOT EXISTS,
- * a boot-time ALTER TABLE … ADD COLUMN) that `db` does not have yet. Each statement is applied to `db`
- * as it is found to apply; → the applied statements, tables first, in order.
- */
-function lazyDDL(files, db) {
- const tables = () => new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
- const columns = (t) => { try { return new Set(db.prepare(`PRAGMA table_xinfo("${t}")`).all().map((c) => c.name.toLowerCase())); } catch { return new Set(); } };
- const lits = stringLiterals(files).map(stripSqlComments);
- const applied = [];
- const apply = (ddl) => { try { db.exec(ddl); applied.push(ddl); return true; } catch { return false; } };
- for (const text of lits) {
- for (const m of text.matchAll(/CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+["`]?(\w+)["`]?\s*\(/gi)) {
- // A rebuild's scratch table (x_new) is the migration's own business, not a table N-1 uses.
- if (tables().has(m[1]) || /_(new|old|tmp|backup)$/i.test(m[1])) continue;
- const end = skipBalanced(text, m.index + m[0].length - 1, '(', ')');
- if (end >= 0) apply(normalizeSql(text.slice(m.index, end + 1)));
- }
- }
- for (const text of lits) {
- const m = /^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+(?:COLUMN\s+)?["`]?(\w+)["`]?[^;]*$/i.exec(text.trim().replace(/;$/, ''));
- if (!m || !tables().has(m[1]) || columns(m[1]).has(m[2].toLowerCase())) continue;
- apply(normalizeSql(text).replace(/;$/, ''));
- }
- return applied;
-}
-
/** Every file under dir with one of the extensions, as { name (relative to root), text }. */
/** Every file under dir with one of the extensions, as { name (relative to root), text }. */
function readTree(root, dirs, exts = ['.js']) {
function readTree(root, dirs, exts = ['.js']) {
const out = [];
const out = [];
@@ -768,50 +741,6 @@ function readTree(root, dirs, exts = ['.js']) {
@@ -768,50 +741,6 @@ function readTree(root, dirs, exts = ['.js']) {
return out.sort((a, b) => (a.name < b.name ? -1 : 1));
return out.sort((a, b) => (a.name < b.name ? -1 : 1));
}
}
-/** The schema as DDL, tables first (no internal or FTS shadow tables), then indexes, triggers and views. */
-function schemaDDL(db) {
- const shadow = new Set(db.prepare('PRAGMA table_list').all().filter((t) => t.type === 'shadow').map((t) => t.name));
- const rows = db.prepare("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'").all()
- .filter((r) => !shadow.has(r.name));
- const order = { table: 0, index: 1, view: 2, trigger: 3 };
- return rows.sort((a, b) => order[a.type] - order[b.type] || (a.name < b.name ? -1 : 1)).map((r) => r.sql);
-}
-
-/** Prepares every statement on db (a missing table or column fails the prepare). → [{ sql, error }] */
-function prepareProblems(db, statements) {
- const problems = [];
- for (const sql of statements) {
- try { db.prepare(sql); } catch (e) { problems.push({ sql, error: e.message }); }
- }
- return problems;
-}
-
-/**
- * INSERTs of N-1 that N would refuse: a column N made NOT NULL without a default that the INSERT does
- * not name. → [{ sql, error }]
- */
-function insertProblems(db, statements) {
- const problems = [];
- const cols = new Map();
- const required = (table) => {
- if (!cols.has(table)) {
- let info = [];
- try { info = db.prepare(`PRAGMA table_xinfo("${table.replace(/"/g, '""')}")`).all(); } catch { info = []; }
- const pkInt = info.filter((c) => c.pk).length === 1 && info.find((c) => c.pk && /^INTEGER$/i.test(c.type));
- cols.set(table, info.filter((c) => c.notnull && c.dflt_value == null && !c.hidden && !(pkInt && c.pk)).map((c) => c.name));
- }
- return cols.get(table);
- };
- for (const sql of statements) {
- const m = /^\s*(?:INSERT|REPLACE)(?:\s+OR\s+\w+)?\s+INTO\s+["`]?(\w+)["`]?\s*\(([^)]*)\)\s*(VALUES|SELECT)/i.exec(sql);
- if (!m) continue;
- const named = new Set(m[2].split(',').map((c) => c.trim().replace(/^["`]|["`]$/g, '').toLowerCase()));
- const missing = required(m[1]).filter((c) => !named.has(c.toLowerCase()));
- if (missing.length) problems.push({ sql, error: `${m[1]}.${missing.join(', ')} is NOT NULL without a default, and N-1 does not set it` });
- }
- return problems;
-}
-
// ── PostgreSQL (a release on openvibe-sdk/db, plan T3) ─────────
// ── PostgreSQL (a release on openvibe-sdk/db, plan T3) ─────────
/**
/**
@@ -899,7 +828,7 @@ module.exports = {
@@ -899,7 +828,7 @@ module.exports = {
flatten, readsOf, readProblems, statusCompatible,
flatten, readsOf, readProblems, statusCompatible,
send, requestsFor, notFoundProbe, isNoRoute, callProblems,
send, requestsFor, notFoundProbe, isNoRoute, callProblems,
wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
- stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,
+ stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, readTree,
worktree, gitFiles, summarize,
worktree, gitFiles, summarize,
pgMigrations, pgPrepareProblems, pgInsertProblems,
pgMigrations, pgPrepareProblems, pgInsertProblems,
};
};
@@ -2,16 +2,12 @@
@@ -2,16 +2,12 @@
/**
/**
* Loaded with `node -r` into a release booted by test/n-1/service.js (N-1 at record time, N in the
* Loaded with `node -r` into a release booted by test/n-1/service.js (N-1 at record time, N in the
* test), from that release's directory. With N1_SQL_OUT set, every SQL text the process prepares or
* test), from that release's directory. With N1_SQL_OUT set, every SQL text the process prepares or
- * executes on the chat database is written there as JSON when it exits: on a release on SQLite, what
- * better-sqlite3 prepares on CHAT_DB_PATH; on a release on PostgreSQL (openvibe-sdk/db, no better-sqlite3),
- * the statements its handles run, as PostgreSQL received them ($n parameters).
+ * executes on the chat database is written as PostgreSQL SQL.
*/
*/
const fs = require('fs');
const fs = require('fs');
-const path = require('path');
const out = process.env.N1_SQL_OUT;
const out = process.env.N1_SQL_OUT;
-const onPostgres = out && require('./engine').onPostgres(process.cwd());
-if (out && onPostgres) {
+if (out) {
const sdkDb = require(require.resolve('openvibe-sdk/db', { paths: [process.cwd()] }));
const sdkDb = require(require.resolve('openvibe-sdk/db', { paths: [process.cwd()] }));
const seen = new Set();
const seen = new Set();
const createDb = sdkDb.createDb;
const createDb = sdkDb.createDb;
@@ -25,15 +21,4 @@ if (out && onPostgres) {
@@ -25,15 +21,4 @@ if (out && onPostgres) {
return d;
return d;
};
};
process.on('exit', () => { try { fs.writeFileSync(out, JSON.stringify([...seen])); } catch { /* */ } });
process.on('exit', () => { try { fs.writeFileSync(out, JSON.stringify([...seen])); } catch { /* */ } });
-} else if (out) {
- const Database = require(path.join(process.cwd(), 'node_modules', 'better-sqlite3'));
- const seen = new Set();
- const note = (db, sql) => {
- try { if (process.env.CHAT_DB_PATH && path.resolve(db.name) === path.resolve(process.env.CHAT_DB_PATH)) seen.add(String(sql)); } catch { /* */ }
- };
- const prepare = Database.prototype.prepare;
- Database.prototype.prepare = function (sql, ...rest) { note(this, sql); return prepare.call(this, sql, ...rest); };
- const exec = Database.prototype.exec;
- Database.prototype.exec = function (sql, ...rest) { note(this, sql); return exec.call(this, sql, ...rest); };
- process.on('exit', () => { try { fs.writeFileSync(out, JSON.stringify([...seen])); } catch { /* */ } });
}
}
@@ -1,9 +1,5 @@
@@ -1,9 +1,5 @@
'use strict';
'use strict';
-/**
- * Chat serves from PostgreSQL only (plan T3, decision 11): better-sqlite3 is not a dependency, and nothing the
- * service loads opens SQLite. Reading a SQLite file is left to the one-time tools under scripts/ (Live's snapshot,
- * the production import), through node:sqlite in scripts/lib/sqlite.js.
- */
+/** PostgreSQL is the serving store; completed SQLite tooling and file settings are gone. */
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -11,36 +7,61 @@ const { suite } = require('./helpers');
@@ -11,36 +7,61 @@ const { suite } = require('./helpers');
const t = suite('no-sqlite');
const t = suite('no-sqlite');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
+const at = (name) => path.join(ROOT, name);
+const source = (name) => fs.readFileSync(at(name), 'utf8');
function files(dir) {
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;
+ if (!fs.existsSync(dir)) return [];
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const file = path.join(dir, entry.name);
+ return entry.isDirectory() ? files(file) : [file];
+ });
}
}
-t('package.json: better-sqlite3 is neither a dependency nor a dev dependency', () => {
- const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
- for (const k of ['dependencies', 'devDependencies', 'optionalDependencies']) assert.ok(!(pkg[k] || {})['better-sqlite3'], `${k} lists better-sqlite3`);
- assert.ok(pkg.dependencies.pg && pkg.dependencies['openvibe-sdk'], 'pg and openvibe-sdk serve');
+t('package dependencies use PostgreSQL and do not include SQLite drivers', () => {
+ const pkg = JSON.parse(source('package.json'));
+ for (const group of ['dependencies', 'devDependencies', 'optionalDependencies']) {
+ for (const name of ['better-sqlite3', 'sqlite3']) assert.ok(!(pkg[group] || {})[name], `${group} lists ${name}`);
+ }
+ assert.ok(pkg.dependencies.pg && pkg.dependencies['openvibe-sdk']);
+ assert.ok(pkg.devDependencies['@electric-sql/pglite']);
+ assert.ok(!pkg.scripts['import-from-live']);
+});
+
+t('removed tools, tests, and runbooks are absent', () => {
+ for (const name of [
+ 'scripts/import-from-live.js', 'scripts/import-sqlite-to-pg.js', 'scripts/migrate-chat-preferences.js',
+ 'scripts/parity-check.js', 'scripts/lib/sqlite.js', 'server/prefs/from-live.js',
+ 'test/import.test.js', 'test/import-pg.test.js', 'test/migrate-preferences.test.js',
+ 'test/rehearsal.test.js', 'test/cutover-evidence.test.js', 'docs/cutover.md',
+ 'docs/cutover-evidence-t3.md', 'docs/pg-port',
+ ]) assert.ok(!fs.existsSync(at(name)), `${name} still exists`);
+});
+
+t('service and configuration do not open SQLite files', () => {
+ for (const file of files(at('server')).filter((f) => /\.js$/.test(f))) {
+ assert.doesNotMatch(fs.readFileSync(file, 'utf8'), /require\(['"](?:better-sqlite3|sqlite3|node:sqlite|openvibe-sdk\/sqlite-import|openvibe-sdk\/sqlite-cli)['"]\)/, file);
+ assert.doesNotMatch(fs.readFileSync(file, 'utf8'), /\b(?:[A-Z_]*DB_PATH)\b/, file);
+ }
+ for (const name of ['.env.example', 'deploy/systemd/openvibe-chat.service', 'README.md']) {
+ assert.doesNotMatch(source(name), /\b(?:[A-Z_]*DB_PATH)\b/, name);
+ }
+ for (const file of files(at('scripts')).filter((f) => /\.js$/.test(f))) {
+ assert.doesNotMatch(fs.readFileSync(file, 'utf8'), /require\(['"](?:better-sqlite3|sqlite3|node:sqlite)['"]\)/, file);
+ }
});
});
-t('nothing under server/ requires better-sqlite3 or node:sqlite', () => {
- const offenders = files(path.join(ROOT, 'server'))
- .filter((f) => /require\(\s*['"](better-sqlite3|node:sqlite|sqlite3)['"]\s*\)|from\s+['"](better-sqlite3|node:sqlite)['"]/.test(fs.readFileSync(f, 'utf8')))
- .map((f) => path.relative(ROOT, f));
- assert.deepStrictEqual(offenders, []);
+t('current docs and deployment files do not refer to retired tools or the Chat file setting', () => {
+ const retired = /CHAT_DB_PATH|scripts\/(?:import-from-live|import-sqlite-to-pg|migrate-chat-preferences|parity-check)\.js|docs\/(?:cutover(?:-evidence-t3)?\.md|pg-port\/|live-patch\.diff)/;
+ for (const name of ['README.md', 'STATUS.json', '.env.example', 'deploy/systemd/openvibe-chat.service']) {
+ assert.doesNotMatch(source(name), retired, name);
+ }
+ for (const file of files(at('docs'))) assert.doesNotMatch(fs.readFileSync(file, 'utf8'), retired, file);
});
});
-t('scripts read SQLite only through scripts/lib/sqlite.js', () => {
- const offenders = files(path.join(ROOT, 'scripts'))
- .filter((f) => path.relative(ROOT, f) !== path.join('scripts', 'lib', 'sqlite.js'))
- .filter((f) => /require\(\s*['"](node:sqlite|sqlite3)['"]\s*\)/.test(fs.readFileSync(f, 'utf8')))
- .map((f) => path.relative(ROOT, f));
- assert.deepStrictEqual(offenders, []);
+t('production without DATABASE_URL fails before opening PGlite', async () => {
+ const { openDb } = require('../server/db/database');
+ await assert.rejects(openDb({ db: { url: '' }, nodeEnv: 'production' }), /DATABASE_URL is not set/);
});
});
t.run();
t.run();
deleted file mode 100644
deleted file mode 100644
@@ -1,55 +0,0 @@
@@ -1,55 +0,0 @@
-'use strict';
-/**
- * The cutover rehearsal's seed (docs/cutover.md's ```rehearse block → test/rehearsal/seed.sql).
- * `ov rehearse` loads the declared seed on a fresh database after main's migrations and before the
- * branch's, so the seed must be PostgreSQL and must only name columns of migrations/0001 and 0002 —
- * not chat_ingress_applied, which 0003 creates. Without the declaration the harness would load every
- * test/fixtures/*.sql instead, and test/fixtures/live-chat-schema.sql is Live's SQLite schema.
- * This reproduces that exact order on an in-process PostgreSQL (PGlite).
- */
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const { createDb } = require('openvibe-sdk/db');
-const { suite } = require('./helpers');
-
-const t = suite('rehearsal');
-const ROOT = path.join(__dirname, '..');
-const MIGRATIONS = path.join(ROOT, 'migrations');
-const RUNBOOK = path.join(ROOT, 'docs', 'cutover.md');
-const SEED = path.join(ROOT, 'test', 'rehearsal', 'seed.sql');
-
-let db;
-t('the runbook declares only the PostgreSQL seed', () => {
- const block = /^```rehearse[ \t]*\n([\s\S]*?)^```/gm.exec(fs.readFileSync(RUNBOOK, 'utf8'));
- assert.ok(block, "docs/cutover.md has a ```rehearse block");
- const seeds = block[1].split('\n').map((l) => l.trim()).filter((l) => l.startsWith('seed:'))
- .map((l) => l.slice(5).trim());
- assert.deepStrictEqual(seeds, ['test/rehearsal/seed.sql'], 'the only seed is test/rehearsal/seed.sql');
-});
-
-t('the seed applies after 0001–0002 and before 0003', async () => {
- const base = fs.mkdtempSync(path.join(os.tmpdir(), 'rh-base-'));
- for (const f of ['0001_initial.sql', '0002_audio_claim_owner.sql']) fs.copyFileSync(path.join(MIGRATIONS, f), path.join(base, f));
- db = createDb({ pglite: true, service: 'rehearsal-test', log: { log() {}, warn() {}, error() {} } });
- const first = await db.migrate({ dir: base, log: { log() {} } });
- assert.deepStrictEqual(first.applied.map((a) => a.id), ['0001', '0002'], 'main\'s migrations applied');
- await db.query(fs.readFileSync(SEED, 'utf8'));
- for (const [table, n] of [['chat_messages', 3], ['dm_conversations', 1], ['dm_participants', 2], ['dm_messages', 2], ['bridge_applied', 1]]) {
- const row = await db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
- assert.strictEqual(row.n, n, `${table} has ${n} seeded row(s)`);
- }
- assert.strictEqual((await db.prepare(`SELECT to_regclass('chat_ingress_applied') AS t`).get()).t, null, '0003 has not run yet');
- // windowDays 0: a test wants the final schema, not a contract held for its N-1 window (ADR-028).
- const second = await db.migrate({ dir: MIGRATIONS, log: { log() {} }, windowDays: 0 });
- const later = fs.readdirSync(MIGRATIONS).filter((f) => /^\d{4}_.*\.sql$/.test(f)).map((f) => f.slice(0, 4))
- .filter((id) => id > '0002').sort();
- assert.ok(later.includes('0003'), 'migrations/0003 exists');
- assert.strictEqual(second.held.length, 0, 'no later migration is held');
- assert.deepStrictEqual(second.applied.map((a) => a.id), later, 'every migration after 0002 applies on top of the seed');
-});
-
-t('cleanup', async () => { await db.close(); });
-
-t.run();
deleted file mode 100644
deleted file mode 100644
@@ -1,29 +0,0 @@
@@ -1,29 +0,0 @@
-
-INSERT INTO chat_messages (stream_id, user_id, anon_id, username, message, message_type, source_platform, timestamp, channel_user_id, subject_id) VALUES
- (9001, 42, NULL, 'alice', 'first!', 'chat', 'openvibe', '2026-10-01 12:00:00', 42, 'usr_alice'),
- (9001, NULL, 'anon1', 'guest', 'hi from a guest', 'chat', 'openvibe', '2026-10-01 12:00:05', 42, NULL),
- (9001, NULL, NULL, NULL, 'guest joined the chat', 'system', 'openvibe', '2026-10-01 12:00:06', 42, NULL);
-
-INSERT INTO dm_conversations (name, is_group, created_by, created_at, updated_at) VALUES
- (NULL, 0, 42, '2026-10-01 12:01:00', '2026-10-01 12:01:45');
-
-INSERT INTO dm_participants (conversation_id, user_id, last_read_at, joined_at, subject_id) VALUES
- (1, 42, '1970-01-01 00:00:00', '2026-10-01 12:01:00', 'usr_alice'),
- (1, 43, '1970-01-01 00:00:00', '2026-10-01 12:01:00', 'usr_bob');
-
-INSERT INTO dm_messages (conversation_id, sender_id, message, created_at, sender_subject_id) VALUES
- (1, 42, 'hey bob', '2026-10-01 12:01:30', 'usr_alice'),
- (1, 43, 'hey alice', '2026-10-01 12:01:45', 'usr_bob');
-
-INSERT INTO bridge_applied (key, result, applied_at) VALUES
- ('rehearsal:seed:1', '{"ok":true}', 1788220800000);
@@ -270,15 +270,4 @@ t('CORS: no *.openvibe.tools wildcard — only exact origins get credentialed CO
@@ -270,15 +270,4 @@ t('CORS: no *.openvibe.tools wildcard — only exact origins get credentialed CO
} finally { config.extraOrigins = saved; }
} finally { config.extraOrigins = saved; }
});
});
-t('scripts/parity-check.js: two identical services answer the same', async () => {
- const { spawn } = require('child_process');
- const path = require('path');
- const r = await new Promise((resolve) => {
- const c = spawn(process.execPath, [path.join(__dirname, '..', 'scripts', 'parity-check.js'), '--live', h.base, '--chat', h.base, '--stream', String(streamId), '--channel', String(streamer.id), '--token', alice.token]);
- let out = ''; c.stdout.on('data', (d) => { out += d; }); c.on('close', (code) => resolve({ code, out }));
- });
- assert.strictEqual(r.code, 0, r.out);
- assert.match(r.out, /all paths answer the same/);
-});
-
t.run(async () => { if (h) await h.close(); });
t.run(async () => { if (h) await h.close(); });
@@ -112,7 +112,7 @@ t('TTS admin settings: admins only; credentials only the owner; written by Live'
@@ -112,7 +112,7 @@ t('TTS admin settings: admins only; credentials only the owner; written by Live'
const view = await h.http('GET', '/api/tts/admin/settings', { token: admin.token });
const view = await h.http('GET', '/api/tts/admin/settings', { token: admin.token });
assert.strictEqual(view.body.settings.googleApiKey, '••••••••', 'admins see credentials masked');
assert.strictEqual(view.body.settings.googleApiKey, '••••••••', 'admins see credentials masked');
// (Live masks the engine's cached settings object in place, so for up to 30s after an admin
// (Live masks the engine's cached settings object in place, so for up to 30s after an admin
- // looks, the owner — and Google synthesis — see the mask too. Moved as it is; see docs/cutover.md.)
+ // looks, the owner — and Google synthesis — see the mask too.)
});
});
t.run(async () => { if (h) await h.close(); });
t.run(async () => { if (h) await h.close(); });