openvibers/openvibe.chat Commits

Chat: the migration era is deleted (SQLite and Live-import tooling, CHAT_DB_PATH, cutover docs) (#46)

OP OpenVibers committed 10 Oct 2026 · 4114da10e0 · 41 files, +85 −4557

Plan T1 "Delete the migration era" and T3's leftovers. Chat has run on PostgreSQL (ov_chat) since 2026-10-02 and owns every chat table; the import from Live is finished. PGlite stays for development and tests.

**Deleted:**
- the SQLite → PostgreSQL and Live → Chat import tooling: `scripts/import-sqlite-to-pg.js`, `import-from-live.js`, `migrate-chat-preferences.js`, `parity-check.js`, `scripts/lib/sqlite.js`, `server/prefs/from-live.js`, their tests, fixtures and rehearsal seed;
- the N-1 harness's SQLite engine (`test/n-1/engine.js` and SQLite capture). Its PostgreSQL recording and replay stay;
- the cutover runbooks, evidence, the PG-port notes and `docs/live-patch.diff`;
- `CHAT_DB_PATH` (config, `.env.example`, systemd unit). `CHAT_CACHE_DIR` now defaults to `data/cache` by itself.

`test/no-sqlite.test.js` now also asserts the deleted files, scripts and settings are gone and that production fails fast without `DATABASE_URL`. README and STATUS are updated. Chat had no SQLite driver dependency and no legacy deploy script.

`ov test`: 47 passed. The app loads with no reference to the deleted modules. Drafted by a Codex job and reviewed here; the loopback HTTP tests were run here.

Changes

ddiff --git a/.env.example b/.env.example
iindex c797423..41120ce 100644
---- a/.env.example
++++ b/.env.example
@@@ -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)
ddiff --git a/README.md b/README.md
iindex 5b1fed9..8921cf9 100644
---- a/README.md
++++ b/README.md
@@@ -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 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
@@@ -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
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.
--- **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
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
@@@ -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 capture
triggers, and `live_mirror_outbox` stays, idle, until a contract migration drops it.
-- `scripts/import-sqlite-to-pg.js` moves the SQLite data over once, with a per-table count and checksum
-- report (`docs/cutover.md`). `VALKEY_URL`/`VALKEY_PREFIX` put the per-actor rate-limit counters on
-- Valkey instead of this process. No part of the service reads SQLite; the one-time tools that read a
-- 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 only
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
| 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_participa
Chat owns too (C-04 done; there is no authority switch any more): `channel_moderators`,
`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`.
--The importer no longer copies them; Live reads them through `GET /internal/moderation/*`.
++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 enf
when the revision is newer (without the subscription, within the TTL). Network down: the cached copy
with `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,s
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,
# 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 a
its own interval by that much. It also reports whether events are relayed and the Events consumer's
counters.
Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-chat.locations.conf`,
--`/etc/openvibe/chat.env`. The whole switch-over — rehearsal, import, parity checks, nginx, the flag,
--rollback — is `docs/cutover.md`.
++`/etc/openvibe/chat.env`.
### Per-actor limits
@@@ -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/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/open
for openvibe.chat and [deploy/nginx/openvibe.live-chat.locations.conf](deploy/nginx/openvibe.live-chat.locations.conf),
included in openvibe.live's vhost, for Live's chat paths. After a deploy, record the N-1 fixtures
(`npm run n-1:record`). Chat is required in every mode and Live's chat routes are retired: a
--rollback is a Chat release rollback, not a switch back to Live (`docs/cutover.md`).
++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
restart; afterwards `sudo ovhost rollback chat --to <sha>`. Nothing blocks a rollback: the schema
ddiff --git a/STATUS.json b/STATUS.json
iindex fe66f3b..4dd9bbc 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/deploy/nginx/openvibe.live-chat.locations.conf b/deploy/nginx/openvibe.live-chat.locations.conf
iindex 8f26303..015c72f 100644
---- a/deploy/nginx/openvibe.live-chat.locations.conf
++++ b/deploy/nginx/openvibe.live-chat.locations.conf
@@@ -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/.
ddiff --git a/deploy/systemd/openvibe-chat.service b/deploy/systemd/openvibe-chat.service
iindex 866563a..727a3fd 100644
---- a/deploy/systemd/openvibe-chat.service
++++ b/deploy/systemd/openvibe-chat.service
@@@ -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.db
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
# donation alerts read alert sounds there). Legacy layout and release layout (shared/data).
ddiff --git a/docs/calls-cutover.md b/docs/calls-cutover.md
iindex 039af8b..caf569c 100644
---- a/docs/calls-cutover.md
++++ b/docs/calls-cutover.md
@@@ -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
whatever 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 the
notification), accept → connected; the row is `direct`, `ringing` → `active` → `ended`. Decline →
ddiff --git a/docs/cutover-evidence-t3.md b/docs/cutover-evidence-t3.md
ddeleted file mode 100644
iindex 624519d..0000000
---- a/docs/cutover-evidence-t3.md
++++ /dev/null
@@@ -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.
ddiff --git a/docs/cutover.md b/docs/cutover.md
ddeleted file mode 100644
iindex 7081f36..0000000
---- a/docs/cutover.md
++++ /dev/null
@@@ -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`.
ddiff --git a/docs/live-patch.diff b/docs/live-patch.diff
ddeleted file mode 100644
iindex bdd56d3..0000000
---- a/docs/live-patch.diff
++++ /dev/null
@@@ -1,1596 +0,0 @@
--diff --git a/.env.example b/.env.example
--index 31f53b7..19810f1 100644
----- a/.env.example
--+++ 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
----- a/docs/chat-system.md
--+++ 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
----- a/server/auth/auth.js
--+++ 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
----- /dev/null
--+++ 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
----- /dev/null
--+++ 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
----- a/server/chat/chat-server.js
--+++ 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
----- a/server/chat/deploy-notice.js
--+++ 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
----- /dev/null
--+++ 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
----- a/server/index.js
--+++ 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
----- a/server/internal/routes.js
--+++ 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
----- /dev/null
--+++ 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
----- /dev/null
--+++ 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);
--+ }
--+})();
ddiff --git a/docs/pg-port/brief.md b/docs/pg-port/brief.md
ddeleted file mode 100644
iindex be911e7..0000000
---- a/docs/pg-port/brief.md
++++ /dev/null
@@@ -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).
ddiff --git a/docs/pg-port/progress-attempt3.md b/docs/pg-port/progress-attempt3.md
ddeleted file mode 100644
iindex c02d13d..0000000
---- a/docs/pg-port/progress-attempt3.md
++++ /dev/null
@@@ -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
ddiff --git a/docs/pg-port/progress-attempt4.md b/docs/pg-port/progress-attempt4.md
ddeleted file mode 100644
iindex 12ccf40..0000000
---- a/docs/pg-port/progress-attempt4.md
++++ /dev/null
@@@ -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.
ddiff --git a/docs/pg-port/research.md b/docs/pg-port/research.md
ddeleted file mode 100644
iindex 40392ee..0000000
---- a/docs/pg-port/research.md
++++ /dev/null
@@@ -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
ddiff --git a/package.json b/package.json
iindex 7eae57c..f44d69e 100644
---- a/package.json
++++ b/package.json
@@@ -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": [
ddiff --git a/scripts/import-from-live.js b/scripts/import-from-live.js
ddeleted file mode 100644
iindex 39a0b6c..0000000
---- a/scripts/import-from-live.js
++++ /dev/null
@@@ -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 };
ddiff --git a/scripts/import-sqlite-to-pg.js b/scripts/import-sqlite-to-pg.js
ddeleted file mode 100644
iindex 8da1eac..0000000
---- a/scripts/import-sqlite-to-pg.js
++++ /dev/null
@@@ -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 };
ddiff --git a/scripts/lib/sqlite.js b/scripts/lib/sqlite.js
ddeleted file mode 100644
iindex c0fa52f..0000000
---- a/scripts/lib/sqlite.js
++++ /dev/null
@@@ -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 };
ddiff --git a/scripts/migrate-chat-preferences.js b/scripts/migrate-chat-preferences.js
ddeleted file mode 100644
iindex e0fd026..0000000
---- a/scripts/migrate-chat-preferences.js
++++ /dev/null
@@@ -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); });
ddiff --git a/scripts/n-1-record.js b/scripts/n-1-record.js
iindex 9c59185..e1067be 100644
---- a/scripts/n-1-record.js
++++ b/scripts/n-1-record.js
@@@ -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();
ddiff --git a/scripts/parity-check.js b/scripts/parity-check.js
ddeleted file mode 100644
iindex d0de131..0000000
---- a/scripts/parity-check.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 0fbabb9..5938fd6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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
ddiff --git a/server/config.js b/server/config.js
iindex 38dd0bb..03a49e1 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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'),
};
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 918c295..2eb113c 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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 };
ddiff --git a/server/prefs/from-live.js b/server/prefs/from-live.js
ddeleted file mode 100644
iindex e1c5b47..0000000
---- a/server/prefs/from-live.js
++++ /dev/null
@@@ -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 };
ddiff --git a/test/cutover-evidence.test.js b/test/cutover-evidence.test.js
ddeleted file mode 100644
iindex f1a964c..0000000
---- a/test/cutover-evidence.test.js
++++ /dev/null
@@@ -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();
ddiff --git a/test/fixtures/live-chat-schema.sql b/test/fixtures/live-chat-schema.sql
ddeleted file mode 100644
iindex 48ea69f..0000000
---- a/test/fixtures/live-chat-schema.sql
++++ /dev/null
@@@ -1,373 +0,0 @@
---- Live's chat tables exactly as a current Live creates them (dumped from a fresh
---- OpenVibe.Live database, 2026-09-22), plus the minimum of Live's own tables the importer reads.
---- Used by test/import.test.js to build a fake Live snapshot.
---- chat_messages
--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);
---- dm_conversations
--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
-- );
---- dm_participants
--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);
---- dm_messages
--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);
---- dm_blocks
--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);
---- tts_voice_overrides
--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
-- );
---- emotes
--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);
---- channel_sounds
--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);
---- media_requests
--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);
---- media_request_settings
--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
--);
---- moderation_actions
--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);
---- channel_moderators
--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);
---- channel_moderation_settings
--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
--);
---- relay_users
--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)
-- );
---- hidden_relay_users
--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);
---- pending_ip_messages
--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);
---- stream_first_chats
--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);
---- chat_ai_summaries
--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);
---- chat_timeline_events
--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);
---- linked_accounts
--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);
---- managed_streams
--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);
--
---- user_tags (created lazily by Live's game/tags.js)
--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)
-- );
---- Live-owned tables the importer reads (subjects, projections)
--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);
ddiff --git a/test/history-latency.test.js b/test/history-latency.test.js
iindex 16143fd..40bafba 100644
---- a/test/history-latency.test.js
++++ b/test/history-latency.test.js
@@@ -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 });
ddiff --git a/test/import-pg.test.js b/test/import-pg.test.js
ddeleted file mode 100644
iindex e1e0f8d..0000000
---- a/test/import-pg.test.js
++++ /dev/null
@@@ -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();
ddiff --git a/test/import.test.js b/test/import.test.js
ddeleted file mode 100644
iindex 152a308..0000000
---- a/test/import.test.js
++++ /dev/null
@@@ -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 }); });
ddiff --git a/test/message-deleted.test.js b/test/message-deleted.test.js
iindex 4e41c96..b9e7b32 100644
---- a/test/message-deleted.test.js
++++ b/test/message-deleted.test.js
@@@ -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 next
const { 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,
});
ddiff --git a/test/migrate-preferences.test.js b/test/migrate-preferences.test.js
ddeleted file mode 100644
iindex 27509e4..0000000
---- a/test/migrate-preferences.test.js
++++ /dev/null
@@@ -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(); });
ddiff --git a/test/n-1.test.js b/test/n-1.test.js
iindex f59f174..4d3358b 100644
---- a/test/n-1.test.js
++++ b/test/n-1.test.js
@@@ -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) };
ddiff --git a/test/n-1/boot-child.js b/test/n-1/boot-child.js
iindex 2074fc7..e74ac3a 100644
---- a/test/n-1/boot-child.js
++++ b/test/n-1/boot-child.js
@@@ -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');
ddiff --git a/test/n-1/engine.js b/test/n-1/engine.js
ddeleted file mode 100644
iindex 3f4a58e..0000000
---- a/test/n-1/engine.js
++++ /dev/null
@@@ -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 };
ddiff --git a/test/n-1/harness.js b/test/n-1/harness.js
iindex a2fd348..357d798 100644
---- a/test/n-1/harness.js
++++ b/test/n-1/harness.js
@@@ -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,
};
ddiff --git a/test/n-1/preload.js b/test/n-1/preload.js
iindex 8a06477..00b6e93 100644
---- a/test/n-1/preload.js
++++ b/test/n-1/preload.js
@@@ -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 { /* */ } });
}
ddiff --git a/test/no-sqlite.test.js b/test/no-sqlite.test.js
iindex 576aac4..2a518e0 100644
---- a/test/no-sqlite.test.js
++++ b/test/no-sqlite.test.js
@@@ -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();
ddiff --git a/test/rehearsal.test.js b/test/rehearsal.test.js
ddeleted file mode 100644
iindex ffa4307..0000000
---- a/test/rehearsal.test.js
++++ /dev/null
@@@ -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();
ddiff --git a/test/rehearsal/seed.sql b/test/rehearsal/seed.sql
ddeleted file mode 100644
iindex 3d12467..0000000
---- a/test/rehearsal/seed.sql
++++ /dev/null
@@@ -1,29 +0,0 @@
---- PostgreSQL seed for `ov rehearse` (declared in docs/cutover.md's rehearse block); test/fixtures/*.sql are SQLite fixtures for the import tests, not seeds.
----
---- Loaded on a fresh scratch database *after* main's migrations (migrations/0001_initial.sql,
---- 0002_audio_claim_owner.sql) and *before* this branch's migrations (0003_chat_ingress.sql), so it
---- may only name columns those two create and must not touch chat_ingress_applied (0003's table).
---- Plain PostgreSQL INSERTs, no SQLite syntax. The database is empty, so identity columns are left
---- to their defaults and the first dm_conversations row is id 1, which the child rows reference.
--
---- A stream's chat: two regular lines and a system notice.
--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);
--
---- One direct conversation between alice and bob (id 1), with its participants and two lines.
--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');
--
---- A bridge delivery already applied once (its idempotency key would return this result on a retry).
--INSERT INTO bridge_applied (key, result, applied_at) VALUES
-- ('rehearsal:seed:1', '{"ok":true}', 1788220800000);
ddiff --git a/test/rest.test.js b/test/rest.test.js
iindex 8f10d0d..e8001cb 100644
---- a/test/rest.test.js
++++ b/test/rest.test.js
@@@ -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(); });
ddiff --git a/test/sounds-tts.test.js b/test/sounds-tts.test.js
iindex 027bdba..6556fd0 100644
---- a/test/sounds-tts.test.js
++++ b/test/sounds-tts.test.js
@@@ -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(); });